Skip to content

Deploying kuukura (self-host with Docker)

One instance per organization. The server is one static binary in a small image; put an HTTPS reverse proxy (Dokploy/Traefik, Caddy, …) in front of it. The server refuses requests that did not arrive over HTTPS (it trusts X-Forwarded-Proto and X-Forwarded-For only from private network addresses, i.e. your proxy on the Docker network). All values below are fake examples.

kuukura is deployed from the published image ghcr.io/victorlunam/kuukura. Releases are tagged YEAR.MONTH.N (the list is on the releases page); the tag latest is the newest release. The templates default to latest: set KUUKURA_VERSION to a release tag to pin one, which you should do in production. Nothing is built on your server.

Target File TLS Master key
Docker Compose + Caddy (any VPS) deploy/docker/docker-compose.yml Caddy (Let’s Encrypt) Docker secret file
Dokploy (Compose) deploy/dokploy/docker-compose.yml Dokploy’s Traefik generated into the kuukura-keys volume
Dokploy (template) deploy/dokploy/ template.toml + meta.json Dokploy’s Traefik generated into the kuukura-keys volume
Coolify deploy/coolify/kuukura.yaml Coolify’s proxy generated into the kuukura-keys volume
Render render.yaml Render VAULT_MASTER_KEY, generated by Render
Docker Swarm / Portainer stack deploy/swarm/stack.yml your proxy Swarm secret
Plain docker run below your proxy generated into a /keys volume, or a Docker secret file

How to use each template:

  • Dokploy, Compose: Create Service, Compose, paste the file, set VAULT_DOMAIN, the two VAULT_OIDC_* values and KUUKURA_VERSION in the Environment tab, add the domain (service kuukura, port 8080, HTTPS) in the Domains tab.
  • Dokploy, template: template.toml, docker-compose.yml, kuukura.svg and the entry in meta.json are the blueprint for a pull request to Dokploy/templates (blueprints/kuukura/). Once merged, pick “kuukura” in the template list, fill the two VAULT_OIDC_* values in the Environment tab before the first deploy (the server stops with “VAULT_OIDC_CLIENT_ID and VAULT_OIDC_CLIENT_SECRET are required” while they are empty). Dokploy creates the template’s domain as a generated host without a certificate: in the Domains tab set your real domain and turn HTTPS on (Let’s Encrypt), and set VAULT_DOMAIN in the Environment tab to the same domain. The Dokploy repository wants a pinned image, so the pull request sets KUUKURA_VERSION and meta.json version to a release tag; here they stay latest. To back up the key, open the service’s terminal (sh) and run /ku-server key show.
  • Coolify: add deploy/coolify/kuukura.yaml as a Docker Compose service (or submit it to Coolify’s service templates), fill VAULT_OIDC_CLIENT_ID and VAULT_OIDC_CLIENT_SECRET when asked. Coolify assigns the domain (VAULT_DOMAIN is set from SERVICE_FQDN_KUUKURA, the domain without a scheme). Open the service’s terminal (sh) once and run /ku-server key show.
  • Render: “New Blueprint Instance” from this repository’s render.yaml. Render asks for the sync: false values (VAULT_DOMAIN, the two OIDC values); everything else is set. On Render the key lives in Render’s environment (VAULT_MASTER_KEY, generated once as a bare key): read it from the Environment tab and store a copy in your password manager; anyone with access to the service’s settings can read it. Render has one persistent disk, so one instance only, on a paid plan (0.5c-512mb; a disk is not available on free). The server listens on Render’s default port 10000 (VAULT_ADDR=:10000). image.url ends in latest; replace it with a release tag to pin one.
  • Compose + Caddy, Swarm: see the comments at the top of each file. The Swarm stack needs the external overlay network proxy (and the two secrets) to exist before docker stack deploy.

For every target, VAULT_DOMAIN must be the domain the people use (it is also the passkey RP ID), and the Google redirect URI is https://<domain>/auth/callback.

