RSS Amplifier

GingerDev · Aug 11, 2025

Single‑Server Deployment for Rails 8 + Postgres with Kamal

0
Sign in to vote or save

Abhinay Kumar · GingerDev

This is my first attempt to deploy a Rails 8 application with Kamal and what I found is that Kamal has the easiest configuration to deploy any web application on server. This blog will mostly focus on deployment on a single server and in case you have a requirement to deploy on multiple servers with load-balancer configured then you should checkout the demo video by DHH on the Kamal’s website.

Reference: Kamal docs for installation and workflow: https://kamal-deploy.org/docs/installation/

What you’ll end up with, A single Linux server (Ubuntu 22.04+ is great) running:

  1. Docker Engine

  2. kamal-proxy terminating TLS on ports 80/443 (Let’s Encrypt)

  3. Your Rails 8 app as a container (non‑root, serves static files)

  4. A Postgres 15 accessory container with a persistent volume

  5. Continuous deploys with `kamal deploy`

  6. Asset precompilation in the image and zero 404s on fingerprinted CSS/JS

  7. Solid Cache / Queue / Cable backed by Postgres databases

Pre-requisites:

Install Kamal and make sure Docker can push to your registry (Docker Hub in this guide):
# Kamal is added by default on Rails 8 application
In case it is not, you can always add it your Gemfile or run:
$ gem install kamal
$ kamal init
  • Login to your Dockerhub account

  • Go to “Account settings“ → “Settings” → “Personal access token“

    • generate a new token This token then can be saved as an environment variables in your .zshrc or .bashrc file

      # .zshrc
      :
      export KAMAL_REGISTRY_PASSWORD=”Personal access token”

If you’ll use HTTPS, point your domain A record at the server IP now. Kamal’s proxy will request a Let’s Encrypt cert for `proxy.host` during setup.

Edit your .kamal/secrets file and these env variables: (MAKE SURE YOU DO NOT ADD THE ACTUAL VALUES HERE, ONLY THE ENVIRONMENT VARIABLE NAMES)

KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
# I had to use path production.key even though the official doc says
# master.key. You can run
# EDITOR=vim rails credentials:edit --environment productio
# to generate production.key and production.yml.enc (where your
# application specific envs can be stored.)
RAILS_MASTER_KEY=$(cat config/credentials/production.key)
# This can also be saved in your .zshrc file
# export POSTGRES_PASSWORD=<Database Password>
POSTGRES_PASSWORD=$DATABASE_PASSWORD 

Now, you can run the setup command:

kamal setup

As per the document this is all that is required to deploy your application however the setup failed for me couple of times and I will list the errors and solutions below in case you ever face the same issues:

Solution: You need to make sure your SSH user has sudo and key-based auth. Then set up Docker permissions so you don’t need `sudo` for every command.

Run these command:

# 1) Create docker group if missing; add your user to it
$ ssh <user>@<server> 'sudo getent group docker >/dev/null || sudo groupadd docker'
$ ssh <user>@<server> 'sudo usermod -aG docker <user>'
# 2) Enable and start Docker
$ ssh <user>@<server> 'sudo systemctl enable --now docker || sudo service docker start'
# 3) IMPORTANT: open a NEW SSH session so group membership takes effect
$ ssh <user>@<server> 'docker version'

Solution: Add these packages to Dockerfile.

# Dockerfile
:
:
apt-get install -y libpq-dev
apt-get install -y libpq5
`ActiveRecord::ConnectionNotEstablished` and attempts to use a local Unix socket.

Solution:

# deploy.yaml
:
:
:
  env:
    clear:
      DATABASE_HOST: application-name-db
      DATABASE_PORT: 5432
P.S: Make sure accessory uses `POSTGRES_USER`/`POSTGRES_PASSWORD`, and the app uses the same values.

If you initialized Postgres with the wrong creds and need a do‑over (DATA LOSS):

$ bin/kamal accessories stop db
$ ssh <user>@<server> 'docker volume rm application-name-db-data || true'
$ bin/kamal accessories boot db

Solution:

1) If you are using tailwind v4 then try downgrading to v3. In my case, this was an instant fix.

gem 'tailwindcss-rails', '~> 3.3', '>= 3.3.2'
# You need to run this command to install required files for Tailwind to # compile successfully
$ run: `bin/rails tailwindcss:install`

2) Static files not served by app

# config/deploy.yml
:
:
:
env:
  clear:
    RAILS_SERVE_STATIC_FILES: true

Solution: This happens when Kamal prunes old images. It’s cosmetic.

Run these commands to fix it:

