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.