Kamal Deploys skill for Codex
Zero-downtime container deploys to your own servers. Codex sets up Kamal 2 for Rails, Node, Python or Go apps with a deploy.yml that matches the app's real port and health path, secrets pulled from a password manager through .kamal/secrets, and accessories bound to localhost. It debugs failed health checks from logs and proxy output, keeps migrations rollback-safe, and asks before every setup, deploy or rollback against real servers.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- kamal-deploys Installs to ~/.agents/skills/kamal-deploys/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install kamal-deploys The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project kamal-deploys Installs to .agents/skills/kamal-deploys/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/kamal-deploys.zipunzip -o kamal-deploys.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) kamal-deploys PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $kamal-deploys, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Deploys containerised web apps to your own VPS or bare-metal servers with Kamal 2 - config/deploy.yml, kamal-proxy with automatic Let's Encrypt TLS, zero-downtime health-checked rollouts, .kamal/secrets with password-manager adapters, accessories for Postgres or Redis, destinations for staging, hooks, rollbacks and lock handling. Use when setting up Kamal for Rails, Node, Python or Go apps, a kamal deploy fails with "target failed to become healthy", moving from Heroku or Kamal 1 to Kamal 2, running several apps on one server, or rolling back a bad release.
Use it when
- Deploying a containerised app to your own VPS or bare-metal servers
- A Kamal deploy fails its health check or hangs
- Moving from Heroku or Kamal 1 to Kamal 2
- Running several apps or a staging destination on the same servers
Use something else when
- You want a general VPS setup with Docker Compose, Coolify or a reverse proxy: use vps-deploys
- You want a managed platform instead of servers: use fly-io
How to check the result
The health path returns 200 over HTTPS with a valid certificate, kamal app details shows the new version on every host, and a staging rollback restores the previous version.
The mistake it prevents
Leaving app_port at its default of 80 while the app listens on 3000, so kamal-proxy's health check never passes and every deploy times out.
Done well means: kamal deploy builds, pushes and swaps containers with zero downtime
behind kamal-proxy, the health check reflects real readiness, secrets never enter git,
stateful accessories have backups, and rollback to the previous version is a tested
one-liner.
Every kamal setup, kamal deploy, kamal rollback, kamal remove or accessory reboot
against a real server is a production change. Show the command and target destination,
and get the user’s confirmation before running it.
Inspect first
cat config/deploy.yml config/deploy.*.yml 2>/dev/null
cat .kamal/secrets* 2>/dev/null | sed 's/=.*/=<redacted>/' # see keys, never print values
ls .kamal/hooks/ 2>/dev/null
kamal version
kamal config -d staging # fully resolved config for a destination
grep -n EXPOSE Dockerfile; grep -rn "listen\|PORT" config/ 2>/dev/null | head
Read AGENTS.md for the deploy process (who may deploy, which destination is default).
Kamal needs SSH access to the servers, a container registry, and Docker locally or a
remote builder: all network operations, so ask for approval in a Codex sandbox.
Minimal Kamal 2 config
# config/deploy.yml
service: shop
image: acme/shop
servers:
web:
- 203.0.113.10
job:
hosts:
- 203.0.113.10
cmd: bin/jobs
proxy:
ssl: true
host: shop.example.com
app_port: 3000 # port the app listens on inside the container (default 80)
healthcheck:
path: /up
interval: 2
timeout: 3
registry:
server: ghcr.io
username: acme-bot
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
env:
clear:
RAILS_LOG_TO_STDOUT: "1"
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
volumes:
- "shop_storage:/rails/storage"
accessories:
db:
image: postgres:17
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear: { POSTGRES_USER: shop, POSTGRES_DB: shop_production }
secret: [POSTGRES_PASSWORD]
directories:
- data:/var/lib/postgresql/data
aliases:
console: app exec --interactive --reuse "bin/rails console"
logs: app logs -f
.kamal/secrets (gitignored) maps names to values, pulled at deploy time:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
SECRETS=$(kamal secrets fetch --adapter 1password --account acme --from Deploy/shop POSTGRES_PASSWORD DATABASE_URL)
POSTGRES_PASSWORD=$(kamal secrets extract POSTGRES_PASSWORD $SECRETS)
DATABASE_URL=$(kamal secrets extract DATABASE_URL $SECRETS)
Adapters exist for several password managers and cloud secret stores; check kamal secrets fetch --help for the installed version. Never write literal secret values into
deploy.yml or commit .kamal/secrets with values in it.
First deploy
- [ ] 1. App serves 200 on the health path over plain HTTP inside the container
- [ ] 2. DNS A/AAAA for proxy.host points at the server (needed for Let's Encrypt)
- [ ] 3. Ports 80 and 443 open on the server; SSH key access as the configured user
- [ ] 4. Registry credentials work: docker login locally with the same token
- [ ] 5. kamal config shows the expected resolved values
- [ ] 6. Confirm with the user, then: kamal setup (installs Docker if missing, boots proxy, accessories, app)
- [ ] 7. curl -I https://<host>/up returns 200 with a valid certificate
Subsequent releases: kamal deploy (or kamal deploy -d staging for a destination file
config/deploy.staging.yml, which overrides the base config).
How a deploy swaps traffic
Kamal builds and pushes the image, pulls it on each server, starts the new container,
waits for kamal-proxy’s health check to pass on app_port + healthcheck.path, switches
traffic, then stops the old container. If the new container never becomes healthy, the
old one keeps serving and the deploy fails.
“target failed to become healthy”
Check in this order:
kamal app logs --lines 200 # did the app boot? crash on missing env?
kamal app details # running containers and versions
kamal proxy logs --lines 100 # health check requests and their status
kamal app exec --reuse "curl -sv http://localhost:3000/up" # if curl exists in the image
| Cause | Fix |
|---|---|
App listens on a different port than app_port | Align app_port, Dockerfile EXPOSE, and the server’s listen port |
App binds 127.0.0.1 | Bind 0.0.0.0 |
Health path redirects to HTTPS (Rails force_ssl) | Exclude the health path from SSL redirect (config.ssl_options = { redirect: { exclude: ->(r) { r.path == "/up" } } }) |
| Health path needs DB/auth and those are not ready | Make /up a cheap liveness check; boot migrations separately |
| Boot takes longer than the deploy timeout | Raise deploy_timeout, or speed up boot (precompile assets at build time) |
| Missing secret causes crash on boot | Check kamal app logs; verify the key is in env.secret and .kamal/secrets |
| Host header mismatch | Health checks go to the container directly; app-level host authorization must allow it |
Day-two operations
kamal rollback <version> # version = image tag (git SHA); list with `kamal app containers`
kamal app exec -i --reuse "bin/rails db:migrate:status"
kamal accessory logs db
kamal accessory reboot db # restarts the database: downtime, confirm first
kamal lock status
kamal lock release # only after confirming no deploy is running
kamal prune all # remove old images and containers on servers
- Rollback only swaps the app container; it does not undo database migrations. Write migrations backward-compatible (expand, deploy, contract) so the previous version runs against the new schema.
- A deploy interrupted mid-way leaves the deploy lock; check who is deploying before releasing it.
- Several apps on one server: each has its own
serviceandproxy.host; kamal-proxy routes by host name. Accessories with published ports must not collide. proxy.ssl: truewith Let’s Encrypt works per server. With multiple web servers, put a load balancer (with its own certificate) in front and terminate TLS there, or use a provider certificate; each server cannot obtain the same certificate independently.
Hooks
Executable scripts in .kamal/hooks/ (for example pre-build, pre-deploy,
post-deploy, pre-connect) run locally with KAMAL_* environment variables. Use
pre-build to refuse deploying a dirty git tree, and post-deploy to notify chat (after
the user confirms the webhook destination). A hook exiting non-zero aborts the deploy.
#!/usr/bin/env bash
# .kamal/hooks/pre-build
set -euo pipefail
if [ -n "$(git status --porcelain)" ]; then
echo "Refusing to deploy with uncommitted changes" >&2
exit 1
fi
Gotchas
- Kamal 1 to 2: Traefik was replaced by kamal-proxy,
.envby.kamal/secrets, and the default health check port changed. Follow the official upgrade guide and runkamal upgradeper destination only after confirming with the user. - Building amd64 images on Apple Silicon uses emulation and is slow; configure a remote
builder (
builder.remote: ssh://...) or build in CI. - Accessories are not managed databases: no automatic backups, failover or upgrades. Schedule dumps off-server and test a restore, or use a managed database.
- Docker-published accessory ports bypass the host firewall; bind them to
127.0.0.1as above (see$linux-server-hardening). - Persistent files need
volumes:; anything written inside the container disappears on the next deploy.
Verification
curl -sI https://<host>/upreturns 200 and the certificate is valid (openssl s_client -connect <host>:443 -servername <host> </dev/null | openssl x509 -noout -dates).kamal app detailsshows the new version on every server and role.- A deliberately failing health check on staging leaves the old version serving.
kamal rollback <previous>on staging restores the previous version within a minute.
Report template
**Destination:** production (2 web hosts behind Hetzner LB, 1 job host)
**Version:** 3f9c2e1 deployed 14:05 UTC; previous a71b0d4 (rollback target)
**Health:** /up 200 via kamal-proxy; deploy took 74 s
**Changes:** app_port 3000 (was default 80), /up excluded from force_ssl
**Secrets:** fetched from 1Password via .kamal/secrets; nothing committed
**Open items:** nightly pg_dump of db accessory to object storage, restore test What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Kamal Deploys"
short_description: "Zero-downtime container deploys to your own servers"
default_prompt: "$kamal-deploys Set up Kamal 2 to deploy our Rails app to a Hetzner server with Let's Encrypt" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.