$ ssh <user>@<server> "docker system prune -f && docker image prune -a -f --filter label=service=job_application"
$ kamal deploy

Here is how the final Dockerfile looks:

# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t application-name .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name application-name application-name
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libpq5 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libpq-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

Final deploy.yml should look like this:

# Name of your application. Used to uniquely configure containers.
service: application-name
# Name of the container image.
image: dockerhub-username/application-name
# Deploy to these servers.
servers:
  web:
    - your.domain.com
  # job:
  #   hosts:
  #     - 192.168.0.1
  #   cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.
#
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
proxy:
  ssl: true
  host: your.domain.com
# Credentials for your image host.
registry:
  # Specify the registry server, if you're not using Docker Hub
  # server: registry.digitalocean.com / ghcr.io / ...
  username: dockerhub-username
  # Always use an access token rather than real password when possible.
  password:
    - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
    - POSTGRES_PASSWORD
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true
    # Set number of processes dedicated to Solid Queue (default: 1)
    # JOB_CONCURRENCY: 3
    # Set number of cores available to the application on each server (default: 1).
    # WEB_CONCURRENCY: 2
    # PostgreSQL database configuration
    DATABASE_HOST: application-name-db
    DATABASE_PORT: 5432
    POSTGRES_USER: application-name
    # Log everything from Rails
    RAILS_LOG_LEVEL: debug
    RAILS_SERVE_STATIC_FILES: true
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
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"
# Use a persistent storage volume for local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
  - "application-name_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
  arch: amd64
  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  # remote: ssh://docker@docker-builder-server
  #
  # # Pass arguments and secrets to the Docker build process
  # args:
  #   RUBY_VERSION: 3.2.3
  # secrets:
  #   - GITHUB_TOKEN
  #   - RAILS_MASTER_KEY
# Use a different ssh user than root
ssh:
  user: <ssh-user-name>
# Use accessory services (secrets come from .kamal/secrets).
accessories:
  db:
    image: postgres:15
    host: <server-ip> # or your domain name
    # Change to 5432 to expose port to the world instead of just local network.
    port: "127.0.0.1:5432:5432"
    env:
      clear:
        POSTGRES_DB: application-name_production
        POSTGRES_USER: application-name
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
    options:
      health-cmd: "pg_isready -U application-name"
      health-interval: "10s"
      health-timeout: "3s"
      health-retries: 3

Here is how the .kamal/secrets file should look:

# Grab the registry password from ENV
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/credentials/production.key)
POSTGRES_PASSWORD=$DATABASE_PASSWORD

Your database.yml should look something like this:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
  host: <%= ENV["DATABASE_HOST"] %>
  port: <%= ENV["DATABASE_PORT"] %>
production:
  primary:
    <<: *default
    database: application-name_production
    username: <%= ENV['POSTGRES_USER'] %>
    password: <%= ENV['POSTGRES_PASSWORD'] %>
  cache:
    <<: *default
    database: application-name_production_cache
    username: <%= ENV['POSTGRES_USER'] %>
    password: <%= ENV['POSTGRES_PASSWORD'] %>
    migrations_paths: db/cache_migrate
  queue:
    <<: *default
    database: application-name_production_queue
    username: <%= ENV['POSTGRES_USER'] %>
    password: <%= ENV['POSTGRES_PASSWORD'] %>
    migrations_paths: db/queue_migrate
  cable:
    <<: *default
    database: application-name_production_cable
    username: <%= ENV['POSTGRES_USER'] %>
    password: <%= ENV['POSTGRES_PASSWORD'] %>
    migrations_paths: db/cable_migrate

Some useful commands:

# Deploy latest main
kamal deploy
# Tail logs
bin/kamal logs
bin/kamal app logs -f
# Rails console / shell on the server
bin/kamal console
bin/kamal shell
# Postgres logs
bin/kamal accessories logs db -f
# Clean orphaned Docker resources on the server
ssh <user>@<server> 'docker system prune -f && docker image prune -a -f --filter label=service=application-name'

This workflow gives you a “batteries included” single-server deployment:
- HTTPS via Kamal’s proxy, a private image on Docker Hub, Postgres as an accessory, and Rails 8 features like Solid Cache/Queue/Cable backed by separate databases.
- Most problems boil down to: missing `KAMAL_REGISTRY_PASSWORD`, Docker permissions on the server, libpq headers for the `pg` gem, or asset serving in production.

With the commands above in your pocket, you can iterate quickly and deploy with confidence.

Kya Kamal ki cheez hai ye!!

Read the original on gingerdev.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.