Terminal window
docker run -d --name kuukura --restart unless-stopped -p 127.0.0.1:8080:8080 \
-v kuukura-data:/data -v kuukura-keys:/keys \
-e VAULT_DOMAIN=vault.example.com \
-e VAULT_MASTER_KEY_FILE=/keys/master_key -e VAULT_MASTER_KEY_GENERATE=true \
-e VAULT_OIDC_CLIENT_ID=... -e VAULT_OIDC_CLIENT_SECRET=... \
ghcr.io/victorlunam/kuukura:latest # in production use a release tag instead of latest

Upgrading: with latest, pull and redeploy (docker compose pull && docker compose up -d); with a pinned KUUKURA_VERSION, change it first and read CHANGELOG.md. Migrations run at start; back up the /data volume (or the database) first. ku-server version prints the running version.

Local testing without a domain: local-testing.md.

Create an OAuth client (type “Web application”) in Google Cloud. Authorized redirect URI: https://vault.example.com/auth/callback. Keep the client ID and secret for step 4.

The master key (KEK) wraps every secret’s data key. It is held only in server memory. There is no unseal command: the server reads the key at start and refuses to start without it, and also refuses a key that cannot open the data already in the database (“the master key does not match the data (key manual:v1 cannot open it)”).

Why it lives outside the database and outside /data

Section titled “Why it lives outside the database and outside /data”

Encryption at rest only means something if the key is not stored next to the data. A database dump, a copy of the /data volume or a backup of either must not carry the key; otherwise a stolen backup is a stolen vault. So the key lives in its own place (a file in a separate /keys volume, a Docker secret, an environment variable, or KMS) and is backed up separately. Back up /data and /keys as two different things, to two different places.

  1. A key file (VAULT_MASTER_KEY_FILE=/keys/master_key, or a Docker secret such as /run/secrets/master_key). The text is one line per version, 1:<base64 of 32 bytes>; the highest version is active.
  2. An environment variable (VAULT_MASTER_KEY), for platforms without secret files. The value is the same text, or just one bare key: 44 characters of base64 or 64 hex characters, which counts as version 1 (openssl rand -base64 32 makes one). Anyone who can read the container’s environment can read the key.
  3. AWS KMS (VAULT_KEY_PROVIDER=aws-kms, VAULT_KMS_KEY_ARN=arn:aws:kms:... and the usual AWS credentials and region variables). The key never leaves KMS; data keys are wrapped by KMS Encrypt/Decrypt with the AAD as encryption context. Keep KMS key deletion protection (and a long deletion window) on: deleting the KMS key makes every secret unreadable. key show says “not applicable” here.

Set VAULT_MASTER_KEY_GENERATE=true together with VAULT_MASTER_KEY_FILE=/keys/master_key and mount a volume at /keys (the image has the directory, owned by the server’s user). At serve start, if the file is missing:

  • and the database holds no encrypted data (no secret versions, no authenticator secrets), the server creates the key (directory 0700, file 0600, never over an existing file), records it in the audit log under the ku-server principal (the key itself is never logged or audited) and logs a warning that the key has no backup yet;
  • and the database does hold encrypted data, it refuses to start: “the master key file … is missing but the database holds encrypted data; restore the key from your backup”. It never generates a key over existing data.

Without VAULT_MASTER_KEY_GENERATE, a missing key file stops the server, as before. To make a key file yourself instead (it is never printed):

Terminal window
mkdir -p secrets
docker run --rm -v "$PWD/secrets:/s" ghcr.io/victorlunam/kuukura key init --key-file /s/master_key

File permissions (Linux hosts): the container runs as user 65532, so a key file that is 0600 and owned by you is not readable inside it. Make the secret files belong to that user and keep them private: sudo chown 65532:65532 secrets/master_key secrets/oidc_client_secret && sudo chmod 0400 secrets/master_key secrets/oidc_client_secret. (Docker Desktop and OrbStack map ownership for you; Swarm secrets are mounted readable by the service already.)

Without the key the data cannot be read, and nobody can recover it for you. Right after the first start, open the service’s terminal in Dokploy or Coolify (the image includes a minimal busybox sh for this) and run /ku-server key show. From a host, the same thing:

