Deploying Rails 8 with Kamal 2 on a single server
From a fresh VPS to HTTPS and zero-downtime deploys in an afternoon: deploy.yml, secrets, a database accessory, background jobs and rollbacks.
A lot of Rails apps don’t need a platform. They need one decent server, a database, HTTPS, and a deploy command that doesn’t take the site down. Kamal 2 gives you exactly that, and Rails 8 apps ship with it already configured.
This is the setup we use for small and medium apps: one Linux server, PostgreSQL on the same box, background jobs inside Puma, automatic SSL. You can grow from here later without starting over.
What you need
- A Rails 8 app (new apps include
config/deploy.yml,.kamal/secretsand a productionDockerfile). - A fresh Linux server with a public IP. Any provider works. We usually start with 2–4 GB of RAM.
- SSH access to that server with your key.
- A domain whose DNS A record points at the server. Let’s Encrypt checks this, so do it first.
- Docker installed on your laptop (Kamal builds the image locally by default).
- A container registry account — or, with Kamal 2.8 and later, none at all for simple deploys, since it can use a local registry. We’ll show the registry version, since that’s what most teams use in practice.
How the pieces fit
When you run kamal deploy, Kamal builds your Docker image, pushes it to the registry, SSHes into the server, pulls the image, starts a new container, waits for it to pass a health check, and then tells Kamal Proxy to send traffic to it. The old container is stopped only after the new one is healthy. That’s your zero-downtime deploy.
Inside the container, Thruster sits in front of Puma and takes care of asset caching and compression, so you don’t need Nginx.
Step 1: Fill in config/deploy.yml
Here’s a complete single-server config. Replace the names, IP and domain with yours:
# config/deploy.yml
service: myapp
image: your-registry-user/myapp
servers:
web:
- 203.0.113.10
proxy:
ssl: true
host: app.example.com
registry:
username: your-registry-user
password:
- KAMAL_REGISTRY_PASSWORD
env:
secret:
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
clear:
SOLID_QUEUE_IN_PUMA: true
DB_HOST: myapp-db
WEB_CONCURRENCY: 2
volumes:
- "myapp_storage:/rails/storage"
asset_path: /rails/public/assets
builder:
arch: amd64
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole"
accessories:
db:
image: postgres:16
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: myapp
POSTGRES_DB: myapp_production
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
A few lines deserve a note:
proxy.ssl: truewith ahostturns on automatic Let’s Encrypt certificates. This only works when you deploy to a single server, the domain’s DNS already points at it, and port 443 is reachable.SOLID_QUEUE_IN_PUMA: trueruns Solid Queue inside the web process via its Puma plugin. On one server that’s all you need for background jobs.asset_pathkeeps old and new assets available during a deploy, so a browser holding the old page doesn’t get 404s for its CSS.- The accessory’s port is bound to
127.0.0.1, so PostgreSQL isn’t exposed to the internet. Your app reaches it over Kamal’s Docker network by its container name, which is the service name plus the accessory name (myapp-dbhere). builder.arch: amd64matters if you build on an Apple Silicon Mac and deploy to an x86 server.
Step 2: Point the database config at the accessory
# config/database.yml (production section)
production:
primary: &primary_production
<<: *default
host: <%= ENV["DB_HOST"] %>
database: myapp_production
username: myapp
password: <%= ENV["POSTGRES_PASSWORD"] %>
queue:
<<: *primary_production
database: myapp_production_queue
migrations_paths: db/queue_migrate
cache:
<<: *primary_production
database: myapp_production_cache
migrations_paths: db/cache_migrate
New Rails 8 apps already have the queue and cache entries for Solid Queue and Solid Cache; you’re just pointing them at PostgreSQL. The Docker entrypoint runs bin/rails db:prepare on boot, which creates and migrates each database.
Step 3: Secrets, without committing secrets
Kamal reads secrets from .kamal/secrets. The file is committed, so it must never contain raw values — only commands or variable references that produce them:
# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
POSTGRES_PASSWORD=$(rails credentials:fetch postgres.password)
The credentials:fetch command arrived in Rails 8.1. On 8.0, read the value from an environment variable or your password manager instead — Kamal has built-in helpers for 1Password, Bitwarden and LastPass via kamal secrets fetch.
Step 4: Set up the server
kamal setup
The first run installs Docker on the server if it’s missing, starts the PostgreSQL accessory, boots Kamal Proxy, builds and pushes your image, and starts the app. When it finishes, open https://app.example.com. The certificate may take a few seconds to be issued on first request.
Rails 8 apps include a health check route at /up, which is what Kamal Proxy polls before switching traffic. If your app takes a while to boot, that’s usually the first thing to look at when a deploy times out.
Step 5: Day-to-day commands
kamal deploy # build, push, boot new container, switch traffic
kamal rollback # switch back to a previous version (pass the version)
kamal app logs # recent logs; "kamal logs" with the alias above follows them
kamal console # remote Rails console (the alias above)
kamal app details # what's running where
kamal rollback boots a previous image that’s still on the server, which is why Kamal keeps the last few containers around. Rollbacks take seconds — test one before you need it.
Step 6: Backups (don’t skip this)
Running your own database means owning your backups. At a minimum:
- Schedule a nightly
pg_dumpto object storage (a small accessory or a recurring Solid Queue job both work). - Keep at least a week of dumps.
- Restore one into a scratch database every month. A backup you’ve never restored is a hope, not a backup.
If that sounds like more than you want to own, use a managed PostgreSQL and drop the accessory. Everything else in this guide stays the same.
When to outgrow one server
Move beyond a single box when you need it, not before:
- Jobs slow down the web app: add a
jobrole on a second server runningbin/jobs, and removeSOLID_QUEUE_IN_PUMA. - The database needs room: move it to its own server or a managed service.
- You need more web capacity: add servers under
web. With more than one server, terminate SSL in front of them (a load balancer or Cloudflare) instead of relying on Kamal’s automatic certificates.
What this costs
The honest answer is “a server, plus your time to understand it.” The server is usually a small fraction of an equivalent PaaS bill. The time is an afternoon to set up and a few hours a month to keep an eye on things. For many teams that trade is very good. For some, a PaaS is still worth paying for — and that’s a fine decision too.
New to the rest of Rails 8? See what’s new in Rails 8. If you’d rather have someone set this up and hand it over with a runbook, that’s what we do.
Sources
- Kamal documentation — kamal-deploy.org
- Kamal proxy configuration — kamal-deploy.org
- Rails 8.0: No PaaS Required — rubyonrails.org
- Rails 8.1 release announcement — rubyonrails.org