Kamal 2: Zero-Downtime Docker Deploys Without Kubernetes

Photo by roger4336 on flickr
Kamal 2 is a deployment tool from Basecamp that deploys Docker containers to any server over SSH, without a control plane, etcd, or cluster networking. It targets the space between running a single docker command by hand and operating a full Kubernetes cluster, giving small teams zero-downtime deploys without that operational overhead.
Kamal 2 uses a purpose-built reverse proxy called kamal-proxy. During a deploy, it starts the new container alongside the old one, polls a health check endpoint until the new container reports healthy, then atomically switches traffic to it and drains remaining requests from the old container before stopping it.
Yes, through a feature called accessories. Accessories are declared in the same config/deploy.yml file as your app but are managed independently with the kamal accessory command. They do not get zero-downtime rollouts, so restarting a database accessory causes a brief connection drop and should be scheduled carefully.
Run kamal app containers -q to list previously deployed image tags still retained on the server, then run kamal rollback with the tag you want to revert to. Because the image is already pulled locally, rollback is nearly as fast as the original traffic switch and requires no rebuild or registry pull.
Kamal reads secret values from a local .kamal/secrets file that is excluded from version control, and only the secret names appear in config/deploy.yml under env.secret. Destination-specific files like .kamal/secrets.production let different environments use different values while keeping the actual secrets off the repository entirely.

Photo by roger4336 on flickr
Kubernetes solves real problems, but most side projects and small production apps do not have those problems. What they actually need is a way to build a Docker image, push it to a registry, and run it on one or two servers without downtime and without babysitting a control plane. That is exactly the gap Kamal 2 fills.
Kamal is a deployment tool from Basecamp that drives your existing servers over SSH, using plain Docker underneath. Version 2 replaced the old Traefik-based routing layer with a purpose built reverse proxy called kamal-proxy, which is what finally makes true zero-downtime rollouts and instant rollback practical on a single VPS. This guide walks through a full deployment: proxy, accessories, secrets, and recovering from a bad release.
Kubernetes earns its complexity at a certain scale: many services, many teams, autoscaling across a fleet of nodes. Below that scale, a control plane, etcd, and a networking layer add operational overhead with little payoff, and most solo developers or small teams end up spending more hours learning YAML dialects than shipping features. Kamal targets the space in between a single docker run command and a full cluster, borrowing the parts of container orchestration that actually matter for a small deployment — image builds, health checks, and coordinated restarts — while leaving out the parts that only pay off at large scale.
Kamal is framework agnostic. It was built for Rails apps but works with any application that can be packaged into a Docker image and exposes an HTTP health check endpoint — Node, Next.js, Django, or a static binary all qualify.
Everything Kamal does is driven by one YAML file, config/deploy.yml, plus a secrets file. The config below targets a single server, builds an image, pushes it to a registry, and configures kamal-proxy to route traffic and run health checks against the app.
# config/deploy.yml
service: myapp
image: myregistry/myapp
servers:
web:
hosts:
- 203.0.113.10
labels:
traefik.http.routers.myapp.rule: Host(`myapp.example.com`)
proxy:
ssl: true
host: myapp.example.com
app_port: 3000
healthcheck:
path: /up
interval: 1
timeout: 5
registry:
server: ghcr.io
username: myuser
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
NODE_ENV: production
secret:
- DATABASE_URL
- REDIS_URLThe proxy block is the important part. Kamal-proxy sends a GET request to the health check path once per second, and only switches traffic to the new container once it responds with a success status. Old traffic is drained from the previous container before it is stopped, which is what makes the swap invisible to users.
Running the deploy command triggers a sequence that is worth understanding, because most deploy failures happen in one of these four steps and the error messages point straight at which one failed. Reading the Kamal output line by line during your first few deploys pays off later, since it teaches you exactly which stage — build, push, pull, or health check — to inspect when something eventually goes wrong in production.
| Command | What it does | When to run it |
|---|---|---|
| kamal setup | Provisions Docker, kamal-proxy, and directories on a fresh server | Once, before the first deploy to a new host |
| kamal deploy | Builds, pushes, and rolls out the app with zero downtime | Every release |
| kamal rollback | Reverts to a previously deployed image tag | Immediately after a bad release is noticed |
| kamal accessory boot | Starts or restarts a supporting service like a database | When provisioning or updating a dependency container |
Accessories are Kamal's term for the supporting services your app depends on but that are not part of the zero-downtime rollout — a database, a cache, a queue. They are declared in the same deploy.yml file, but managed with their own lifecycle through the kamal accessory command rather than kamal deploy. This separation exists because a database restart is fundamentally different from an app restart: you cannot simply run two Postgres containers side by side and switch traffic between them the way kamal-proxy switches between app containers, since the data itself lives on a single volume.
# config/deploy.yml
accessories:
postgres:
image: postgres:16
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_DB: myapp
secret:
- POSTGRES_PASSWORD
directories:
- postgres-data:/var/lib/postgresql/data
redis:
image: redis:7
host: 203.0.113.10
port: "127.0.0.1:6379:6379"
directories:
- redis-data:/dataAccessories do not get zero-downtime updates. Restarting Postgres or Redis with kamal accessory reboot causes a brief connection drop while the container restarts, so schedule accessory changes during low-traffic windows and make sure your app retries dropped database connections gracefully.
Kamal reads secrets from a .kamal/secrets file by default, which should never be committed to git. The file supports destination-specific overrides, so a value in .kamal/secrets.production only applies when deploying to that destination, while .kamal/secrets-common applies everywhere.
Every deploy leaves the previous container's image on the server for a retention window, which is what makes rollback fast — no rebuild or registry pull needed. First find the image tag you want to revert to, then roll back to it directly. It is worth practicing this command on a staging server before you actually need it in production, so the exact syntax and the container listing output are already familiar when a real incident happens and every second of downtime matters.
# see exited containers from previous deploys
kamal app containers -q
# roll back to a specific image tag
kamal rollback abc1234
# roll a proxy restart across hosts one at a time
kamal proxy reboot --rollingBecause rollback reuses an already-pulled image, it is nearly as fast as the original deploy's traffic switch. There is no build step and no registry round trip, so a bad release from ten minutes ago can be undone in seconds.
For apps running on more than one host, Kamal deploys to all servers in parallel by default, but you can opt into a rolling strategy so that only a subset of servers are updated at a time, keeping the rest serving traffic. This matters most for proxy-level maintenance, like rotating SSL certificates or bumping the proxy image itself, where you want zero visible impact even while restarting infrastructure that sits in front of your app.
Start with a single-server setup even if you plan to scale out later. Kamal's configuration format makes adding a second or third host a one-line change to the hosts list — you do not need to design for multi-server from day one.
Kamal 2 will not replace Kubernetes for a genuinely large, multi-team platform, but for the vast majority of side projects, small SaaS products, and single-team backends, it removes an entire category of operational complexity while still delivering the zero-downtime deploys and clean rollback story that used to require a control plane to get right. If you already own a VPS and a Docker image, the fastest way to feel the difference is to run kamal setup once and watch your first zero-downtime deploy complete in under a minute.