Terminal window
docker exec -it <container> /ku-server key show

It prints the key text once, only to a real terminal (it refuses a pipe or a log), and records that it did, under the ku-server principal. Store the text in your password manager. A second key show for the same key version refuses (“the key was already shown on . The key file … is the source of truth”); after a rotation the new version can be shown once as well, so every version has a backup. Until the active version has been shown, serve logs a warning at every start.

Anyone with this key and a copy of the database can read every secret. If you want an encrypted file instead of a password-manager entry, encrypt the text with your own tool (age, gpg, sops).

“Shown once” is a usability guard, not a security boundary: it keeps the key out of scrollback and logs and makes you store it, but whoever has host access can read the key file (or the environment) directly. The file is the source of truth; copy it from the volume with host access if you need it again.

Put the saved text into the key file (VAULT_MASTER_KEY_FILE; for a generated key, the kuukura-keys volume) or into VAULT_MASTER_KEY, with the same database. Include every version the data might still use (the whole ring key show printed). If the key is wrong or a version is missing, the server stops at start and says which key id could not open the data. Restoring onto an empty database needs nothing special; with generate mode on and the file missing, a database that already holds data refuses to start instead of getting a new key.

ku-server key rotate adds a version to a writable key file and rewraps everything to it in one transaction; see the runbook (including the two-step path for a key in an environment variable).

SQLite (default): nothing to do. Data lives in the /data volume (WAL mode, single writer). Back up the volume however you back up Docker volumes (backups are out of scope for kuukura itself).

Postgres: VAULT_STORAGE=postgres. Audit rows must be insert-only, which Postgres enforces with grants, so use two roles, and mind the order: the login role can only be created after the group role it joins exists, and that role is created by the first start.

  1. Create an empty database and a table owner role (any role that can create tables in it).
  2. Start kuukura once with VAULT_DATABASE_URL set to the owner’s URL (or VAULT_MIGRATE_DATABASE_URL; they may be the same on this first run). It runs the migrations and creates the group role kuukura_app (NOLOGIN) holding the right grants (INSERT and SELECT only on audit_log). serve then stops with an error, by design: the owner may modify audit_log, and the server refuses to run as such a role. To migrate without that error, run ku-server audit verify with the owner’s URL instead: it migrates and changes nothing else. Both need the master key configured (section 2), like every ku-server command.
  3. Create the login role the server will use: a member of kuukura_app that neither owns the tables nor is a member of the role that does:
    CREATE ROLE kuukura_login LOGIN PASSWORD 'change-me' IN ROLE kuukura_app;
  4. Start for real with VAULT_MIGRATE_DATABASE_URL = the owner’s URL (used for migrations only) and VAULT_DATABASE_URL = the login role’s URL. On every start the server checks that its role can neither modify audit_log nor become the table owner, and refuses to start otherwise. Keep VAULT_MIGRATE_DATABASE_URL set on every upgrade: without it the login role tries to migrate and fails with “permission denied for schema public”.
Variable Meaning
VAULT_NAME Optional instance name for the page header, e.g. neodev (max 32 plain characters). Defaults to the domain. Pages opened from the CLI show the user’s profile name instead.
VAULT_DOMAIN Bare domain, e.g. vault.example.com. Also the passkey RP ID. Changing it invalidates every passkey (users re-enroll after ku-server user reset-factors). A leading https:// and a trailing slash are tolerated and removed; a path is an error.
VAULT_ADDR Listen address, default :8080.
VAULT_STORAGE sqlite or postgres.
VAULT_SQLITE_PATH SQLite file (image default /data/kuukura.db).
VAULT_DATABASE_URL Postgres URL of the app login role.
VAULT_MIGRATE_DATABASE_URL Postgres URL of the table owner, used for migrations only (defaults to VAULT_DATABASE_URL).
VAULT_KEY_PROVIDER manual (default) or aws-kms.
VAULT_MASTER_KEY_FILE Path of the key file, e.g. /keys/master_key or /run/secrets/master_key (preferred).
VAULT_MASTER_KEY_GENERATE true with VAULT_MASTER_KEY_FILE: create the key file at start if it is missing and the database has no encrypted data (see the master key).
VAULT_MASTER_KEY The key as an env var: the key file text, or one bare key (44 base64 characters or 64 hex characters, version 1). Visible to anyone who can read the container’s environment.
VAULT_KMS_KEY_ARN Key ARN for aws-kms.
VAULT_OIDC_CLIENT_ID Google OAuth client ID.
VAULT_OIDC_CLIENT_SECRET_FILE / VAULT_OIDC_CLIENT_SECRET Google OAuth client secret (file preferred).

