Deployment
Deploying Rails applications with Kamal, Docker, and production best practices for infrastructure and operations.
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 linesKamal 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
.envfiles,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_roleor 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 rollbackshould 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
jemallocto 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/secretsor a secrets manager, never indeploy.yml. - Set up a health check endpoint that verifies database and Redis connectivity.
- Use
db:preparein the entrypoint rather thandb: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_roleto 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
Related Skills
Active Record
ActiveRecord query patterns, associations, validations, callbacks, and performance optimization for Rails applications.
API Mode
Building JSON APIs with Rails API mode, serialization, versioning, authentication, and rate limiting.
Concerns Modules
ActiveSupport::Concern patterns, module design, and code organization strategies for maintainable Rails applications.
Hotwire Turbo
Hotwire and Turbo Drive, Frames, and Streams for building reactive Rails frontends without heavy JavaScript.
Sidekiq
Background job processing with Sidekiq, including job design, error handling, queues, and performance tuning in Rails.
Stimulus
Stimulus.js controller patterns for adding interactive behavior to server-rendered Rails HTML.