Kamal 2.x in Production: Zero-Downtime Deploys, Secrets, and Where the Simple Path Ends
If you have read the earlier post on Kamal vs Kubernetes, you already know that Kamal earns its place when you want container-based deployments without the operational overhead of a full orchestration platform. That comparison covers the strategic question. This post covers the mechanical one: what does it actually take to run Kamal 2.x reliably in production?
The short answer is: less than you might expect, but more than the README implies. Kamal 2.x shipped with meaningful changes to how secrets, accessories, and the proxy layer are handled. Some of the Kamal 1.x habits you may have picked up no longer apply. This guide works through zero-downtime rolling deploys, secrets management without a Vault cluster, a Symfony-specific deploy flow with Doctrine migrations, and the proxy configuration that holds it together. At the end, it names the ceiling - the point where Kamal stops being the honest answer.
What Changed Between Kamal 1.x and Kamal 2.x
Kamal 2.x replaced the Traefik-based proxy with kamal-proxy, a purpose-built HTTP proxy maintained by the 37signals team. This is not a cosmetic change. The older traefik configuration block in your deploy.yml is gone, replaced by a proxy block with different semantics.
The accessories model was also revised. In Kamal 1.x, accessories were somewhat second-class citizens - they ran on the target hosts but were not as tightly integrated into the deploy lifecycle. In 2.x, accessories have cleaner lifecycle commands (kamal accessory boot, kamal accessory reboot) and their configuration is validated more strictly at deploy time.
The other meaningful change is that Kamal 2.x leans harder on .kamal/secrets as the canonical secrets file, with hooks into kamal-secrets for fetching values from external stores. This replaces the older pattern of injecting environment variables directly or relying on .env files in ways that were easy to get wrong.
Configuring Zero-Downtime Rolling Deploys
Zero-downtime in Kamal 2.x depends on two things working together: a health check endpoint that kamal-proxy can poll, and a stop_wait_seconds value that gives your app time to drain in-flight requests before the container is killed.
A minimal but production-worthy deploy.yml proxy block looks like this:
proxy:
ssl: true
host: your-app.example.com
app_port: 8080
healthcheck:
path: /up
interval: 3
threshold: 5
The threshold here means kamal-proxy will wait for five consecutive successful responses before it starts routing traffic to the new container. With a three-second interval, that is about 15 seconds of warm-up time - enough for a Symfony app to compile its DI container and warm up the opcode cache on first request.
On the drain side, set stop_wait_seconds in your service configuration:
servers:
web:
hosts:
- 203.0.113.10
options:
stop-wait-seconds: 15
This tells Docker to wait 15 seconds after sending SIGTERM before issuing SIGKILL. Combined with the health check threshold, you get a window where the old container finishes its current requests while the new one ramps up, and kamal-proxy switches traffic only after the new container is verified healthy.
One practical detail: your /up endpoint should return a non-200 response if the application is not ready to serve real traffic - for example, if a cache warmup step is still running or if a required upstream service is unreachable. Returning 200 unconditionally defeats the health check.
Secrets Management Without a Vault Cluster
Kamal 2.x ships with a .kamal/secrets file concept and a kamal-secrets binary that can fetch values from 1Password, AWS Secrets Manager, or plain environment variables. For teams that do not operate a dedicated secrets management cluster, the env adapter combined with a secrets file that is never committed to version control is the pragmatic baseline.
Your .kamal/secrets file (gitignored) might look like this:
DATABASE_URL=$(cat ~/.secrets/database-url)
APP_SECRET=$(cat ~/.secrets/app-secret)
SENTRY_DSN=$(op read "op://Production/Sentry/dsn")
The $(...) syntax means each line is evaluated at deploy time on the machine running kamal deploy. The value is passed to the container as an environment variable. For Symfony applications, DATABASE_URL and APP_SECRET are the typical secrets that should flow through this mechanism rather than being baked into images.
For teams already on 1Password, the op:// syntax gives you proper audit trails and rotation without standing up additional infrastructure. For AWS shops, kamal-secrets supports aws-secrets-manager:// references. The point is that none of these require a Vault cluster - they work by having the deploy machine fetch the secret at deploy time and inject it into the container environment.
One thing to avoid: do not put secrets into deploy.yml itself, even in the env.clear block. deploy.yml belongs in version control. Anything that goes in env.secret is fetched from .kamal/secrets and kept out of the repository, which is the correct boundary.
Deploying a Symfony App with Doctrine Migrations
Symfony adds one complication that generic Kamal documentation skips: you typically want to run doctrine:migrations:migrate before new containers start serving traffic, not after. Running migrations post-deploy means there is a window where new code is live against an old schema, which breaks for any migration that removes or renames a column.
Kamal 2.x hooks solve this cleanly. Create a file at .kamal/hooks/pre-deploy:
#!/usr/bin/env bash
set -e
echo "Running Doctrine migrations..."
kamal app exec --reuse --interactive -- php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
Mark it executable with chmod +x .kamal/hooks/pre-deploy. This hook runs on the deploy machine, executes the migration command inside a running container on the target host, and blocks the deploy from proceeding if the migration fails. The --reuse flag means it uses an already-running container rather than spinning up a temporary one, which keeps the migration running inside the same environment as your application.
For the hook to work reliably, your migrations need to be backward-compatible with the previous version of the application. This means: add columns as nullable before making them required, and never drop a column in the same migration that removes the code referencing it. This is not a Kamal constraint - it is a requirement of any zero-downtime deployment strategy.
The post-deploy hook is the right place for cache warming or search index updates that can run after traffic has already switched:
#!/usr/bin/env bash
set -e
kamal app exec --reuse -- php bin/console cache:warmup --env=prod
The Proxy Configuration: kamal-proxy in Practice
kamal-proxy handles TLS termination via Let's Encrypt automatically when you set ssl: true in the proxy block, manages the rolling cutover between old and new containers, and runs as its own Docker container managed by Kamal itself. For most deployments, it requires no additional configuration beyond what is shown above.
Some teams reach for Caddy or Nginx instead, usually because they already operate one of those for other services on the same host or because they need more complex routing rules (multiple applications behind the same domain, custom header rewriting). If you go this route, you can tell Kamal to skip its managed proxy by setting proxy: false and configuring your own reverse proxy to point at the port your application container exposes.
The tradeoff is that you lose the automatic health-check-gated cutover at the proxy level. Kamal will still start the new container and stop the old one in sequence, but the transition between them at the network layer is your reverse proxy's responsibility. For most applications, kamal-proxy is the simpler choice and the one that requires less ongoing maintenance.
Deploy Timing: What to Expect
On a single 2-vCPU VPS with a modest Symfony application (roughly 40MB Docker image, warm opcode cache), a typical kamal deploy run breaks down like this:
| Phase | Duration |
|---|---|
| Build and push image | 45-90s |
| Pull image on target host | 10-20s |
| Run pre-deploy hook (migrations) | 5-15s |
| Start new container, health check threshold | 15-20s |
| kamal-proxy cutover | < 1s |
| Stop old container (drain window) | 15s |
| Total | ~90-160s |
The long pole is almost always image build time. Optimizing your Dockerfile layer order to maximize cache hits - installing system dependencies and Composer packages before copying application code - is the highest-leverage improvement available. A well-structured Dockerfile can cut build time from 90 seconds to under 10 seconds on a warm cache.
Where Kamal Stops Being the Right Answer
Kamal is honest about its scope, and you should be too. The following scenarios are legitimate reasons to move to Kubernetes or another orchestration platform.
Multi-region deployments with intelligent routing. Kamal knows about hosts, not regions. If you need traffic routed to the nearest geographic cluster, latency-based failover, or active-active across data centers, you need a platform with topology awareness.
GPU workloads. Kamal has no concept of resource requirements beyond what Docker itself exposes. If your application does inference or training, you need an orchestrator that can schedule containers onto nodes with specific hardware.
Stateful distributed systems. Running a database as a Kamal accessory works fine on a single host. Distributed stateful systems - clustered databases, distributed caches with co-location requirements - need persistent volume management and scheduling primitives that Kamal does not provide.
Large engineering teams needing self-service. If 50 developers are each deploying independent services, you will eventually want a platform layer with quota management, guardrails, and a UI that does not require direct server access. Kamal is a CLI tool for teams that have that access.
For a focused SaaS product or a consulting engagement deploying to dedicated infrastructure, none of these constraints apply. Kamal is the right tool, and reaching for Kubernetes to hedge against imaginary future scale is the kind of tech stack complexity that slows delivery without protecting you from anything real.
Getting the Configuration Right
The patterns above are not exotic - they reflect what a production Kamal 2.x setup actually looks like after working through the rough edges. The proxy block, the secrets file, the pre-deploy migration hook, and the stop-wait window cover most of what separates a fragile deploy process from a reliable one.
If you are evaluating Kamal for a new Symfony project or migrating from a manual deploy process, the setup is manageable. But if you are running applications with complex migration behavior, legacy codebases with unpredictable side effects during deploy, or client environments where a failed deploy carries real consequences, a review from someone who has run this in production is worth the time.
Reach out at hello@wolf-tech.io or through wolf-tech.io.

