Self-Hosting libSQL — A Reproducible Docker Compose Demo for Primary/Replica Replication and JWT Auth
libSQL, an extension of SQLite built for distributed and server use, powers Turso’s managed service, but its server implementation, sqld, also runs on your own infrastructure as-is. It comes with the whole package: write to a primary and read from a replica, multitenancy via namespaces, and JWT authentication.
The catch is that the moment you try to stand it up yourself, you run into several places where the docs or older write-ups disagree with how it behaves today. So I built a demo repository that reproduces the whole thing from a fresh clone with a single ./scripts/smoke-test.sh: libsql-self-hosting-demo. This post walks through its structure and the “current behavior” I was actually able to confirm during verification.
The starting point was virtala.dev’s libSQL self-hosting article. It is a solid Debian-based walkthrough as of 2025-02-03, but libSQL moves fast, and some of its assumptions no longer hold. The demo re-verifies the same ideas against the current stable libsql-server v0.24.32.
What the Demo Looks Like
The demo brings up two services, a primary and a replica, with Docker Compose, and accesses them over HTTP from a TypeScript CLI.
flowchart LR
CLI[TypeScript CLI<br/>@libsql/client]
subgraph compose[Docker Compose]
P[primary<br/>HTTP 18080 / gRPC 18081<br/>Admin API 18082]
R[replica<br/>HTTP 18083]
end
CLI -->|write + read<br/>JWT| P
CLI -->|read<br/>JWT| R
P -->|gRPC replication<br/>h2c| R
Admin[Admin API<br/>namespace creation] --> P
The primary takes writes, and the replica connects to the primary’s gRPC endpoint to pull data. From the client’s point of view, writes go to the primary’s HTTP, and reads can hit either the primary or the replica. All host ports bind to 127.0.0.1, keeping the whole thing local-only.
| Service | Purpose | Default |
|---|---|---|
| primary | HTTP API | 127.0.0.1:18080 |
| primary | gRPC replication | 127.0.0.1:18081 |
| primary | Admin API | 127.0.0.1:18082 |
| replica | HTTP API | 127.0.0.1:18083 |
It avoids common development ports like 3000 and 8080, settling on a less crowded range. Ports are overridable through .env, so you can adjust them without touching docker-compose.yml when something is already bound.
Pinning the Runtime and Apple Silicon
The first snag is choosing the image. The libsql/sqld repository the reference article used is already archived; the current distribution is consolidated under libsql-server in tursodatabase/libsql (the ghcr.io image).
Because reproducibility is the priority, the demo pins the image by immutable digest rather than a floating tag.
x-libsql-image: &libsql-image ${LIBSQL_IMAGE:-ghcr.io/tursodatabase/libsql-server@sha256:528e068844b4bc5b87fb128da87e98d361d3414c4e1cced7b943939248e0ed2f}
x-libsql-platform: &libsql-platform ${LIBSQL_PLATFORM:-linux/amd64}This is where Apple Silicon gets in the way. libsql-server has no digest-pinnable arm64 tag (upstream issue #899); the only thing shipping arm64 is the floating latest-arm. To satisfy both digest pinning and the no-floating-tag rule, the demo pins the amd64 digest and, on Apple Silicon, runs it under Rosetta emulation via platform: linux/amd64. Native arm64 is explicitly marked unverified.
The server’s core settings are passed as environment variables. Three of them differ between the primary and the replica:
SQLD_DB_PATH: where data is storedSQLD_GRPC_LISTEN_ADDR: only the primary listens on gRPCSQLD_PRIMARY_URL: only the replica points at the primary’s gRPC
The replica gets depends_on with condition: service_healthy, so it starts only after the primary’s healthcheck passes. Replication can still lag, so the smoke test below does not assume immediate visibility; it polls until the replica’s count catches up.
JWT Authentication
Authentication is a JWT signed with an Ed25519 key pair. Key generation is left to openssl.
openssl genpkey -algorithm Ed25519 -out .local/jwt/jwt.key
openssl pkey -in .local/jwt/jwt.key -pubout -out .local/jwt/jwt.pubThe public key is loaded into the server via SQLD_AUTH_JWT_KEY_FILE, and the client sends the signed token in the Authorization header.
Only the token signing moves out of shell, because assembling base64url by hand and feeding it to openssl pkeyutl -sign is fragile. It lives in a small Node.js script instead. scripts/gen-auth-token.sh is a thin wrapper; the real work is in sign-jwt.mjs, which uses the crypto module.
const header = { alg: "EdDSA", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = {
a: "rw",
iat: now,
exp: now + 60 * 60 * 24,
};
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(
JSON.stringify(payload),
)}`;
const key = createPrivateKey(readFileSync(keyPath));
const signature = sign(null, Buffer.from(signingInput), key).toString("base64url");The claim is a minimal development one: {"a":"rw"} plus iat and an exp one day out. The generated keys and tokens are strictly local development artifacts, not production-ready secrets. They are excluded by .gitignore and are never committed.
Note that the Admin API uses a separate auth scheme from this JWT. The key passed via --admin-auth-key is sent as Authorization: basic local-admin-key. Unlike standard HTTP Basic auth, it carries the raw key without Base64 encoding, which is worth keeping in mind.
Scenario 1: Default-Namespace Replication
The most straightforward and reliable path is replication on the default configuration, with no namespaces. Confirming that a primary write is readable from the replica first nails down the foundation.
The CLI is a thin layer built on @libsql/client. It writes to the primary and reads back from the replica.
pnpm --dir app start init --target primary
pnpm --dir app start insert --target primary --label hello
pnpm --dir app start read --target replicaThe client just switches its endpoint based on the target.
export function endpointFor(target: DemoTarget, namespace?: string): string {
const portEnv =
target === "primary" ? "PRIMARY_HTTP_PORT" : "REPLICA_HTTP_PORT";
const port = process.env[portEnv] ?? DEFAULT_PORTS[target];
const host = namespace ? `${namespace}.localhost` : "127.0.0.1";
return `http://${host}:${port}`;
}In this configuration, a row inserted into the primary becomes visible on the replica after a short delay. Replication is eventually consistent, so the smoke test polls until the count matches rather than reading once.
Scenario 2: Primary-Only Namespaces
libSQL does multitenancy through namespaces. Enabling them requires --enable-namespaces, and since that is a difference in the startup command flags rather than which services run, you cannot toggle it with profiles:. The demo layers a docker-compose.namespaced.yml override on top of the base docker-compose.yml.
docker compose -f docker-compose.yml -f docker-compose.namespaced.yml up -dNamespace creation and deletion go straight through the Admin API.
curl --fail-with-body --silent --show-error \
-X POST "http://127.0.0.1:18082/v1/namespaces/demo/create" \
-H "Authorization: basic local-admin-key" \
-H "Content-Type: application/json" \
--data '{}'Access to a created namespace is routed by host, via <namespace>.localhost. You target a namespace through a subdomain, like demo.localhost:18080. macOS and most modern browsers resolve *.localhost to 127.0.0.1, so it works with no extra configuration.
In this scenario, with no replica involved, the primary alone demonstrates three things:
- A valid token can access the namespace (auth success).
- An invalid token is rejected (auth failure).
- A nonexistent namespace errors out.
The smoke test treats it as a failure if the invalid token or the missing namespace succeeds, which guarantees the negative cases are genuinely rejected.
Scenario 3: Namespace-and-Replica Re-Verification
This is the point I most wanted to confirm: does a namespace created on the primary through the Admin API propagate to a gRPC replica? It is filed as upstream issue #1804, still open, and is treated as a known limitation that namespaces do not replicate.
So rather than tying this behavior to the build’s pass/fail, the demo records the actual result. It creates a namespace, writes through the primary, and tries to read from the replica.
if run_cli count --target replica --namespace tenant >out 2>err; then
echo "Namespace replica read succeeded: $(cat out)"
else
echo "Namespace replica read did not succeed; expected for #1804."
fiAs it turned out, with the v0.24.32 image pinned here, the namespaced replica read succeeded locally (Namespace replica read succeeded: 1). The upstream issue is still open, but at least in this version, propagation was observable.
This kind of ambiguity, “a documented limitation that nonetheless works in a specific version,” is not unusual. The demo makes the expected outcome switchable through an EXPECT_NAMESPACE_REPLICA environment variable: by default it treats the case as a limitation and keeps the build green, while setting pass flips it to “fail if it doesn’t work.” If a future version changes the behavior, the smoke test will surface it.
gRPC TLS Is Optional
The reference article put certificate generation and mutual TLS at the center of its steps, but the current replica can connect to the primary over plaintext gRPC (h2c) by default. To keep the most fragile step off the critical path, the demo relegates TLS to an optional appendix.
Certificate generation (scripts/gen-certs.sh) and mutual TLS only come into play when you layer in docker-compose.tls.yml.
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -dThe default flow needs no certificates, and ./scripts/setup.sh completes without them. The certificates it does generate are self-signed, short-lived, development-only, and likewise excluded from commits.
The Plumbing for Reproducibility
The whole point of this demo is that anyone can reach the same result from a fresh clone. To that end, every dependency is pinned.
- Docker image: pinned by an immutable
sha256digest, with no floating tags. - Local tools: mise pins Node.js
26.3.0, pnpm11.5.1, gitleaks8.30.1, lefthook2.1.9, and shellcheck0.11.0, with no reliance on global installs. - npm dependencies: exact versions in
package.json, withpnpm-lock.yamlcommitted as the source of integrity hashes.
Quality tooling is in place from the initial setup, too. gitleaks guards against accidentally committing keys and tokens, shellcheck covers the shell scripts, Biome handles TypeScript formatting and linting, and tsc --noEmit does type checking. lefthook runs those fast checks before commit.
The GitHub Actions CI has two jobs. A lint job runs gitleaks, shellcheck, Biome, tsc, and a frozen-lockfile install, and a smoke job runs the smoke test on an amd64 runner. On the amd64 runner the pinned digest runs natively, so it exercises all three scenarios without going through Rosetta.
The verified environment, versions, and digest are recorded in README.md and docs/notes.md.
Smoke Test and Cleanup
The three scenarios are bundled in scripts/smoke-test.sh, which recreates the containers and volumes before and after each scenario so every check starts from a clean slate.
./scripts/setup.sh
docker compose up -d
./scripts/smoke-test.shCleanup runs docker compose down -v --remove-orphans to tear down containers and volumes for both the base and namespaced configurations, then removes the generated local state under .local/. Because the data directory is created by the container (as root), the removal also goes through a container of the same image to avoid permission mismatches.
./scripts/cleanup.shThat returns things to a state where you can run the same steps again after cleanup.
Wrapping Up
The takeaways from this demo come down to the following.
libsql/sqldis archived; the current distribution is consolidated intolibsql-serverintursodatabase/libsql.- There is no digest-pinnable
arm64image, so on Apple Silicon the way to keep reproducibility is to pin the amd64 digest and run it under Rosetta. - Default-namespace replication works reliably. Namespace-to-replica propagation (#1804) is treated as a limitation, but it succeeded locally on
v0.24.32. - gRPC TLS can be optional in the current version; the default connects over plaintext h2c.
- Pinning the digest, the tools, and the npm dependencies, and re-verifying all three scenarios from a clean slate in the smoke test, lets a fresh clone reach the same result.
The hardest part of self-hosting is closing, one by one, the gaps between the docs and how things behave today. Freeze a working configuration together with its scripts and its digest, and the version of you six months from now (or another team member) avoids stepping into the same potholes.
That’s all from the field, where I’ve been rebuilding a minimal libSQL self-hosting setup into a reproducible form.