Kamal: Rails Way of Deploying Apps
PROGRAMMING LANGUAGES Dec. 31, 2025, 5:30 a.m.

Kamal: Rails Way of Deploying Apps

Deploying a Rails application can feel like navigating a maze of servers, gems, and environment variables. Fortunately, the Rails community has converged on a set of conventions that make the process predictable and repeatable. In this article we’ll walk through the “Rails way” of deployment, from a bare‑metal VPS to a modern container‑first workflow, and sprinkle in real‑world examples you can copy straight into your own projects.

Why “Rails Way” Matters

The Rails way isn’t just a set of commands; it’s a philosophy of automation, convention, and clear separation of concerns. By embracing tools like Capistrano, Puma, and systemd you get a deployment pipeline that is both transparent to developers and robust enough for production traffic. This approach also reduces the “it works on my machine” syndrome, because every environment follows the same recipe.

In practice, the Rails way means you spend less time debugging server quirks and more time delivering features. It also makes onboarding new team members easier—there’s a single, documented path from code commit to live site. Below we’ll break down each step, explain the reasoning behind it, and provide ready‑to‑run snippets.

Preparing the Server Environment

The first step is to provision a clean Linux box (Ubuntu 22.04 LTS is a solid choice). You’ll need a non‑root user with sudo privileges, a dedicated system user for the Rails app, and a few essential packages: Git, Node.js (for the asset pipeline), Yarn, and a Ruby version manager like rbenv or rvm.

# Create a deploy user
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG sudo deploy

# Install dependencies
sudo apt-get update && sudo apt-get install -y \
    git curl gnupg2 build-essential libssl-dev libreadline-dev \
    zlib1g-dev libsqlite3-dev nodejs yarn

# Install rbenv for Ruby version management
su - deploy -c '
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
echo \'export PATH="$HOME/.rbenv/bin:$PATH"\' >> ~/.bashrc
echo \'eval "$(rbenv init -)"\' >> ~/.bashrc
source ~/.bashrc
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
~/.rbenv/bin/rbenv install 3.2.2
~/.rbenv/bin/rbenv global 3.2.2
gem install bundler
'

Notice how we isolate the Ruby toolchain per user. This prevents clashes when you host multiple Rails apps on the same server. After installing Ruby, verify the versions:

ruby -v   # => ruby 3.2.2p0
bundler -v # => Bundler version 2.4.0

Database Setup

Most Rails apps use PostgreSQL in production. Install it, create a dedicated role, and a database that matches your app’s name. The Rails way prefers a single, well‑named database per environment, avoiding ad‑hoc naming schemes.

sudo apt-get install -y postgresql postgresql-contrib

# Switch to the postgres user to create role and DB
sudo -u postgres psql -c "CREATE ROLE deploy LOGIN PASSWORD 'strongpassword';"
sudo -u postgres createdb -O deploy myapp_production
Pro tip: Enable password authentication only for the deploy user and keep the default peer authentication for local system accounts. This adds a layer of security without complicating CI pipelines.

Automating Deployments with Capistrano

Capistrano is the de‑facto deployment orchestrator for Rails. It uses SSH to run a series of tasks on the remote host, handling code checkout, bundle install, asset precompilation, and server restarts. The gem ships with a generator that scaffolds a config/deploy.rb file and stage‑specific files like config/deploy/production.rb.

First, add Capistrano to your Gemfile and run the installer:

# Gemfile
group :development do
  gem 'capistrano', '~> 3.16'
  gem 'capistrano-rails', '~> 1.6'
  gem 'capistrano-rbenv', '~> 2.2'
  gem 'capistrano-puma', '~> 5.0'
end

# Then bundle and install
bundle install
bundle exec cap install

The generated deploy.rb already includes sensible defaults: a shared folder for logs, pids, and sockets; a linked config/database.yml that lives outside version control; and a default branch of main. Let’s fine‑tune it for a typical production setup.

Key Capistrano Settings

# config/deploy.rb
lock "~> 3.16.0"

set :application, "myapp"
set :repo_url, "git@github.com:yourorg/myapp.git"
set :deploy_to, "/var/www/#{fetch(:application)}"

# Keep the last 5 releases
set :keep_releases, 5

# rbenv configuration
set :rbenv_type, :user
set :rbenv_ruby, '3.2.2'

# Puma settings
set :puma_threads, [4, 16]
set :puma_workers, 2
set :puma_bind, "unix://#{shared_path}/tmp/sockets/puma.sock"
set :puma_state, "#{shared_path}/tmp/pids/puma.state"
set :puma_pid, "#{shared_path}/tmp/pids/puma.pid"
set :puma_access_log, "#{shared_path}/log/puma.access.log"
set :puma_error_log, "#{shared_path}/log/puma.error.log"

Notice the explicit socket path; this is how Nginx will talk to Puma later. Capistrano will automatically create the shared/tmp/sockets directory during the first deploy.

Deploying for the First Time

Run the following command from your local machine (make sure your SSH key is added to the server’s ~deploy/.ssh/authorized_keys list):

bundle exec cap production deploy

Capistrano will clone the repo into a timestamped release folder, bundle install gems, run rails assets:precompile, and finally restart Puma. If anything fails, Capistrano rolls back to the previous release, keeping your site up.

Serving the App with Puma and Nginx

Puma is the default web server for modern Rails apps. It’s lightweight, multi‑threaded, and works great behind a reverse proxy like Nginx. The capistrano-puma gem provides tasks to start, stop, and restart Puma using a systemd service file.

Create a systemd unit for Puma that points to the shared socket. This file lives on the server, not in your repo, but you can push it via Capistrano’s upload! helper.

