Kamal - Deploying from clean checkouts and worktrees
Deploying "from a clean checkout" (a fresh clone, a git worktree, or a CI runner) is good release hygiene: the image can't accidentally include your local uncommitted changes. But there's a trap in how Kamal resolves secrets that will cost you a failed deploy the first time you try it.
The trap: gitignored key files resolve silently EMPTY
Your .kamal/secrets file typically sources secrets from local files:
RAILS_MASTER_KEY=$(cat config/master.key)
DB_PRIMARY_PASSWORD=$(cat config/db.key)
Both files are gitignored, so a pristine checkout doesn't have them. And here's the nasty part: the $(cat β¦) substitution does not fail the deploy when the file is missing. It resolves to an empty string, the image builds fine, the push succeeds, and the container ships with empty credentials.
You then get failures that look nothing like "missing key file":
- An empty
RAILS_MASTER_KEYmeans the app can't decryptcredentials.yml.enc, so the boot dies at whatever reads credentials first: often a custom guard or an initializer, with an error message about that feature rather than about the key. - An empty database password dies later still, as
PG::ConnectionBad: fe_sendauth: no password supplied, duringdb:prepare, after credentials already loaded fine.
Each failure is downstream of the real cause, so you can burn a full build-push-boot cycle (minutes) per missing file if you debug them one at a time.
The 5-second preflight that catches all of it
Before deploying from ANY checkout that isn't your usual working directory:
kamal secrets print
Eyeball the output for empty values. Every secret should have one. If anything is blank, fix the sourcing before you deploy. You just saved yourself a failed cycle per empty line.
The pre-flight that runs by itself: bin/check-deploy-secrets
RailsFast apps (template v1.2.0 and later) ship this check as a script, and Kamal runs it for you. It reads the same secrets files that Kamal reads (.kamal/secrets-common, then .kamal/secrets or .kamal/secrets.<destination>), with Kamal's own parser, but it does not run their commands. For each secret that your config/deploy.yml uses, it finds the files that its $(cat β¦) or < file commands read. Then it sorts what it finds in two groups:
- Errors: a file that is missing or empty. Either way the secret would reach the containers as an empty string. Errors stop a deploy.
- Warnings: a file that still holds a template placeholder.
bin/setupwritesYOUR_β¦andyour-β¦values intoconfig/backups/*.key. They look filled in, but the backups cannot upload with them. Placeholders never stop a deploy, so a new app's first deploy still goes through before its backups are set up.
bin/check-deploy-secrets # production (.kamal/secrets)
bin/check-deploy-secrets -d staging # the staging destination (.kamal/secrets.staging)
bin/check-deploy-secrets -c config/deploy.worker.yml # another config file
The deploy secrets are not ready (.kamal/secrets):
config/master.key (RAILS_MASTER_KEY) is missing
Kamal sends these template placeholders as they are (.kamal/secrets):
config/backups/s3_bucket.key (BACKUP_MONITOR_BUCKET, BUCKET) still holds a template placeholder
Each line names the file and the secrets that read it. It prints names, never values. A secret built from another one (POSTGRES_PASSWORD=$DB_PRIMARY_PASSWORD) carries the other secret's file. Only a literal fallback makes a file optional: one command, then ||, then echo, printf, true or : with no file of its own. For example $(cat config/backups/monitor_key_id.key 2>/dev/null || echo ""). Such a file may be absent. It may be empty only when the fallback prints nothing either, because cat succeeds on an empty file and the fallback never runs. A fallback that reads another file (|| cat other.key), or a list before it (a ; b || echo), makes every file required.
The pre-connect hook
.kamal/hooks/pre-connect runs bin/check-deploy-secrets --hook before Kamal connects to a host. As a hook it checks only the secrets that the current command sends, for the destination Kamal is using. It also sees every secret as Kamal evaluated it, so it stops on a used secret that came out empty from a source it cannot follow, such as an ENV variable or a password manager.
| Command | On a missing or empty secret |
|---|---|
setup, deploy, redeploy, rollback, app boot, accessory boot, accessory reboot |
stops: these start containers with the secrets |
everything else (app logs, app exec, app details, build push, β¦) |
prints the list and continues, so you can still read logs during an incident |
kamal config |
does not run: it connects to no host |
Placeholders only print a warning, whatever the command. To skip the check once, add --skip-hooks (-H) to the Kamal command. This also skips your other hooks. The hook cannot see --reuse: an app exec without it starts a new container with the same wrong secrets, so read the warning.
Existing apps. A template merge adds the hook and the script, but your .kamal/secrets and config/deploy.yml stay yours (merge=ours). Run bin/check-deploy-secrets once before your next deploy. The usual finding is the four placeholder backup key files: fill them in (Configuration). An app that does not run the pg-backup accessory removes it from config/deploy.yml, and the check then skips the secrets that only the accessory reads.
Kamal runs one file per hook. If you already had a .kamal/hooks/pre-connect, chain the two: in the template's hook, replace exec "$check" --hook with "$check" --hook || exit and put your own checks below it.
The fix
Either copy the gitignored key files into the clean checkout before deploying:
cp /path/to/working-copy/config/master.key config/master.key
cp /path/to/working-copy/config/db.key config/db.key
β¦or, better long-term, source those secrets from a password manager (1Password's op, etc.) in .kamal/secrets, so any checkout on an authenticated machine resolves them identically. The commented examples at the top of the generated .kamal/secrets show the pattern.
Related failure modes worth knowing
- Registry 403 on push (
unexpected status from HEAD request β¦ 403 Forbidden): registry auth tokens expire (AWS ECR's last 12 hours). Runkamal registry login(or for ECR directly,aws ecr get-login-password | docker login --username AWS --password-stdin <registry>) and retry. The build cache survives, so the retry is fast. - A failed deploy is traffic-safe, but read the right logs. When the new container fails its health check, Kamal stops it and the previous container keeps serving, so users see nothing. But Kamal's own output only says
target failed to become healthy within configured timeout. The actual error is in the stopped container's logs on the host:
ssh <deploy-host> "docker logs <service>-web-<git-sha> 2>&1 | tail -50"
That's where the real exception lives. Read it before changing anything.