Skip to content

Deployment

Secrets

.kamal/secrets is the plain-text glue between your secret store and deploy.yml. It’s read on the control machine at deploy time and values are resolved there. The file itself is never under git — the whole point is that it’s the bridge between “secrets that live in a vault” and “values the deploy flow needs at runtime.”

You’ll learn:

  • The format of .kamal/secrets
  • How $(...) subshell substitution pulls values from any CLI tool
  • The five built-in adapters for common vaults
  • How secrets flow from .kamal/secrets through deploy.yml at deploy time — and which leg of that flow is still pending

.kamal/secrets is a shell-compatible file with KEY=value lines. Comments start with #. Blank lines are ignored.

.kamal/secrets (illustrative — do not type)
# Registry
KAMAL_REGISTRY_PASSWORD=ghp_abc123...
# App
DATABASE_URL=postgres://user:pass@db.example.com/app
WHEELS_RELOAD_PASSWORD=choose-something-long

The keys on the left are referenced from two places in deploy.yml:

  • registry.password: — an array of keys the registry login uses. This works today.
  • env.secret: — an array of keys delivered into the app (and accessory) container environment via a host-side env file. Works on develop (4.0.4+) builds; the released 4.0.3 silently drops these entries (#2957).

At deploy time, wheels deploy reads .kamal/secrets, resolves every $(...), and looks up each name the deploy flow asks for. A ${VAR} interpolation key that’s missing from .kamal/secrets resolves to an empty string, so a typo’d key there fails downstream rather than at resolution time. Two guard rails on develop builds: a registry login whose password resolves empty fails fast with DeployRegistryCli.MissingPassword, and an env.secret name with no resolvable value fails fast with Wheels.Deploy.EnvSecretMissing (naming the missing keys, never values) before anything touches a host.

For multi-environment deploys, .kamal/secrets.<destination> is merged on top of .kamal/secrets when you pass --destination. Keys in the overlay win.

.kamal/secrets.production (illustrative — do not type)
DATABASE_URL=$(op read op://Production/App/database-url)
.kamal/secrets.staging (illustrative — do not type)
DATABASE_URL=$(op read op://Staging/App/database-url)

Inside .kamal/secrets, anything on the right-hand side is passed through bash, so the usual $(command) substitution works. This is how you pull live values from your secret store:

.kamal/secrets (illustrative — do not type)
# 1Password CLI
DATABASE_URL=$(op read op://Production/App/database-url)
# Bitwarden CLI
DATABASE_URL=$(bw get password db-url)
# AWS Secrets Manager
DATABASE_URL=$(aws secretsmanager get-secret-value --secret-id prod/db-url --query SecretString --output text)
# Doppler
DATABASE_URL=$(doppler secrets get DATABASE_URL --plain)
# Flat file on the control machine
WHEELS_MASTER_KEY=$(cat ~/.wheels/master.key)

$(...) runs once per deploy on the machine running wheels deploy. The vault is the system of record; .kamal/secrets is a glue layer.

Five named adapters are available behind the wheels deploy fetch-secrets verb. They’re a shorthand over the raw $(...) substitution — preferring whichever CLI tool you already use.

AdapterAliasesBacking tool
1Passwordop, 1passwordop CLI
Bitwardenbw, bitwardenbw CLI
AWS Secretsawsaws secretsmanager
LastPasslastpass, lpasslpass CLI
Dopplerdopplerdoppler CLI

Fetches a set of keys from an adapter and prints them as KEY=VALUE lines — the format .kamal/secrets accepts via $(...).

wheels deploy fetch-secrets (illustrative — requires vault credentials)
wheels deploy fetch-secrets --adapter=op --from=op://Production/App DATABASE_URL WHEELS_MASTER_KEY

Prints:

output (illustrative)
DATABASE_URL=postgres://...
WHEELS_MASTER_KEY=abc123...

Common pattern: wrap a fetch-secrets call in .kamal/secrets to pull multiple keys at once without repeating $(...):

.kamal/secrets (illustrative — do not type)
$(wheels deploy fetch-secrets --adapter=op --from=op://Production/App DATABASE_URL WHEELS_MASTER_KEY)

Extracts a single key from a KEY=VALUE block. Useful inside hooks or wrapper scripts. The key is a positional argument and the block arrives via --from= — the verb does not read stdin, so piping into it doesn’t work.

wheels deploy extract-secrets (illustrative)
wheels deploy extract-secrets B --from="$(wheels deploy fetch-secrets --adapter=op --from=op://Production/App A B)"
# prints the value of B

Prints your project’s fully resolved .kamal/secrets as KEY=VALUE lines. All $(...) subshells expand; every referenced key is emitted.

wheels deploy print-secrets (illustrative — requires resolved .kamal/secrets)
wheels deploy print-secrets

Useful for auditing what the deploy flow will actually see. Run it in private — the output is plaintext secrets.

secrets flow (illustrative — current Phase 1 behavior)
vault (1Password / Bitwarden / AWS / LastPass / Doppler / flat files)
│ $(op read ...) ← .kamal/secrets evaluates once, on the control machine
.kamal/secrets (KEY=VALUE, in-memory only)
│ registry.password: in deploy.yml names the key
docker login on each host

How the password reaches docker login depends on your CLI build: on CLIs built from develop (4.0.4+) it is fed over stdin (docker login --password-stdin) and never appears in the command string; the released 4.0.3 still passes it inline via -p <password>, where it is visible in the remote host’s process table and in --dry-run output (#2956, fixed by #3008).

The second leg — env.secret: keys landing in the container environment — works on develop (4.0.4+) builds: resolved values are written to .kamal/apps/<service>/env/roles/<role>.env (accessories: .../env/accessories/<name>.env) on each host. The file is created and locked to 600 permissions before any content lands (and re-locked to 600 immediately after the upload), the content travels over SFTP (never a command line), and docker run picks it up via --env-file. The released 4.0.3 CLI does not have this leg — it silently drops env.secret entries (#2957).

Two things never happen:

  • The plaintext .kamal/secrets file itself is never uploaded to the target hosts. Only the resolved values the deploy needs leave the control machine — to docker login over stdin, and to the per-role env files described above.
  • The resolved KEY=VALUE map is not cached. Every deploy re-runs every $(...). Your vault is the source of truth.
  1. Update the value in your vault.
  2. Run wheels deploy — the next deploy pulls the new value.

Running containers keep their original env vars. Rotation is a deploy, not a live operation.