Skip to content

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 init stamps out config/deploy.yml and .kamal/secrets
  • What to edit in deploy.yml before your first deploy
  • The difference between wheels deploy setup and wheels deploy
  • How to verify the rollout, tail logs, and roll back if something breaks

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 init generates a starter Dockerfile in step 1 (and refuses to overwrite an existing one unless you pass --force) — but verify the generated one actually builds your app before deploying.
  1. Stamp out the config files.

    From your project root:

    Run from project root
    wheels deploy init

    Creates four files:

    • config/deploy.yml — the deploy manifest. Under git.
    • .kamal/secrets — secret values. Never under git. Add .kamal/secrets to .gitignore immediately.
    • Dockerfile — a starter production Dockerfile. If one already exists, init refuses 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.yml uses your project directory name as the service name and a changeme registry user. You’ll edit both next.

  2. Edit config/deploy.yml for your target.

    At minimum, fix four keys:

    config/deploy.yml
    service: myapp
    image: myorg/myapp
    servers:
    web:
    - 192.0.2.10
    proxy:
    host: app.example.com
    ssl: true
    registry:
    username: myorg
    password:
    - KAMAL_REGISTRY_PASSWORD
    • service names the on-host containers (myapp-web-abc1234). Pick something short and stable.
    • image is the registry path excluding the tag. wheels deploy appends a version (short git sha by default).
    • servers lists the Linux hosts that run the web role. Add more roles (job, worker) if you have them.
    • proxy.host is the public hostname. ssl: true auto-issues a Let’s Encrypt cert. If you point proxy.host at a CDN or custom reverse proxy instead, set ssl: false.
    • registry.username is the identity used for docker 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.

  3. 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.yml
    KAMAL_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 through bash at deploy time — any command-line tool that prints to stdout works: op, bw, aws secretsmanager get-secret-value, doppler secrets get, or plain cat config/master.key.

    See Secrets for the full adapter list.

  4. Configure env in deploy.yml.

    Under env:, declare the environment variables your container needs:

    config/deploy.yml
    env:
    clear:
    WHEELS_ENV: production
    WHEELS_DATASOURCE_CLASS: com.mysql.cj.jdbc.Driver

    clear: values are baked into deploy.yml and safe under git. At deploy time, wheels deploy translates them into docker run -e flags.

  5. 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 bootstrap

    Runs which docker || curl -fsSL https://get.docker.com | sh on every host. Idempotent — safe to run on a host that already has Docker. Fails fast if SSH or sudo isn’t configured correctly.

  6. Run setup once.

    First-time deploy
    wheels deploy setup

    setup is the first-run verb. In the current Phase 1 CLI it is an alias for wheels deploy — it does not yet boot kamal-proxy or 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 boot
    wheels deploy accessory boot all
    wheels deploy setup

    On 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.

  7. Hit the app.

    Once setup completes cleanly, your app is live at https://app.example.com. Let’s Encrypt provisioning takes 10-30 seconds the first time — if curl -I https://app.example.com returns a TLS error immediately, wait a moment and retry.

    Check the container state:

    Check container state
    wheels deploy app containers

    Prints docker ps output filtered by your service label, for every host. You should see one running container per host per role. (wheels deploy details gives the wider view — app, proxy, and accessories. wheels deploy app details is a different verb: it requires --release=<version> and reports a single container’s docker inspect status.)

  8. Make a change and redeploy.

    Commit a change, then:

    Rolling redeploy
    wheels deploy

    Same 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-proxy drains 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. (--release rather than --version because the CLI runtime’s root parser claims --version for itself.)

A successful deploy leaves you with a handful of useful introspection commands:

Introspection commands
wheels deploy details # everything — app, proxy, accessories
wheels deploy app containers # just app containers across hosts
wheels deploy app logs --follow # tail every app log, prefixed by host
wheels deploy proxy logs # kamal-proxy access/error logs
wheels deploy audit # on-host audit log (who deployed what)

If something looks wrong, wheels deploy details is always the first stop.

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:

Roll back to a previous version
wheels deploy rollback abc1234

The 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.

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 overkamal-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.