# /etc/systemd/system/puma_myapp.service
[Unit]
Description=Puma HTTP Server for myapp
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp/current
Environment=RAILS_ENV=production
ExecStart=/home/deploy/.rbenv/shims/bundle exec puma -C /var/www/myapp/current/config/puma.rb
Restart=always

[Install]
WantedBy=multi-user.target

After uploading, enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable puma_myapp
sudo systemctl start puma_myapp

Now configure Nginx to proxy traffic to the Puma socket. This step ensures SSL termination, static file serving, and request buffering.

# /etc/nginx/sites-available/myapp
server {
  listen 80;
  server_name myapp.example.com;

  root /var/www/myapp/current/public;
  access_log /var/www/myapp/shared/log/nginx.access.log;
  error_log /var/www/myapp/shared/log/nginx.error.log;

  location / {
    try_files $uri @puma;
  }

  location @puma {
    proxy_pass http://unix:/var/www/myapp/shared/tmp/sockets/puma.sock;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_redirect off;
  }

  location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
    expires max;
    add_header Cache-Control public;
  }
}

Enable the site and reload Nginx:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Pro tip: Use Let’s Encrypt’s Certbot to obtain a free SSL certificate and add a listen 443 ssl block. Automate renewal with certbot renew --quiet in a cron job.

Dockerizing the Rails App (Optional but Powerful)

While the classic Capistrano + Nginx + Puma stack works everywhere, many teams now prefer containerization for consistency across dev, staging, and production. Docker lets you encapsulate the entire runtime, including Ruby, Node, and system libraries, into a single image.

Below is a minimal Dockerfile that follows Rails conventions: it uses a multi‑stage build to keep the final image lean, compiles assets, and runs Puma as the entrypoint.

# Dockerfile
FROM ruby:3.2.2-alpine AS builder

# Install build dependencies
RUN apk add --no-cache build-base nodejs npm yarn postgresql-dev

WORKDIR /app

# Cache Gemfile changes
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment 'true' \
    && bundle config set --local without 'development test' \
    && bundle install

# Copy source code and compile assets
COPY . .
RUN bundle exec rails assets:precompile RAILS_ENV=production

# Production image
FROM ruby:3.2.2-alpine

RUN apk add --no-cache libpq nodejs

WORKDIR /app
COPY --from=builder /app /app

EXPOSE 3000
ENV RAILS_ENV=production
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

Build and run the container with a one‑liner. The docker-compose.yml below wires PostgreSQL and the Rails app together, mirroring the same environment variables you’d use on a bare‑metal server.

# docker-compose.yml
version: "3.9"
services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: deploy
      POSTGRES_PASSWORD: strongpassword
      POSTGRES_DB: myapp_production
    volumes:
      - db_data:/var/lib/postgresql/data

  web:
    build: .
    command: bundle exec puma -C config/puma.rb
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      - db

volumes:
  db_data:

When you push this image to a container registry (Docker Hub, GitHub Packages, or a private ECR), your CI/CD pipeline can pull it onto any host—whether it’s a DigitalOcean droplet, an AWS EC2 instance, or a Kubernetes pod. The deployment steps become a single docker pull && docker run command.

Pro tip: Add a healthcheck to the Dockerfile that hits /health (a simple controller action returning 200). Kubernetes will then automatically restart unhealthy pods.

Managing Secrets and Environment Variables

Rails 7 introduced config/credentials.yml.enc for encrypted secrets, but many production teams prefer environment variables managed by tools like dotenv, figaro, or cloud‑native secret stores (AWS Secrets Manager, GCP Secret Manager). The Rails way is to keep the secret store out of the repo and load values at runtime.

For a Capistrano deployment, you can create a .env.production file on the server and symlink it into the shared directory. Then add a before‑hook to export those variables before running any Rails tasks.

# config/deploy/production.rb
set :linked_files, fetch(:linked_files, []).push('.env.production')

namespace :deploy do
  task :export_env do
    on roles(:app) do
      within release_path do
        execute :export, "$(cat #{shared_path}/.env.production | xargs)"
      end
    end
  end

  before 'deploy:starting', 'deploy:export_env'
end

In a Docker setup, you simply reference the same .env.production file in the docker-compose.yml under env_file. This keeps your pipeline consistent across deployment strategies.

Monitoring, Logging, and Zero‑Downtime Deploys

Production Rails apps need observability. The Rails way encourages structured logs (JSON format) and a process manager that supports zero‑downtime restarts. Puma’s phased restart feature works perfectly with Capistrano’s puma:restart task, allowing new code to load while existing connections finish gracefully.

# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY") { 2 })
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS") { 5 })
threads threads_count, threads_count

preload_app!

on_worker_boot do
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

# Enable phased restart
plugin :tmp_restart

For logging, configure Rails to output JSON and forward it to a log aggregator like Loki or Papertrail. In config/environments/production.rb:

config.log_formatter = ::Logger::Formatter.new
config.log_tags = [:request_id]
config.logger = ActiveSupport::TaggedLogging.new(
  ActiveSupport::Logger.new(STDOUT, formatter: proc do |severity, datetime, progname, msg|
    { level: severity, time: datetime.iso8601, pid: Process.pid, message: msg }.to_json + "\n"
  end)
)

Now every log line is a valid JSON object, making it easy to query by request ID, severity, or custom fields.

Continuous Integration & Deployment (CI/CD)

Integrating the Rails deployment flow into a CI pipeline (GitHub Actions, GitLab CI, CircleCI) adds safety nets: linting, test suites, and automated rollbacks. A typical workflow runs the test suite on every PR, builds a Docker image on merge, pushes it to a registry, and triggers a Capistrano deploy to the production server.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: deploy
          POSTGRES_PASSWORD: password
          POSTGRES_DB: myapp_test
        ports: [5432:5432]
    steps:
      - uses: actions/checkout@v3
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with
        
Share this article