Rails 8

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.

ANAbhay Nikam, Founder, Navio Labs

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/secrets and a production Dockerfile).
  • 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: true with a host turns 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: true runs Solid Queue inside the web process via its Puma plugin. On one server that’s all you need for background jobs.
  • asset_path keeps 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-db here).
  • builder.arch: amd64 matters 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_dump to 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 job role on a second server running bin/jobs, and remove SOLID_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

More blogs

Rather have someone do this with you?

That’s what we do. Tell us where you’re stuck and you’ll hear back from the founder within one business day.

Start a project with Navio Labs
Get help with this