Skip to main content
Technology & EngineeringRuby Rails433 lines

Deployment

Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.

Quick Summary28 lines
You are an expert in deploying Ruby on Rails applications using Kamal (formerly MRSK), Docker, and modern production infrastructure patterns.

## Key Points

- Use multi-stage Docker builds to keep the final image small (no build tools, no dev gems).
- Enable `jemalloc` to reduce Ruby memory fragmentation in long-running processes.
- Enable YJIT (`RUBY_YJIT_ENABLE=1`) on Ruby 3.2+ for significant performance gains.
- Use Kamal accessories for databases and Redis rather than managed services when cost is a concern, but prefer managed services for critical production workloads.
- Store secrets in `.kamal/secrets` or a secrets manager, never in `deploy.yml`.
- Set up a health check endpoint that verifies database and Redis connectivity.
- Use `db:prepare` in the entrypoint rather than `db:migrate` -- it handles both creation and migration.
- Configure rolling deploys with health checks to achieve zero-downtime deployments.
- Set memory limits on containers to prevent runaway processes from consuming all server memory.
- **Missing health check**: Without a health check, Kamal cannot verify the new container is ready before switching traffic.
- **Running migrations in parallel**: When deploying to multiple servers, migrations can race. Use Kamal's `primary_role` to run migrations on one host.
- **Large Docker images**: Not using multi-stage builds or leaving build dependencies in the final image.

## Quick Example

```bash
# Deploy to staging
kamal deploy -d staging

# Deploy to production
kamal deploy
```
skilldb get ruby-rails-skills/DeploymentFull skill: 433 lines
Paste into your CLAUDE.md or agent config

Kamal and Docker Deployment — Ruby on Rails

You are an expert in deploying Ruby on Rails applications using Kamal (formerly MRSK), Docker, and modern production infrastructure patterns.

Overview

Kamal is the official Rails deployment tool that uses Docker to deploy applications to any server (bare metal, VPS, or cloud VMs) without Kubernetes. It manages zero-downtime deploys, rolling restarts, and multi-server orchestration via SSH. Combined with Docker for containerization, it provides a straightforward path from development to production.

Core Philosophy

Deployment should be boring, repeatable, and reversible. The best deployment pipeline is one where pushing to production is a non-event — a single command that builds, ships, and verifies your application with zero manual intervention. Every deployment should be identical regardless of who runs it, eliminating the "works on my machine" class of problems entirely.

Docker provides the reproducibility guarantee: the same image that passes CI is the same image that runs in production. Kamal bridges the gap between Docker's containerization and traditional server management, giving you the simplicity of cap deploy with the consistency of containers. The key insight is that you do not need Kubernetes to get reliable, zero-downtime deployments — you need a health check, a reverse proxy, and a tool that knows how to swap containers safely.

Production readiness goes beyond deploying code. It encompasses secrets management, database backups, log aggregation, SSL termination, and monitoring. Each of these is a failure mode waiting to happen if neglected. The time to set them up is before your first production deploy, not after your first incident.

Anti-Patterns

  • Snowflake Servers: Manually configuring production servers with ad-hoc package installs, custom configs, and undocumented tweaks. When this server dies, you cannot reproduce it. Containerize everything and treat servers as disposable.

  • Secrets in Source Control: Committing .env files, master.key, database passwords, or API tokens to the repository. Use Kamal's secrets management, environment variables from a secrets manager, or encrypted credentials — never plaintext in version control.

  • Deploy and Pray: Deploying without a health check endpoint, then checking the app manually in a browser to see if it works. Automated health checks that verify database connectivity, Redis availability, and application boot are non-negotiable.

  • Migration Races: Running database migrations simultaneously from multiple deployment targets. Migrations must run on a single node before the new code is rolled out to all servers. Use Kamal's primary_role or a deploy hook to serialize migrations.

  • No Rollback Plan: Deploying without knowing how to quickly revert to the previous version. Test your rollback procedure before you need it in an emergency. kamal rollback should be a practiced operation, not a theoretical one.

Core Concepts

Dockerfile for Rails

# Dockerfile
ARG RUBY_VERSION=3.3.0
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    curl libjemalloc2 libvips postgresql-client && \
    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:test"

# Build stage
FROM base AS build

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    build-essential git libpq-dev node-gyp pkg-config python-is-python3 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install 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

