Skip to content

Getting started

Lockwell is an object store you host yourself. Your app talks to it through one SDK: files in, files out, per-customer isolation built in.

This page gets you to a working result: run the server, store a file, read it back, and let a browser upload directly. Concepts can wait; when a term is new, Concepts in plain words defines it in a sentence.

1. Run Lockwell

One container, one volume, no external database.

sh
git clone https://github.com/KelpHect/lockwell.git
cd lockwell
cp .env.example .env
docker compose up -d --build

Before up, open .env and replace the two CHANGE_ME values with long random strings. They become your first access key, and the container refuses to start while the placeholders are still in place.

Check it is alive:

sh
curl -fsS http://localhost:9000/health

You now have two listeners and one credential:

  • http://localhost:9000: the public listener (object I/O)
  • http://localhost:9001: the admin listener (management, kept private)
  • the access key from .env, already bootstrapped with full data access

Already running Lockwell somewhere? Skip ahead and use your own endpoint and key.

2. Install the SDK

sh
npm i @kelphect/sdk
sh
go get github.com/lockwell/lockwell/pkg/lockwellnative
sh
# com.lockwell:lockwell-sdk on GitHub Packages. See Installation for the
# repository + auth setup.

Registries, prerequisites, and client options live on the Installation page.

3. Store a file and read it back

Point the native client at the public listener with the key from .env. It mints and refreshes its own short-lived token; you never touch auth plumbing.

ts
import { NativeClient } from "@kelphect/sdk";

const native = new NativeClient({
  endpoint: "http://localhost:9000",
  accessKeyId: process.env.LOCKWELL_ROOT_ACCESS_KEY_ID,
  secretKey: process.env.LOCKWELL_ROOT_SECRET_KEY,
});

await native.createBucket("inbox");
await native.putObject("inbox", "hello.txt", "hi there", { contentType: "text/plain" });

const got = await native.getObject("inbox", "hello.txt");
console.log(got.body.toString()); // "hi there"
go
package main

import (
 "context"
 "io"
 "log"
 "os"
 "strings"

 "github.com/lockwell/lockwell/pkg/lockwellnative"
)

func main() {
 ctx := context.Background()

 native, err := lockwellnative.New("http://localhost:9000",
  os.Getenv("LOCKWELL_ROOT_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_ROOT_SECRET_KEY"))
 if err != nil {
  log.Fatal(err)
 }

 if _, err := native.CreateBucket(ctx, lockwellnative.CreateBucketInput{Name: "inbox"}); err != nil {
  log.Fatal(err)
 }
 if _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{
  Bucket: "inbox", Key: "hello.txt",
  Body: strings.NewReader("hi there"), ContentType: "text/plain",
 }); err != nil {
  log.Fatal(err)
 }

 obj, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "inbox", Key: "hello.txt"})
 if err != nil {
  log.Fatal(err)
 }
 defer obj.Close()
 body, _ := io.ReadAll(obj)
 log.Println(string(body)) // "hi there"
}
java
import com.lockwell.sdk.nativeapi.LockwellNativeClient;
import com.lockwell.sdk.nativeapi.NativeTypes.GetResult;
import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions;

var native = LockwellNativeClient.builder()
    .endpoint("http://localhost:9000")
    .accessKeyId(System.getenv("LOCKWELL_ROOT_ACCESS_KEY_ID"))
    .secretKey(System.getenv("LOCKWELL_ROOT_SECRET_KEY"))
    .build();

native.createBucket("inbox");
native.putObject("inbox", "hello.txt", "hi there".getBytes(),
    new PutOptions().contentType("text/plain"));

try (GetResult got = native.getObject("inbox", "hello.txt")) {
    got.body().transferTo(System.out); // "hi there"
}

That is a private, encrypted object store taking writes. Streaming, ranges, listing, versions, and the rest of the object API start at Upload & download.

4. Let the browser upload directly

Sign a short-lived PUT URL on your server and hand only the URL to the browser. The file bytes go straight to Lockwell, and your key never leaves the server.

ts
// Server-side: the signed URL comes back as a path; join it to the endpoint.
const signed = await native.signUrl({
  method: "PUT",
  bucket: "inbox",
  key: "photo.jpg",
  ttlSeconds: 300,
});
const url = new URL(signed.url, "http://localhost:9000").toString();

// Browser-side:
await fetch(url, { method: "PUT", body: file });
go
// SignURL returns an absolute URL in Go.
url, err := native.SignURL(ctx, lockwellnative.SignURLInput{
 Method: "PUT", Bucket: "inbox", Key: "photo.jpg", TTLSeconds: 300,
})
if err != nil {
 log.Fatal(err)
}
// Return url to the browser, which PUTs the file body to it.
java
// The signed URL comes back as a path; join it to the endpoint.
var signed = native.signUrlResult("PUT", "inbox", "photo.jpg", 300);
var url = java.net.URI.create("http://localhost:9000").resolve(signed.url()).toString();
// Return url to the browser, which PUTs the file body to it.

The URL is bound to one method and one object, expires on its TTL, and can never exceed the signing key's scope. The full flow, including the download direction, is on Signed URLs.

That's it

You ran the server, stored a file, read it back, and took a browser upload. One SDK, no second store, no glue code.

Going multi-tenant

The quickstart used the bootstrap root key. For a real app you give each customer their own tenant and a key scoped to it, so customers are isolated by the credential itself.

That is the job of the admin API and the app kit (provisionTenant, clientForTenant). The admin API needs an admin token, which is minted offline because the CLI and the daemon cannot hold the embedded store at the same time:

sh
docker compose stop lockwell
docker compose run --rm lockwell lockwell admin-token create --name my-app --role owner
docker compose start lockwell
# prints an lwadm_... token exactly once. Store it as LOCKWELL_ADMIN_TOKEN.

Then follow The app kit for the provision-and-go flow, and Tenancy & auth for the model behind it.

Next steps

Released under the Apache-2.0 License. License