Skip to content

Deployment

deploy.yml Reference

This is the complete deploy.yml schema. wheels deploy loads config/deploy.yml on every invocation, validates it against this schema, and hands it to every subcommand. Unknown top-level keys and missing required keys fail fast with an error prefixed by the config file’s absolute path (messages are not line-scoped).

Three keys are required. Omitting any of them stops the loader with <path to deploy.yml>: missing required key: '<name>'.

KeyTypePurpose
servicestringShort, stable name. Drives container naming (<service>-<role>-<version>) and the Docker label that scopes every wheels deploy query.
imagestringRegistry path excluding the tag (e.g. myorg/myapp). wheels deploy appends a version.
serversarray or mapOne or more hosts. See servers below.

The loader rejects any top-level key not on this list. Case-insensitive. The allowlist contains only keys the runtime actually reads — Kamal keys that wheels deploy has not implemented yet are rejected rather than accepted-and-ignored (see Rejected Kamal keys below).

Allowed top-level keys (illustrative — do not type)
service, image, servers, registry, builder, env, ssh, proxy, accessories

The name threaded through every container, label, and lock file on the server.

config/deploy.yml (illustrative — do not type)
service: myapp

Containers are named <service>-<role>-<version> (myapp-web-abc1234). Accessories are named <service>-<accessory> (myapp-db). The lock file is /tmp/kamal_deploy_lock_<service>. Keep it short and stable — renaming later means manually cleaning up every host.

The registry path for the app image, excluding the tag.

config/deploy.yml (illustrative — do not type)
image: myorg/myapp # Docker Hub
image: ghcr.io/myorg/myapp # GHCR — include the registry host

wheels deploy tags every build with a version (short git sha by default) and produces a pull target like myorg/myapp:abc1234. Pair with registry.server when the registry isn’t Docker Hub.

The hosts Wheels deploys to. Three shapes, increasing in expressiveness.

config/deploy.yml (illustrative — do not type)
servers:
- 192.0.2.10
- 192.0.2.11

Every host joins an implicit role named web. Simplest shape for single-role apps.

config/deploy.yml (illustrative — do not type)
servers:
web:
- 192.0.2.10
job:
- 192.0.2.11

