What It Took to Deploy This Site with Kamal
One config file, two accessories, and a one-line deploy script — plus the two things that nearly shipped placeholder posts to a live site.

This site runs on a single small VPS, deployed with Kamal. No Kubernetes, no managed platform, no CI pipeline — one command from my laptop builds a container, ships it to the box, and swaps it in behind a proxy that handles TLS. Here's what it actually took, including the two things that nearly bit me.
The shape of the config
Kamal reads one file. Mine names the service, points at the server, and lists the domains the proxy should answer for:
service: myapp
image: myapp
servers:
web:
hosts:
- app.example.com
proxy:
hosts:
- example.com
- www.example.com
- app.example.com
ssl: true
app_port: 80
healthcheck:
path: /up
Postgres and Redis run as accessories — containers Kamal boots on the same host, on the same Docker network, addressable by name:
accessories:
redis:
image: redis:7-alpine
port: "127.0.0.1:6379:6379"
cmd: redis-server --appendonly yes
directories:
- data:/data
postgres:
image: postgres:17
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
Both publish on 127.0.0.1 only. That's the difference between "I can run psql after SSHing in" and "my database is on the public internet." The app doesn't use the published ports at all — it reaches myapp-postgres:5432 over the Docker network, because Kamal names accessory containers <service>-<accessory>.
Secrets
Secrets live in .env.production, which is gitignored, and .kamal/secrets, which is committed and contains no values — only instructions for fetching them:
RAILS_MASTER_KEY=$(cat config/master.key)
POSTGRES_PASSWORD=$(. ./.env.production 2>/dev/null; printenv POSTGRES_PASSWORD)
DATABASE_URL=postgres://myapp:$POSTGRES_PASSWORD@myapp-postgres:5432/myapp
There's a trap in that file worth knowing about. .kamal/secrets is not a shell script — Kamal parses it with Dotenv. source lines and conditionals are silently ignored; only KEY=VALUE and $(...) command substitution are evaluated. Worse, Dotenv expands $VAR references inside the $(...) against its own parsed environment before running the command, so the obvious $(. ./.env.production; echo "$POSTGRES_PASSWORD") expands to echo "" and hands you an empty password. printenv takes the name as a literal argument and reads it after the dot-source runs, which is why it works where echo doesn't.
Deploying is then one line, in deploy.sh:
source .env.production && kamal deploy
The first run
kamal deploy doesn't provision anything — on a bare server you want kamal setup, which installs Docker, boots the accessories, then deploys. Start to finish it took about three minutes, most of that cross-compiling gems for linux/amd64 from an arm64 laptop.
Migrating the old content afterwards was a one-off container against the same database:
kamal app exec 'bin/rails blog:import'
325 posts and 61 comments, no downtime, because the running web container was never touched.
Was it worth it
The whole deployment story is four files: config/deploy.yml, .kamal/secrets, .env.production, and a one-line deploy.sh. Everything is a container on one box I can SSH into. When something breaks I read logs with kamal logs, open a console with kamal console, and get a shell with kamal shell.
Comments