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/secretsthroughdeploy.ymlat deploy time — and which leg of that flow is still pending
The file format
Section titled “The file format”.kamal/secrets is a shell-compatible file with KEY=value lines. Comments start with #. Blank lines are ignored.
# RegistryKAMAL_REGISTRY_PASSWORD=ghp_abc123...
# AppDATABASE_URL=postgres://user:pass@db.example.com/appWHEELS_RELOAD_PASSWORD=choose-something-longThe 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 ondevelop(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.
Destination-scoped secrets
Section titled “Destination-scoped secrets”For multi-environment deploys, .kamal/secrets.<destination> is merged on top of .kamal/secrets when you pass --destination. Keys in the overlay win.
DATABASE_URL=$(op read op://Production/App/database-url)DATABASE_URL=$(op read op://Staging/App/database-url)$(...) command substitution
Section titled “$(...) command substitution”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:
# 1Password CLIDATABASE_URL=$(op read op://Production/App/database-url)
# Bitwarden CLIDATABASE_URL=$(bw get password db-url)
# AWS Secrets ManagerDATABASE_URL=$(aws secretsmanager get-secret-value --secret-id prod/db-url --query SecretString --output text)
# DopplerDATABASE_URL=$(doppler secrets get DATABASE_URL --plain)
# Flat file on the control machineWHEELS_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.
Built-in adapters
Section titled “Built-in adapters”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.
| Adapter | Aliases | Backing tool |
|---|---|---|
| 1Password | op, 1password | op CLI |
| Bitwarden | bw, bitwarden | bw CLI |
| AWS Secrets | aws | aws secretsmanager |
| LastPass | lastpass, lpass | lpass CLI |
| Doppler | doppler | doppler CLI |
wheels deploy fetch-secrets
Section titled “wheels deploy fetch-secrets”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 --adapter=op --from=op://Production/App DATABASE_URL WHEELS_MASTER_KEYPrints:
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 $(...):
$(wheels deploy fetch-secrets --adapter=op --from=op://Production/App DATABASE_URL WHEELS_MASTER_KEY)wheels deploy extract-secrets
Section titled “wheels deploy extract-secrets”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 B --from="$(wheels deploy fetch-secrets --adapter=op --from=op://Production/App A B)"# prints the value of Bwheels deploy print-secrets
Section titled “wheels deploy print-secrets”Prints your project’s fully resolved .kamal/secrets as KEY=VALUE lines. All $(...) subshells expand; every referenced key is emitted.
wheels deploy print-secretsUseful for auditing what the deploy flow will actually see. Run it in private — the output is plaintext secrets.
How secrets flow at deploy time
Section titled “How secrets flow at deploy time”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 hostHow 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/secretsfile itself is never uploaded to the target hosts. Only the resolved values the deploy needs leave the control machine — todocker loginover stdin, and to the per-role env files described above. - The resolved
KEY=VALUEmap is not cached. Every deploy re-runs every$(...). Your vault is the source of truth.
Rotating a secret
Section titled “Rotating a secret”- Update the value in your vault.
- 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.