Deployment
Your First Deploy
This page takes an existing Wheels app and ships it to one or more Linux servers. You’ll generate config, edit it for your target, bootstrap Docker on a fresh host, and run your first rolling deploy. Nothing here assumes you’ve used Kamal before. The whole thing takes about 20 minutes if your servers are ready.
You’ll learn:
- How
wheels deploy initstamps outconfig/deploy.ymland.kamal/secrets - What to edit in
deploy.ymlbefore your first deploy - The difference between
wheels deploy setupandwheels deploy - How to verify the rollout, tail logs, and roll back if something breaks
Before you start
Section titled “Before you start”Three quick checks before running anything:
- SSH works.
ssh deploy@your-host "uname -a"succeeds without a password prompt. - Registry login works.
docker login <registry>succeeds locally. - Your app has a working
Dockerfile. It builds and runs locally (docker build . && docker run <image>). If you don’t have one yet,wheels deploy initgenerates a starterDockerfilein step 1 (and refuses to overwrite an existing one unless you pass--force) — but verify the generated one actually builds your app before deploying.
Walk-through
Section titled “Walk-through”-
Stamp out the config files.
From your project root:
Run from project root wheels deploy initCreates four files:
config/deploy.yml— the deploy manifest. Under git..kamal/secrets— secret values. Never under git. Add.kamal/secretsto.gitignoreimmediately.Dockerfile— a starter production Dockerfile. If one already exists,initrefuses to overwrite it unless you pass--force..dockerignore— keeps build context lean.
A
.kamal/hooks/directory is also created for optional local hook scripts. It’s empty and safe to leave that way.The generated
deploy.ymluses your project directory name as the service name and achangemeregistry user. You’ll edit both next. -
Edit
config/deploy.ymlfor your target.At minimum, fix four keys:
config/deploy.yml service: myappimage: myorg/myappservers:web:- 192.0.2.10proxy:host: app.example.comssl: trueregistry:username: myorgpassword:- KAMAL_REGISTRY_PASSWORDservicenames the on-host containers (myapp-web-abc1234). Pick something short and stable.imageis the registry path excluding the tag.wheels deployappends a version (short git sha by default).serverslists the Linux hosts that run thewebrole. Add more roles (job,worker) if you have them.proxy.hostis the public hostname.ssl: trueauto-issues a Let’s Encrypt cert. If you pointproxy.hostat a CDN or custom reverse proxy instead, setssl: false.registry.usernameis the identity used fordocker login.password:names one or more environment variables that resolve to the actual password — the password itself lives in.kamal/secrets.
See deploy.yml Reference for every key.
-
Populate
.kamal/secrets.The generated file has placeholder entries. Replace them with real values or with
$(...)subshells that pull from your secret store:.kamal/secrets # Registry — matches registry.password[0] in deploy.ymlKAMAL_REGISTRY_PASSWORD=$(op read op://Production/Registry/password)# App-level secrets — referenced by env.secret in deploy.yml and# delivered to the container via a host-side env file (#2957)DATABASE_URL=$(op read op://Production/App/database-url)WHEELS_RELOAD_PASSWORD=$(op read op://Production/App/reload-password)The
$(...)form runs throughbashat deploy time — any command-line tool that prints to stdout works:op,bw,aws secretsmanager get-secret-value,doppler secrets get, or plaincat config/master.key.See Secrets for the full adapter list.
-
Configure env in
deploy.yml.Under
env:, declare the environment variables your container needs:config/deploy.yml env:clear:WHEELS_ENV: productionWHEELS_DATASOURCE_CLASS: com.mysql.cj.jdbc.Driverclear:values are baked intodeploy.ymland safe under git. At deploy time,wheels deploytranslates them intodocker run -eflags. -
Bootstrap Docker on the host (first time only).
If Docker is already installed on the target, skip this step. Otherwise:
Install Docker on every host (requires reachable hosts) wheels deploy bootstrapRuns
which docker || curl -fsSL https://get.docker.com | shon every host. Idempotent — safe to run on a host that already has Docker. Fails fast if SSH orsudoisn’t configured correctly. -
Run setup once.
First-time deploy wheels deploy setupsetupis the first-run verb. In the current Phase 1 CLI it is an alias forwheels deploy— it does not yet bootkamal-proxyor any accessories you’ve declared (full first-run orchestration is tracked in #2957). Boot those explicitly before (or right after) your first deploy:First-run orchestration (run explicitly for now) wheels deploy proxy bootwheels deploy accessory boot allwheels deploy setupOn subsequent deploys you use
wheels deploy— the proxy and accessories are already running.Expect a few minutes on the first run — Docker pulls the base images for
kamal-proxy, any accessories, and your app. Output is prefixed with[host]so you can see what each server is doing in parallel. -
Hit the app.
Once
setupcompletes cleanly, your app is live athttps://app.example.com. Let’s Encrypt provisioning takes 10-30 seconds the first time — ifcurl -I https://app.example.comreturns a TLS error immediately, wait a moment and retry.Check the container state:
Check container state wheels deploy app containersPrints
docker psoutput filtered by your service label, for every host. You should see one running container per host per role. (wheels deploy detailsgives the wider view — app, proxy, and accessories.wheels deploy app detailsis a different verb: it requires--release=<version>and reports a single container’sdocker inspectstatus.) -
Make a change and redeploy.
Commit a change, then:
Rolling redeploy wheels deploySame command, no
setup. The rolling flow kicks in: new image builds, pushes, pulls to every host, then the proxy cuts traffic over host-by-host. Zero downtime —kamal-proxydrains in-flight requests to the old container before switching.The version label is the short git sha by default. Override with
--release=<tag>if you tag releases differently. (--releaserather than--versionbecause the CLI runtime’s root parser claims--versionfor itself.)
Verifying the rollout
Section titled “Verifying the rollout”A successful deploy leaves you with a handful of useful introspection commands:
wheels deploy details # everything — app, proxy, accessorieswheels deploy app containers # just app containers across hostswheels deploy app logs --follow # tail every app log, prefixed by hostwheels deploy proxy logs # kamal-proxy access/error logswheels deploy audit # on-host audit log (who deployed what)If something looks wrong, wheels deploy details is always the first stop.
Rolling back
Section titled “Rolling back”Every deploy tags its container with the version label (git sha by default). To roll back, you point wheels deploy rollback at a previous version:
wheels deploy rollback abc1234The version is a positional argument, same as Ruby Kamal’s kamal rollback VERSION. Don’t write --version=abc1234 — the CLI runtime’s root parser intercepts --version before the deploy module sees it and the command fails (#2674).
Finds containers tagged abc1234 on every host, starts them, and asks the proxy to switch traffic back. The old containers usually still exist on-host — wheels deploy doesn’t prune aggressively — so rollback is fast. If you’ve pruned, the rollback fails at the “no such container” step; re-deploy from that sha instead.
Troubleshooting
Section titled “Troubleshooting”Three failure modes you’ll hit at least once:
“passwordless sudo not configured on <host>” — wheels deploy shells in as the SSH user and sudos to root for Docker commands. Either add your SSH user to /etc/sudoers.d/wheels-deploy with NOPASSWD:ALL, or deploy as root directly by setting ssh.user: root in deploy.yml.
“image not found” — the image hasn’t pushed, or the registry credentials don’t resolve. Run wheels deploy build push --dry-run to see the exact docker push command that would run, then check .kamal/secrets — a missing $(op read ...) is the usual culprit.
Proxy refuses to cut traffic over — kamal-proxy health-checks new containers before switching. If proxy.healthcheck.path (/up by default) returns non-2xx within proxy.healthcheck.timeout (30s), the proxy keeps routing to the old container. Check wheels deploy app logs --tail=200 on the new container — usually it’s a missing env var or a failed database connection.