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.
Deploy targets
Section titled “Deploy targets”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 twoVAULT_OIDC_*values andKUUKURA_VERSIONin the Environment tab, add the domain (servicekuukura, port 8080, HTTPS) in the Domains tab. - Dokploy, template:
template.toml,docker-compose.yml,kuukura.svgand the entry inmeta.jsonare the blueprint for a pull request toDokploy/templates(blueprints/kuukura/). Once merged, pick “kuukura” in the template list, fill the twoVAULT_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 setVAULT_DOMAINin the Environment tab to the same domain. The Dokploy repository wants a pinned image, so the pull request setsKUUKURA_VERSIONandmeta.jsonversionto a release tag; here they staylatest. To back up the key, open the service’s terminal (sh) and run/ku-server key show. - Coolify: add
deploy/coolify/kuukura.yamlas a Docker Compose service (or submit it to Coolify’s service templates), fillVAULT_OIDC_CLIENT_IDandVAULT_OIDC_CLIENT_SECRETwhen asked. Coolify assigns the domain (VAULT_DOMAINis set fromSERVICE_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 thesync: falsevalues (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 onfree). The server listens on Render’s default port 10000 (VAULT_ADDR=:10000).image.urlends inlatest; 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 beforedocker 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.
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 latestUpgrading: 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.
1. Google login
Section titled “1. Google login”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.
2. The master key
Section titled “2. The master key”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.
The three ways to provide it
Section titled “The three ways to provide it”- 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. - 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 32makes one). Anyone who can read the container’s environment can read the key. - 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 KMSEncrypt/Decryptwith 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 showsays “not applicable” here.
Generate mode (the easy path)
Section titled “Generate mode (the easy path)”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-serverprincipal (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):
mkdir -p secretsdocker run --rm -v "$PWD/secrets:/s" ghcr.io/victorlunam/kuukura key init --key-file /s/master_keyFile 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.)
Back it up: key show, once per version
Section titled “Back it up: key show, once per version”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:
docker exec -it <container> /ku-server key showIt 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 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.
Restoring
Section titled “Restoring”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.
Rotating
Section titled “Rotating”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).
3. Database
Section titled “3. Database”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.
- Create an empty database and a table owner role (any role that can create tables in it).
- Start kuukura once with
VAULT_DATABASE_URLset to the owner’s URL (orVAULT_MIGRATE_DATABASE_URL; they may be the same on this first run). It runs the migrations and creates the group rolekuukura_app(NOLOGIN) holding the right grants (INSERT and SELECT only onaudit_log).servethen stops with an error, by design: the owner may modifyaudit_log, and the server refuses to run as such a role. To migrate without that error, runku-server audit verifywith the owner’s URL instead: it migrates and changes nothing else. Both need the master key configured (section 2), like everyku-servercommand. - Create the login role the server will use: a member of
kuukura_appthat 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; - Start for real with
VAULT_MIGRATE_DATABASE_URL= the owner’s URL (used for migrations only) andVAULT_DATABASE_URL= the login role’s URL. On every start the server checks that its role can neither modifyaudit_lognor become the table owner, and refuses to start otherwise. KeepVAULT_MIGRATE_DATABASE_URLset on every upgrade: without it the login role tries to migrate and fails with “permission denied for schema public”.
4. Configure and start
Section titled “4. Configure and start”| 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):
docker compose exec kuukura /ku-server user invite you@example.com --admindocker compose exec kuukura /ku-server user listuser 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.
6. Using it
Section titled “6. Using it”ku profile add work --url https://vault.example.com # the profile holds the server URLku login # signs in to the active profileku secret set DATABASE_URL app:shop # value from a hidden promptku workspace grant dev@example.com write app:shopku link shop && ku run env:dev -- npm run devCI: 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:
- give its HTTPS listener a certificate for the domain (ACM on AWS);
- turn HTTPS off for the domain in the platform (the load balancer does TLS; otherwise the platform redirects forever);
- let Traefik keep the load balancer’s
X-Forwarded-Proto: in the Traefik config addentryPoints.web.forwardedHeaders.trustedIPswith 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.
Verifying a release
Section titled “Verifying a release”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) andchecksums.txton 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):
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/kuukuragh 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 tagcosign 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 checksumssha256sum --check --ignore-missing checksums.txtFor 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.
Operations
Section titled “Operations”- 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 verifyon a schedule (weekly, and after any incident). It printsOK: 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.