Setting up a single Rails 8 server with Postgresql and Kamal

Setting up a single Rails 8 server with Postgresql and Kamal

Here's a straightforward guide to getting a Rails app going using Kamal, with postgresql as the backend, on a single server, that actually works without you having to figure out a bunch of crazy shit.

Kamal 2 is a fantastic bit of code, something between Dokku (which is quite good for a single server but doesn't scale to more than one), and the monstrosity that is Kubernetes (which 99.99% of web apps will never ever need and shouldn't even have nightmares about).

Its documentation is also absolutely horrendous.

I've read through the documentation site a bunch of times by now. I bought both of Jozef Strzibny's books on Kamal. I've been on the Discord for a year. It's really not enough.

Kamal is one of the worst documented tools I've ever used.

It's made worse by the fact that deploying things is the kind of activity that's filled with edge cases and specificities that work for only that app... which makes it all extra hard when the tool is poorly documented.

This is all made even worse by the fact that a v2 of Kamal was released a year after the v1, whilst everyone was still trying to get to grips with v1, and it had a bunch of backwards-incompatible changes, settings that moved or disappeared, etc.

Both LLMs and human helpers on the Kamal Discord can be confused by this:

In fact, Kamal 2 is so bad... it took me a day to go through applying the steps in this article, even though I'd deployed a similar site 2 months earlier.

Kamal 2 is the worst... except for all the other options. At least once you figure your way around it...

So, with that in mind, here's a guide that actually works for setting up a Rails server with Kamal 2 in 2025, on a Hetzner server that you might have installed following these instructions.

It's actually pretty straightforward, once you know what to do. Figuring out what to do is the part that takes many hours.

Prerequisites

You have:

  • A rails 8 app running on your local machine, synced to Github
  • A Github account (free is fine)
  • A Docker account (free is fine)
  • A reasonable level of comfort using the command line

Set up DNS

First, of course, point whatever domain you want to serve your app under, to your server IP, by creating an A record pointing to that IP. E.g. yourapp.example.com -> 1.2.3.4.

Initial configuration

First, we install the kamal gem and set it up.

bundle add kamal
kamal init

This creates a skeleton config/deploy.yml. Get this into the file:

# config/deploy.yml

service: yourapp
image: yourdockerusername/yourapp

registry:
  username: yourdockerusername
  password:
  - KAMAL_REGISTRY_PASSWORD

ssh:
  user: yourusername
  port: 12444 # This is the SSH port for your server

env:
  clear:
    RAILS_SERVE_STATIC_FILES: true # Let Rails serve static files (since no external proxy in production)
    RAILS_LOG_TO_STDOUT: true # Log to STDOUT for Docker logging
  secret:
    - RAILS_MASTER_KEY # Rails master key (provide in .kamal/secrets)
    - DATABASE_URL # Database connection string (provided per env in secrets)

servers:
  web:
    hosts:
      - 1.2.3.4 # The IP of the staging server from Hetzner
  jobs:
    hosts:
      - 1.2.3.4 # Same IP - we'll be running the jobs here
    cmd: "./bin/rails solid_queue:start"

proxy:
  ssl: true
  host: yourapp.example.com
  app_port: 3000

builder:
  arch: amd64
  remote: ssh://yourusername@1.2.3.4:12444
  args:
    RAILS_ENV: production
  cache:
    type: registry
    options: mode=max
    image: yourdockerusername/yourapp:production-build-cache

accessories:
  postgres:
    image: postgres:16.2 # Use the correct version for your app
    host: 1.2.3.4
    env:
      clear:
        POSTGRES_USER: yourapp
        POSTGRES_DB: yourapp_production
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data # Persist db data on host (named volume "data")

Many tutorials will leave this at that, but the problem with setting up Kamal for deployment is you're not just setting up Kamal to deploy your app, you also need to set up your app to be deployed by Kamal.

Rails Credentials

While developing, you will typically just have dev credentials (encrypted by config/master.key) and test credentials, curiously placed elsewhere, under config/credentials/test.key.

For production, you need to set up a new environment, with rails credentials:edit --environment=production, which will create config/credentials/production.key and config/credentials/production.yml.enc. Make sure you put all the necessary credentials for production in there.