# Install JavaScript dependencies (if using Node)
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot
RUN bundle exec bootsnap precompile app/ lib/

# Precompile assets
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage
FROM base

# Copy built artifacts
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run as non-root user
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

# Use jemalloc for better memory performance
ENV LD_PRELOAD="libjemalloc.so.2" \
    MALLOC_CONF="dirty_decay_ms:1000,narenas:2,background_thread:true"

ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 3000
CMD ["./bin/rails", "server"]

Docker Entrypoint

#!/bin/bash -e
# bin/docker-entrypoint

# If running the rails server then create or migrate existing database
if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

Kamal Configuration

# config/deploy.yml
service: myapp
image: myregistry/myapp

servers:
  web:
    hosts:
      - 192.168.1.10
      - 192.168.1.11
    labels:
      traefik.http.routers.myapp.rule: Host(`myapp.com`)
      traefik.http.routers.myapp.tls: true
      traefik.http.routers.myapp.tls.certresolver: letsencrypt
    options:
      memory: 1g

  job:
    hosts:
      - 192.168.1.12
    cmd: bundle exec sidekiq -q critical,6 -q default,3 -q low,1
    options:
      memory: 2g

registry:
  server: ghcr.io
  username: myuser
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    RAILS_LOG_TO_STDOUT: "1"
    RAILS_SERVE_STATIC_FILES: "true"
    RUBY_YJIT_ENABLE: "1"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - REDIS_URL

traefik:
  options:
    publish:
      - "443:443"
    volume:
      - "/letsencrypt:/letsencrypt"
  args:
    entryPoints.web.address: ":80"
    entryPoints.websecure.address: ":443"
    certificatesResolvers.letsencrypt.acme.email: admin@myapp.com
    certificatesResolvers.letsencrypt.acme.storage: /letsencrypt/acme.json
    certificatesResolvers.letsencrypt.acme.httpChallenge.entryPoint: web
    entryPoints.web.http.redirections.entryPoint.to: websecure
    entryPoints.web.http.redirections.entryPoint.scheme: https

healthcheck:
  path: /up
  port: 3000
  max_attempts: 10
  interval: 5s

accessories:
  db:
    image: postgres:16
    host: 192.168.1.20
    port: "5432:5432"
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
    options:
      memory: 2g

  redis:
    image: redis:7
    host: 192.168.1.20
    port: "6379:6379"
    directories:
      - data:/data
    cmd: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru

Kamal Environment Secrets

# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
DATABASE_URL=postgresql://myapp:$DB_PASSWORD@192.168.1.20:5432/myapp_production
REDIS_URL=redis://192.168.1.20:6379/0
POSTGRES_PASSWORD=$DB_PASSWORD

Implementation Patterns

Kamal Deploy Commands

# Initial setup (provisions Traefik, accessories, and app)
kamal setup

# Standard deploy
kamal deploy

# Deploy with skip of asset compilation
kamal deploy --skip-push

# Roll back to previous version
kamal rollback

# View application logs
kamal app logs -f

# Run a Rails console on a web server
kamal app exec --interactive "bin/rails console"

# Run a one-off task
kamal app exec "bin/rails db:seed"

# Check deploy status
kamal details

# Manage accessories
kamal accessory boot db
kamal accessory reboot redis

Health Check Endpoint

# config/routes.rb
Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check
end

# Or a custom health check:
# app/controllers/health_controller.rb
class HealthController < ActionController::API
  def show
    checks = {
      database: database_connected?,
      redis: redis_connected?,
      migrations: migrations_current?
    }

    status = checks.values.all? ? :ok : :service_unavailable
    render json: { status: status == :ok ? "ok" : "degraded", checks: checks }, status: status
  end

  private

  def database_connected?
    ActiveRecord::Base.connection.execute("SELECT 1")
    true
  rescue StandardError
    false
  end

  def redis_connected?
    Redis.current.ping == "PONG"
  rescue StandardError
    false
  end

  def migrations_current?
    !ActiveRecord::Base.connection.migration_context.needs_migration?
  rescue StandardError
    false
  end
end

Production Rails Configuration