Any number of roles. The web role is the one meant to receive public traffic through the proxy; other roles (job, worker, sidekiq) are started and stopped like web. Known defect: the current CLI issues the kamal-proxy deploy cutover call for every role, not just web, so non-web roles also get registered with the proxy (#2957).

config/deploy.yml (illustrative — do not type)
servers:
web:
hosts:
- 192.0.2.10
env:
clear:
WHEELS_MAX_THREADS: 8
options:
memory: 2gb
cpus: 2
labels:
my-label: value
cmd: /app/bin/web
  • hosts: — required when using the map shape.
  • cmd: — overrides the container entrypoint for this role. Useful when one image runs as web under one command and as job under another.
  • env: — parsed but currently ignored: only the top-level env: is applied to containers; per-role overrides never reach docker run (#3088).
  • options: — parsed but currently ignored: no extra docker run flags (memory, cpus, etc.) are emitted (#3088).
  • labels: — parsed but currently ignored: containers only get the four labels Wheels adds (service, role, destination, version) (#3088).

Host strings accept user@host, host:port, and user@host:port. IPv6 literals must be bracketed ([::1]:22). Multiple colons without brackets are rejected — including adjacent colons, so an unbracketed '::1:22' fails validation.

Where images are pushed and pulled.

config/deploy.yml (illustrative — do not type)
registry:
server: ghcr.io # optional — defaults to Docker Hub
username: myorg
password:
- KAMAL_REGISTRY_PASSWORD
  • server: — hostname. Omit for Docker Hub.
  • username: — identity used for docker login. Literal string.
  • password: — array of secret keys. wheels deploy resolves the first key against .kamal/secrets and passes it to docker login --password-stdin.

For ECR, set username: AWS and point password: at an env var that you populate via a $(aws ecr get-login-password) in .kamal/secrets — ECR tokens rotate every 12 hours.

How and where Docker images are built.

config/deploy.yml (illustrative — do not type)
builder:
context: .
dockerfile: Dockerfile
arch:
- amd64
- arm64
args:
WHEELS_VERSION: "4.0.0"
remote: ssh://deploy@builder.example.com
  • context: — build context. Default ..
  • dockerfile: — path relative to context. Default Dockerfile.
  • arch: — parsed but currently ignored: no --platform flag is emitted, so builds target whatever the host running the build is (#3088). wheels deploy build create does set up a docker buildx builder.
  • args: — parsed but currently ignored: no --build-arg flags are emitted (#3088).
  • remote: — parsed but currently ignored: the build always runs locally; no remote builder endpoint is used (#3088).

Environment variables for app containers. clear values are baked into deploy.yml (safe under git) and passed to docker run -e at deploy time.

config/deploy.yml (illustrative — do not type)
env:
clear:
WHEELS_ENV: production
WHEELS_LOG_TO_STDOUT: "1"

Changes to env take effect on next deploy only; running containers keep their original values.

How wheels deploy connects to hosts.

config/deploy.yml (illustrative — do not type)
ssh:
user: deploy
port: 22
proxy: bastion.example.com
keys:
- ~/.ssh/id_ed25519
keys_only: false
  • user: — SSH user. Defaults to root. If not root, wheels deploy wraps root-requiring commands with sudo -n.
  • port: — TCP port. Default 22.
  • proxy: — parsed but currently ignored: ProxyJump-style bastion hops are not implemented; every connection goes direct (#3088).
  • keys: — array of private key file paths. The first entry is passed to the SSH pool. Tilde (~/) is expanded against the JVM user.home because sshj reads key files via java.io.File and does not expand the shell shortcut. Omit to fall back to ssh-agent.
  • keys_only: — parsed but currently ignored (#3088).

Host key verification reads your ~/.ssh/known_hosts. Your ~/.ssh/config is not read — the bundled sshj client doesn’t parse OpenSSH client config, so per-host users, identities, and ProxyJump directives there have no effect. Declare connection settings in this ssh: block instead.

Configuration for kamal-proxy, the singleton reverse proxy on every host.

config/deploy.yml (illustrative — do not type)
proxy:
host: app.example.com
app_port: 8080
ssl: true
healthcheck:
path: /up
timeout: 30
forward_headers: true
buffering:
requests: true
responses: true
  • host: — the public hostname. Used for TLS SNI and Let’s Encrypt issuance.
  • app_port: — port the app listens on inside the container; wheels deploy and wheels deploy rollback route traffic to it via the kamal-proxy target (--target <container>:<app_port>). The config default is 80 (matching Kamal), and wheels deploy init scaffolds 8080 to match its generated Dockerfile.
  • ssl: — if true, kamal-proxy requests a Let’s Encrypt cert for host on first boot. Set false if you terminate TLS in front of the proxy.
  • healthcheck: — new container must return 2xx at healthcheck.path within healthcheck.timeout seconds (defaults: /up, 30) or the proxy refuses to cut traffic over.
  • forward_headers: — parsed but currently ignored; not forwarded to the proxy (#3088).
  • buffering: — parsed but currently ignored (#3088).

Sidecar containers — databases, caches, queues — that run alongside the app but aren’t part of the rolling deploy. Booted once and left alone.

config/deploy.yml (illustrative — do not type)
accessories:
redis:
image: redis:7
host: 192.0.2.20
port: 6379
db:
image: postgres:16
host: 192.0.2.20
port: 5432
env:
clear:
POSTGRES_USER: app
secret:
- POSTGRES_PASSWORD
volumes:
- /data/pg:/var/lib/postgresql/data
files:
- config/init.sql:/docker-entrypoint-initdb.d/init.sql

Two caveats on the example shapes: files: is accepted but never uploaded — no copy step or docker flag is emitted (#3088) — and accessory env.secret entries follow the same delivery rules as the top-level env key: delivered via a host-side env file on 4.0.4+ builds, silently dropped on the released 4.0.3 (#2957).

See Accessories for the full walk-through.

Kamal accepts a number of additional top-level keys that wheels deploy has not implemented. They used to pass validation while doing nothing; the validator now rejects them with unknown top-level key: '<name>' so a config that looks like it works but doesn’t fails loudly (#3088).

KeyWhat it does in KamalStatus in wheels deploy
bootRolling boot sequence (limit: parallelism, wait: pauses)Rejected — hosts always deploy one at a time, sequentially (orchestration work tracked under #2957)
healthcheckLegacy pre-kamal-proxy top-level health checkRejected — configure proxy.healthcheck: instead
hooksPath override for the hooks directoryRejected — hook scripts are always loaded from .kamal/hooks/ (see Hooks; the feature works, only the path override key does not exist)
volumes, labelsApp-container volume mounts / extra labelsRejected — containers only get the four labels Wheels adds (service, role, destination, version); only accessory volumes:/directories: are mounted
loggingdocker run --log-driver / --log-opt passthroughRejected — no logging flags are ever emitted
retain_containersOld-container keep count for rollbackRejected — wheels deploy prune all always keeps the 5 most recent unless you pass --keep=<n> on the command line
minimum_versionFail-fast CLI version gateRejected — no version gate runs
asset_pathZero-downtime JS/CSS asset rotation pathRejected
require_destinationForces every command to pass --destination=<name>Rejected
allow_empty_rolesLets a role with an empty hosts: list pass validationRejected
run_directoryWorking directory inside the containerRejected — the image’s configured WORKDIR is always used
readiness_delaySeconds to wait after docker run before proxy cutoverRejected

deploy.yml supports a single, deliberately small interpolation form: ${UPPER_SNAKE} env-var tokens. This is the same syntax Ruby Kamal supports natively. Lookup order:

  1. .kamal/secrets (and .kamal/secrets.<destination> when --destination is set).
  2. System.getenv(VAR) on the machine running wheels deploy.
  3. Empty string for unset vars.
config/deploy.yml (illustrative — do not type)
service: ${APP_NAME}
image: ${REGISTRY}/${APP_NAME}

Only uppercase-and-underscore tokens are expanded — ${APP_NAME} matches; lowercase ${service} is left alone, which keeps shell-style placeholders elsewhere in the config from being captured by accident.

No loops, conditionals, or method calls. If you need logic (e.g. different values per destination), resolve it in .kamal/secrets or a .kamal/secrets.<destination> overlay first and reference the result with ${VAR}.

--destination production loads config/deploy.yml and then deep-merges config/deploy.production.yml on top. Arrays are replaced; maps merge recursively. Use overlays to split shared config from per-environment overrides:

config/deploy.yml (illustrative — do not type)
service: myapp
image: myorg/myapp
servers:
web:
- 192.0.2.10
config/deploy.staging.yml (illustrative — do not type)
servers:
web:
- staging.example.com
proxy:
host: staging.example.com

One caveat: the wheels deploy config inspection verb currently ignores --destination and always prints the base file (#3085). To see an overlay applied, use a verb that does resolve it, e.g. wheels deploy details --destination=staging --dry-run.

Errors are emitted as <absolute path to deploy.yml>: <message> — the prefix is the full file path, and messages are never line-scoped:

  • missing required key: '<name>' — one of service, image, servers is absent.
  • unknown top-level key: '<name>' (allowed keys: …) — typo, unsupported Kamal key, or a key from the rejected list. The message includes the full allowlist.
  • invalid host: '<string>' — host string has more than one colon without IPv6 brackets (adjacent colons count, so unbracketed '::1:22' is rejected).
  • invalid <kind> name: '<name>' (must match [a-zA-Z0-9][a-zA-Z0-9_.-]*) — a service, role, or accessory name contains shell-unsafe characters. Develop builds only (added after 4.0.3 by #3008 for #2956).