While you're at it, I find it makes sense to move the master.key to config/credentials/development.key, and put the encrypted credentials under config/credentials/development.yml.enc, so it all makes a bit more sense.

Kamal Secrets

In order for the above to work, you need, for example, to set up some Kamal secrets.

This is done via a .kamal/secrets file like this:

RAILS_MASTER_KEY=$(cat config/credentials/production.key)
KAMAL_REGISTRY_PASSWORD=$(cat config/credentials/deployment/kamal_password.key)
POSTGRES_PASSWORD=$(cat config/credentials/deployment/postgres_pw_prod.key)
DATABASE_URL="postgres://yourapp:$(cat config/credentials/deployment/postgres_pw_prod.key)@yourapp-postgres:5432/yourapp_production"

An important thing you'll notice here is that rather than include the credentials in this file, we are importing them from elsewhere. My favourite way is to have them in .key files under config/credentials/deployment/*.key. I then add the following line to .gitignore:

/config/credentials/deployment/*

Database setup

POSTGRES_PASSWORD can be whatever you want it to be. DATABASE_URL should be set so that the following will work out of the box in config/database.yml:

production:
  <<: *default
  url: <%= ENV['DATABASE_URL'] %>

Basically, POSTGRES_PASSWORD will be used by Kamal to set up the database, and DATABASE_URL will be used by Rails to connect to that database.

But in Rails 8, if you use SolidQueue, SolidCable and SolidCache, which you totally should, you also have 3 more databases. Typically they are specified in this way:

production:
  primary: &primary_production
    <<: *default
    url: <%= ENV["DATABASE_URL"] %>
  cache:
    <<: *primary_production
    database: yourapp_production_cache
    database_tasks: true
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    database: yourapp_production_queue
    database_tasks: true
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_production
    database: yourapp_production_cable
    database_tasks: true
    migrations_paths: db/cable_migrate

That looks like it will work... but it won't. Because the DATABASE_URL will supersede the database names. This will lead to the deployment failing to create the necessary databases, unless you fix it this way:

production:
  primary: &primary_production
    <<: *default
    url: <%= ENV["DATABASE_URL"] %>
  cache:
    <<: *primary_production
    url: <%= URI.parse(ENV['DATABASE_URL']).tap { |u| u.path += '_cache' } if ENV['DATABASE_URL'] && Rails.env.production? %>
    database_tasks: true
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    url: <%= URI.parse(ENV['DATABASE_URL']).tap { |u| u.path += '_queue' } if ENV['DATABASE_URL'] && Rails.env.production? %>
    database_tasks: true
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_production
    url: <%= URI.parse(ENV['DATABASE_URL']).tap { |u| u.path += '_cable' } if ENV['DATABASE_URL'] && Rails.env.production? %>
    database_tasks: true
    migrations_paths: db/cable_migrate

Email integration

Most likely your app will need to send email - maybe the first thing it will do is send you a confirmation email as you sign up the first (site admin) user.

In dev, a gem like letter_opener means you don't have to worry about it. But of course that won't work in production.

This is also not usually documented in most deployment guides. Here's a simple way to do it with Mailgun. After signing up, obtain an SMTP password, and add credentials like this in your credentials file:

mailgun:
  smtp_login: "youremail@yourapp.example.com"
  smtp_password: "48439834983498384839483989483943-34839498439843"
  smtp_server: "smtp.eu.mailgun.org" # use the us mail server if that makes more sense for you
  smtp_port: 587
  domain: yourapp.example.com

And in your config/environments/production.rb, add the following lines:

  if Rails.application.credentials.dig(:mailgun).present?
    config.action_mailer.delivery_method = :smtp
    config.action_mailer.default_options = { from: "yourapp@#{Rails.application.credentials.dig(:mailgun, :domain) }
    config.action_mailer.smtp_settings = {
      address: Rails.application.credentials.dig(:mailgun, :smtp_server),
      port: Rails.application.credentials.dig(:mailgun, :smtp_port),
      domain: Rails.application.credentials.dig(:mailgun, :domain),
      user_name: Rails.application.credentials.dig(:mailgun, :smtp_login),
      password: Rails.application.credentials.dig(:mailgun, :smtp_password),
      authentication: "plain",
      enable_starttls_auto: true
    }
  end

Dockerfile

Finally, one more thing you will need for all this to work is a Dockerfile that builds your app. Now technically, Rails 8 ships with a Dockerfile... Except that by the time you do a deployment, this Dockerfile might not quite work for you, for example if you installed postgres and it needs some special libraries to work, or you have a more bespoke build process, etc.

I can't help you with the specifics of how your Dockerfile needs to work, but here's a Dockerfile that works for a rails web app with postgres that has an esbuild build process orchestrated by yarn, and, crucially, using buildx caching so it doesn't take 10 minutes each time you deploy:

# syntax=docker/dockerfile:1.7  # keep this at the very top of the Dockerfile

########################
# BASE (from ruby:3.4.4-slim)
########################

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
ARG RUBY_VERSION=3.4.4
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Set production environment
ARG RAILS_ENV=production
ENV BUNDLE_DEPLOYMENT="1" \
  BUNDLE_PATH="/usr/local/bundle" \
  BUNDLE_WITHOUT="development"

########################
# DEPENDENCIES (from base)
########################
# One consolidated apt layer (PGDG for psql 16, Node repo key, etc.)

# Dependencies base image to speed things up!
FROM base AS dependencies

# Use BuildKit cache to speed up apt metadata
# 1) Base apt tools (uses cache mount)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    set -eux; \
    apt-get update -o Acquire::Retries=3; \
    apt-get install -y --no-install-recommends \
      ca-certificates curl gnupg lsb-release xz-utils wget; \
    rm -rf /var/lib/apt/lists/*

# 2) PGDG repo (no apt cache needed here; just writing files)
RUN set -eux; \
    curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
      | gpg --dearmor > /usr/share/keyrings/pgdg.gpg; \
    echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
      > /etc/apt/sources.list.d/pgdg.list

# 3) NodeSource repo (also just writes files)
RUN set -eux; \
    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
      | gpg --dearmor > /usr/share/keyrings/nodesource.gpg; \
    echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
      > /etc/apt/sources.list.d/nodesource.list

# 4) System deps (cache mount again)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    set -eux; \
    apt-get update -o Acquire::Retries=3; \
    apt-get install -y --no-install-recommends \
      build-essential git pkg-config \
      libvips libpq-dev libicu-dev libyaml-dev libssl-dev libreadline-dev zlib1g-dev \
      passwd vim neovim \
      postgresql-client-16 ffmpeg; \
    rm -rf /var/lib/apt/lists/*

# 5) Pin Node + enable Corepack (cache mount again)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    set -eux; \
    apt-get update -o Acquire::Retries=3; \
    apt-get install -y --no-install-recommends nodejs=22.19.0-1nodesource1; \
    rm -rf /var/lib/apt/lists/*; \
    corepack enable

########################
# BUILD (from dependencies)
########################

FROM dependencies AS build

RUN apt-get update -qq

# 1) Gems (layer keyed only by Gemfile*)
COPY Gemfile Gemfile.lock .ruby-version ./
RUN --mount=type=cache,target=/usr/local/bundle/cache \
    bundle install && bundle exec bootsnap precompile --gemfile

# 2) JS PM + deps
COPY package.json yarn.lock ./
# force node_modules linker for this build
RUN corepack enable && yarn config set nodeLinker node-modules
RUN --mount=type=cache,target=/root/.cache/node/corepack corepack prepare yarn@1.22.22 --activate
RUN --mount=type=cache,target=/root/.cache/yarn yarn install --frozen-lockfile

# 3) Copy minimal inputs for assets (adjust for your stack)
COPY app app
COPY lib lib
COPY bin bin
COPY db db
COPY public public
COPY Rakefile config.ru ./
COPY config/ config/
COPY bin/ bin/
COPY *.config.js .
COPY vite.config.ts .

RUN mkdir -p log tmp && : > log/solid_services_production.log

# 4) Compile assets (this handles both Rails assets and Vite build)
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile

# 5) Bring in the rest of the app; bootsnap app code
RUN bundle exec bootsnap precompile app/ lib/

########################
# FINAL (from dependencies)
########################

# Final stage for app image
FROM dependencies AS final
WORKDIR /rails

RUN curl -fsSLo /usr/local/bin/supercronic https://github.com/aptible/supercronic/releases/latest/download/supercronic-linux-amd64 \
  && chmod +x /usr/local/bin/supercronic

# Non-root user and writable dirs
RUN useradd -m -s /bin/bash rails && \
    install -d -o rails -g rails /rails/storage /rails/tmp /rails/tmp/pids /rails/log

# Copy only what's needed at runtime; set ownership at copy time
COPY --from=build --chown=rails:rails /usr/local/bundle /usr/local/bundle
COPY --from=build --chown=rails:rails /rails/app     /rails/app
COPY --from=build --chown=rails:rails /rails/bin     /rails/bin
COPY --from=build --chown=rails:rails /rails/config  /rails/config
COPY --from=build --chown=rails:rails /rails/lib     /rails/lib
COPY --from=build --chown=rails:rails /rails/public  /rails/public
COPY --from=build --chown=rails:rails /rails/Rakefile /rails/config.ru /rails/Gemfile /rails/Gemfile.lock ./

USER rails:rails

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

This Dockerfile works to build the HelixKit app kit which I'm developing and is on Github, and includes Svelte compilation, Vite, Postgres, etc. You may need to adjust it to your needs, but at least this is a reasonably complex starting point.

You can test if your build works locally by running:

docker build

in your Rails folder.

Actually build and deploy

Having done all this setup, it's time to build and deploy. Let's try building first:

kamal build create
kamal build deliver

This should complete. If not, fix that.

Then, let's try actually deploying it all out. First, boot up postgres so it's up when the Rails server goes up.

kamal accessory boot postgres

Then actually deploy your app:

kamal deploy

And... this should be it!

Bonus helper scripts

Create the following scripts under bin/deployment/. First, one to access the console on your app server, bash_console.sh:

#!/bin/bash

kamal app exec --interactive --reuse "bash"

And next, one to access the postgresql server, and optionally run one-off commands on it, pg_console.sh:

#!/bin/bash

# pg_console.sh
# Usage:
#   ./pg_console.sh                  -> open interactive psql session
#   ./pg_console.sh "SQL_COMMAND"    -> run SQL command non-interactively

set -euo pipefail

SERVER="misc"
POSTGRES_CONTAINER="helix-kit-postgres"

PGURL="postgresql://helix_kit:$(cat config/credentials/deployment/postgres_pw_prod.key)@$POSTGRES_CONTAINER:5432/helix_kit_production"

if [ $# -eq 1 ]; then
    # Non-interactive: run SQL command
    SQL_COMMAND="$1"
    echo "Running on $SERVER ($POSTGRES_CONTAINER): $SQL_COMMAND"
    ssh $SERVER -t "docker exec -i $POSTGRES_CONTAINER psql \"$PGURL\" -c \"$SQL_COMMAND\""
else
    # Interactive mode
    echo "Connecting to $SERVER server, $POSTGRES_CONTAINER container..."
    echo "--------------------------------"
    echo "Handy pgsql commands:"
    echo "\c helix_kit_production -- switch to helix_kit_production database"
    echo "\l -- list all databases"
    echo "\dt -- list all tables"
    echo "\du -- list all users"
    echo "\dn -- list all schemas"
    echo "\di -- list all indexes"
    echo "\di+ -- list all indexes with details"
    echo "\di* -- list all indexes with details and statistics"
    echo "\q -- quit"
    echo "--------------------------------"

    ssh $SERVER -t "docker exec -it $POSTGRES_CONTAINER psql \"$PGURL\""
fi

Next...

In a later article, I'll detail how I got a more complicated version of this setup across 2 servers, that includes live postgres data replication and robust failover scripts, so that, for about €70 a month, you can have not only extraordinarily beefy servers, but also have them set up to never lose any data and be able to recover from a server failure in a matter of minutes.