Configuration is validated at startup; a bad or missing value stops the server with a plain message (values are never printed). See the files under deploy/ for complete examples. Per-platform steps are in the table of deploy targets at the top.

Monitoring: GET /healthz (no auth, no details; the image has a HEALTHCHECK using ku-server healthcheck) and JSON logs on stdout (request lines with method, route, status, timing; never query strings, headers, bodies or values).

Shutdown is graceful (SIGTERM, 15 s). After a restart the key file is read again; there is nothing to unseal.

5. People from the host: first admin, lost factors

Section titled “5. People from the host: first admin, lost factors”

From a terminal on the host (ku-server help prints the whole command tree; every state change is audited under the reserved ku-server principal, a disabled machine that can never sign in):

Terminal window
docker compose exec kuukura /ku-server user invite you@example.com --admin
docker compose exec kuukura /ku-server user list

user invite makes a member unless you pass --admin. It prints a single-use link valid for 15 minutes, bound to that Google account. Open it, sign in with Google, then register a passkey (or an authenticator app). You get 10 recovery codes, shown once: store them. The link is never logged and never written to the audit log. There is no HTTP bootstrap and no allowed-domain sign-up: people join only through invites (ku workspace invite someone@example.com).

Lost every passkey and authenticator app, and the recovery codes? Run ku-server user reset-factors you@example.com. In one transaction it deletes their factors and recovery codes, ends their sessions and puts them back into enrollment, and prints a new 15-minute link: sign in with Google, register a new passkey or authenticator app, store the new recovery codes. Also: user disable <email|machine> and user enable <email|machine> (the last active admin cannot be disabled). Admins have the same pair as ku workspace disable <identity> and ku workspace enable <identity>.

Errors: ku-server prints one line per problem on stderr, ku-server: <A sentence.>, and exits 1. Wrong arguments print the command’s own Usage: line and exit 2 (ku uses the same two codes). ku-server help lists every command.

Terminal window
ku profile add work --url https://vault.example.com # the profile holds the server URL
ku login # signs in to the active profile
ku secret set DATABASE_URL app:shop # value from a hidden prompt
ku workspace grant dev@example.com write app:shop
ku link shop && ku run env:dev -- npm run dev

CI: create a machine token with ku workspace invite ci-deploy --machine (shown once), grant it read on the tags it needs, and store it as KU_TOKEN next to KU_URL. A machine can do only what both its own grants and its owner’s grants allow, and can never reveal a value.

Confirmations (reveal, --out, --authorize off, secrets marked requires_authorize) open a browser page on your instance and use what your account has: your passkey (with user verification) or a code from your authenticator app.

Troubleshooting: a load balancer in front of the platform

Section titled “Troubleshooting: a load balancer in front of the platform”

If the domain resolves to a cloud load balancer (an AWS ALB, a GCP or Azure load balancer) that terminates TLS and forwards plain HTTP to Dokploy’s or Coolify’s Traefik, you get the same two symptoms as below: the HTTPS page while HTTPS is off in the platform, and a redirect loop once it is on. Check with dig +short <domain>: a *.elb.amazonaws.com name means an ALB. Either point the domain straight at the platform’s server (simplest; the platform’s Let’s Encrypt then works), or keep the load balancer and:

  1. give its HTTPS listener a certificate for the domain (ACM on AWS);
  2. turn HTTPS off for the domain in the platform (the load balancer does TLS; otherwise the platform redirects forever);
  3. let Traefik keep the load balancer’s X-Forwarded-Proto: in the Traefik config add entryPoints.web.forwardedHeaders.trustedIPs with the load balancer’s private range (e.g. 10.0.0.0/8), restart.