# config/environments/production.rb
Rails.application.configure do
  config.cache_classes = true
  config.eager_load = true
  config.consider_all_requests_local = false
  config.action_controller.perform_caching = true

  # Asset serving
  config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?

  # Logging
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info").to_sym
  config.log_tags = [:request_id]

  if ENV["RAILS_LOG_TO_STDOUT"].present?
    config.logger = ActiveSupport::Logger.new($stdout)
      .tap { |logger| logger.formatter = Logger::Formatter.new }
      .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
  end

  # Cache store
  config.cache_store = :redis_cache_store, {
    url: ENV["REDIS_URL"],
    expires_in: 1.hour,
    error_handler: ->(method:, returning:, exception:) {
      Rails.logger.warn("Redis cache error: #{exception.message}")
      Sentry.capture_exception(exception) if defined?(Sentry)
    }
  }

  # Action Cable
  config.action_cable.allowed_request_origins = [
    "https://myapp.com", "https://www.myapp.com"
  ]

  # Force SSL
  config.force_ssl = true
  config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Active Storage
  config.active_storage.service = :amazon

  # Background jobs
  config.active_job.queue_adapter = :sidekiq
end

Multi-Stage Deployment (Staging + Production)

# config/deploy.staging.yml
service: myapp-staging
image: myregistry/myapp

servers:
  web:
    hosts:
      - 192.168.2.10
    labels:
      traefik.http.routers.myapp-staging.rule: Host(`staging.myapp.com`)

env:
  clear:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: "1"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - REDIS_URL
# Deploy to staging
kamal deploy -d staging

# Deploy to production
kamal deploy

Database Backup Script

#!/bin/bash
# bin/backup_db.sh
set -euo pipefail

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="myapp_production_${TIMESTAMP}.sql.gz"
S3_BUCKET="myapp-backups"

# Dump and compress
kamal accessory exec db \
  "pg_dump -U myapp myapp_production | gzip" > "/tmp/${BACKUP_FILE}"

# Upload to S3
aws s3 cp "/tmp/${BACKUP_FILE}" "s3://${S3_BUCKET}/db/${BACKUP_FILE}"

# Clean up local file
rm "/tmp/${BACKUP_FILE}"

# Remove backups older than 30 days from S3
aws s3 ls "s3://${S3_BUCKET}/db/" | \
  awk '{print $4}' | \
  while read -r file; do
    file_date=$(echo "$file" | grep -oP '\d{8}')
    if [[ $(date -d "$file_date" +%s) -lt $(date -d "30 days ago" +%s) ]]; then
      aws s3 rm "s3://${S3_BUCKET}/db/${file}"
    fi
  done

echo "Backup completed: ${BACKUP_FILE}"

Best Practices

  • Use multi-stage Docker builds to keep the final image small (no build tools, no dev gems).
  • Enable jemalloc to reduce Ruby memory fragmentation in long-running processes.
  • Enable YJIT (RUBY_YJIT_ENABLE=1) on Ruby 3.2+ for significant performance gains.
  • Use Kamal accessories for databases and Redis rather than managed services when cost is a concern, but prefer managed services for critical production workloads.
  • Store secrets in .kamal/secrets or a secrets manager, never in deploy.yml.
  • Set up a health check endpoint that verifies database and Redis connectivity.
  • Use db:prepare in the entrypoint rather than db:migrate -- it handles both creation and migration.
  • Configure rolling deploys with health checks to achieve zero-downtime deployments.
  • Set memory limits on containers to prevent runaway processes from consuming all server memory.

Common Pitfalls

  • Missing health check: Without a health check, Kamal cannot verify the new container is ready before switching traffic.
  • Running migrations in parallel: When deploying to multiple servers, migrations can race. Use Kamal's primary_role to run migrations on one host.
  • Large Docker images: Not using multi-stage builds or leaving build dependencies in the final image.
  • Forgetting RAILS_MASTER_KEY: The app will fail to boot if credentials cannot be decrypted. Ensure the key is in your secrets.
  • No database backups: Always set up automated backups before deploying to production.
  • Ignoring log aggregation: Container logs are ephemeral. Ship logs to an external service (Datadog, Papertrail, or ELK stack).
  • Not setting force_ssl: All production Rails apps should enforce HTTPS. Exclude only the health check endpoint.
  • Hardcoding server IPs: Use DNS or a load balancer in front of your servers for easier scaling and failover.

Install this skill directly: skilldb add ruby-rails-skills

Get CLI access →