Kamal 2 Deployment for SaaS
Stacknaut deploys a full SaaS app to your own server using Kamal 2, the Docker deployment tool from 37signals. One command deploys your frontend, backend, public API, PostgreSQL database, Caddy reverse proxy, and log collection with zero downtime.
Why Kamal 2
Your app runs on a server you control, at a price you choose. No Vercel, no Render, no Fly.io. No per-request pricing surprises, no cold starts, no platform lock-in.
kamal deploy builds Docker images, pushes them, and rolls out new containers with zero downtime. No CI/CD pipeline to configure, no Kubernetes to learn. One command deploys everything.
Kamal is built by 37signals — it runs HEY and Basecamp in production. It handles real traffic at scale, but it's simple enough that a solo developer can understand the entire deployment flow.
You get the reproducibility of Docker containers without the orchestration complexity. No Kubernetes, no ECS, no Docker Swarm. Kamal manages containers directly on your server. The proxy health-checks new containers before switching traffic — if the deploy fails, the old container keeps serving.
How It Works
Kamal builds Docker images locally, pushes them to Docker Hub, and deploys them to your server. The Kamal proxy handles SSL termination (via Let's Encrypt), routing, and zero-downtime container swaps.
Your app runs as three services on a single server:
- Web — Vue frontend served by Caddy (port 80)
- Backend — Fastify API for the frontend (port 3001)
- API — Public-facing API service (port 3002)
Plus two accessories:
- PostgreSQL 16 — Your database, with persistent volume storage
- Vector — Log collector that forwards to BetterStack
Each service has its own deploy configuration and environment variables. Backend and API have explicit health checks (/health endpoint); the web service is a static file server. Kamal proxy routes traffic to the right service based on hostname.
Deploy Commands
# First-time setup (provisions everything)
kamal setup
# Full deploy (all services)
kamal deploy
# Deploy a single service
kamal deploy --roles=backend
kamal deploy --roles=api
kamal deploy --roles=web
# Smart deploy (auto-detects which services changed)
scripts/push-and-deploy.sh
# Force deploy without prompts
scripts/push-and-deploy.sh --force
What kamal deploy Does
kamal deploy is the main command you run after the first setup. It builds the Docker images, pushes them to the registry, starts the new containers on your server, waits for health checks, then switches traffic through Kamal Proxy.
For the Stacknaut app, that means one deploy can update the Vue frontend, Fastify backend, public API, PostgreSQL accessory wiring, Caddy config, and Vector log collection. You can also deploy one role when only part of the app changed:
kamal deploy --roles=web
kamal deploy --roles=backend
kamal deploy --roles=api
I still prefer the included smart deploy script for day-to-day work because it detects the changed service for you. But the underlying deployment path is plain Kamal 2, so there is no hidden CI system or dashboard state to debug.
Smart Deploy Script
The included push-and-deploy.sh script compares your local changes against origin/main and only deploys services that changed:
- Changes in
backend/→ deploys backend - Changes in
frontend/→ deploys web - Changes in
api/→ deploys API - Changes in
shared/→ deploys all (shared code affects everything) - Schema changes in
shared/src/schemas/→ deploys backend first (runs migrations), then the rest
This saves time on every deploy — if you only changed the frontend, only the frontend gets rebuilt and deployed.
Zero-Downtime Deploys
Kamal achieves zero downtime by:
- Building and pushing the new Docker image
- Starting a new container alongside the old one
- Health-checking the new container (backend and API hit
/health; web checks that Caddy is serving on port 80) - Switching the proxy to route traffic to the new container
- Stopping the old container
If the health check fails, the old container keeps serving traffic and the deploy fails safely.
Frontend Build Process
The frontend has a unique build process — it builds at deploy time inside the container:
- Installs shared module dependencies
- Installs frontend dependencies (including devDependencies for Vite)
- Runs
pnpm run build-only(Vite build + pre-rendering) - Renames
index.htmltoindex-default.htmlfor SPA fallback - Starts Caddy to serve the static files
Pre-rendered pages get their own HTML files (index-pricing.html, index-docs.html, etc.) and Caddy rewrites route paths to the correct file. Non-pre-rendered routes fall back to index-default.html for client-side routing.
If you're adding a new public SEO route, the important detail is that the build and server config have to change together. I use the same Vite pre-rendering pattern described in Prerender Vue SPA routes with Vite: list the route in Vite, set route metadata, add the Caddy rewrite, and put the canonical URL in the sitemap.
Configuration
The central deployment config lives in config/deploy.yml:
- Server IP and hostnames
- Docker Hub credentials
- Environment variables per service (tagged by role)
- Database and Vector accessory configuration
- SSL and proxy settings
Environment secrets go in .env.kamal (never committed to git).
What You Get
- One-command deployment with
kamal setupandkamal deploy - Zero-downtime container swaps with health checks
- Smart deploy script that auto-detects changed services
- Schema-aware deploy ordering (backend first when migrations needed)
- SSL via Let's Encrypt (auto-renewed)
- Per-service environment variable management
- Frontend pre-rendering at build time
- Caddy file server with security headers and scanner blocking
- PostgreSQL as a managed Kamal accessory
- Vector log collection to BetterStack
- 5 container retention for quick rollbacks
Key Files
config/deploy.yml — Kamal deployment configuration
kamal-deploy/Caddyfile — Frontend proxy config with rewrites
kamal-deploy/run-frontend — Frontend build + Caddy startup
kamal-deploy/run-backend — Backend startup script
kamal-deploy/run-api — API startup script
scripts/push-and-deploy.sh — Smart deploy with change detection
.env.kamal — Production secrets (not committed)
Setup
- Install Kamal 2:
gem install kamal - Create a Docker Hub account
- Update
config/deploy.ymlwith your server IP, hostnames, and Docker Hub username - Create
.env.kamalfrom.env.kamal.exampleand fill in your secrets - Point your domain's DNS A records to the server IP
- Run
kamal setupfor first-time deployment
For later deploys, run scripts/push-and-deploy.sh.
Deploy without platform sprawl
Use the same Kamal + Hetzner setup I ship with.
Stacknaut includes the app, deployment config, and Terraform repo so your agent works against a complete production path.
What you get in Stacknaut
- Kamal deploy.yml, Dockerfiles, Caddy config, and smart deploy scripts
- Terraform for a hardened Hetzner server with firewall and fail2ban
- Postgres and log collection wired as production accessories