kuukura trusts X-Forwarded-Proto only from private addresses, which is what Traefik is from its point of view.

Troubleshooting: HTTPS behind Cloudflare (or another CDN)

Section titled “Troubleshooting: HTTPS behind Cloudflare (or another CDN)”

kuukura only answers requests that reached your proxy over HTTPS. With Cloudflare’s proxy (orange cloud) in front of Dokploy, Coolify or Caddy, the SSL/TLS mode decides what your server sees:

Symptom Cause Fix
“This kuukura server only works over HTTPS” although the browser shows https:// Cloudflare SSL mode Flexible: Cloudflare talks to your server over plain HTTP DNS only for this hostname (below)
ERR_TOO_MANY_REDIRECTS after turning HTTPS on in the platform Flexible again: the platform redirects HTTP to HTTPS, Cloudflare asks over HTTP again, forever Same

Recommended: DNS only (grey cloud) for the vault’s hostname. A CDN proxy terminates TLS, so it sees every value that passes (reveals, ku run). Turn the proxy off for this one record, set the platform’s certificate to Let’s Encrypt, and the browser and CLI talk to your server directly. It changes nothing for your other hostnames.

If you keep the proxy, do not change the zone-wide SSL mode (it applies to every hostname in the zone): add a Configuration Rule for this hostname only (Rules → Configuration Rules, “Hostname equals vault.example.com”, SSL: Full (strict)). Full (strict) needs a valid certificate on the server: Let’s Encrypt in the platform (not “none”) or a Cloudflare Origin Certificate. Never use Flexible: the hop to your server would carry secrets unencrypted.

Every release is built by the release workflow of this repository from the tagged commit. It publishes:

  • Build provenance (a signed SLSA attestation) for the image and for each CLI archive.
  • A keyless cosign signature on the image, made with the workflow’s GitHub OIDC identity.
  • An SBOM of the image (kuukura-image.spdx.json, SPDX) and checksums.txt on the GitHub release.

The signature and the attestation name the image by its digest, so check what you actually pull. Set v to the release you run (the tag without the leading v, as listed on the releases page):

Terminal window
v=YEAR.MONTH.N
# provenance of the image and of a CLI archive (needs the GitHub CLI, signed in)
gh attestation verify oci://ghcr.io/victorlunam/kuukura:$v --repo victorlunam/kuukura
gh attestation verify kuukura_${v}_linux_amd64.tar.gz --repo victorlunam/kuukura
# the cosign signature (cosign v3 or later: the signature is a Sigstore bundle attached to the image)
# the issuer is GitHub Actions, the identity is this repository's release workflow at that tag
cosign verify ghcr.io/victorlunam/kuukura:$v \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/victorlunam/kuukura/.github/workflows/release.yml@refs/tags/v$v
# the archives against the published checksums
sha256sum --check --ignore-missing checksums.txt

For production, pin the image by digest (ghcr.io/victorlunam/kuukura@sha256:...) after verifying it. These checks prove that the artifact came from this repository’s workflow at that tag; they do not prove the source has no bugs.

  • Key rotation: see runbook-key-rotation-drill.md. Run the drill quarterly.
  • Audit log integrity: the log is a hash chain, but nothing checks it while the server runs. Run docker compose exec kuukura /ku-server audit verify on a schedule (weekly, and after any incident). It prints OK: audit log verified, <rows> rows, head <hash> and exits non-zero if a row was edited or removed in the middle. Store the row count and head hash somewhere the database’s operator cannot edit: that is the only way to notice that the newest rows were cut off.
  • Agents: see agents.md for what is and is not protected.
  • Security review: asvs-l2.md and threat-model.md.