Rails Containerization Best Practices

Introduction

Ship small nonroot images, scan for CVEs, and let Kamal gate traffic. Rails 8.1 gives us that path by default.

  • Pin Ruby and keep it synced with .ruby-version
  • Use multistage builds
  • Precompile gems, app code, and assets during build
  • Run production containers as a nonroot user
  • Fail CI on high and critical CVEs

While our previous guide covered Rails 8 adds Kamal by default, this post focuses on containerization best practices, security hardening, and production optimization techniques.

The examples below use Rails 8.1, Ruby 3.4.9, and Kamal 2.11.0.

Understanding Rails Docker Setup

When we create a new Rails 8.1 application, it generates several Docker related files:

  • Dockerfile - Production ready container image
  • .dockerignore - Files to exclude from builds
  • bin/docker-entrypoint - Container startup script

These files follow industry best practices and provide a solid foundation for production deployments.

The Rails Dockerfile Explained

Rails generates an optimized multistage Dockerfile.

Let’s understand its key components:

# syntax=docker/dockerfile:1
# check=error=true

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

WORKDIR /rails

RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    curl libjemalloc2 libvips libpq-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Build stage
FROM base AS build
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y \
    build-essential curl git pkg-config libyaml-dev && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

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 . .
RUN bundle exec bootsnap precompile app/ lib/
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage
FROM base
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

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 ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]

Rules - enforce these or accept risk:

  • Lock Ruby: ARG RUBY_VERSION must match .ruby-version. No :latest.
  • Keep build dependencies out of runtime. If a compiler lands in the final image, fix it.
  • Precompile gems, code, and assets during build. Runtime compilation means debugging later.
  • Install runtime packages only when required. Smaller image, smaller attack surface.

Why Multistage Builds Matter

Multistage builds separate the build environment from runtime:

  • Build stage: Includes compilers and build tools
  • Final stage: Contains only runtime dependencies

This approach reduces the final image size by 50-70% and improves security by excluding unnecessary tools.

Deployment Best Practices

Quick Deployment with Kamal

Deploy your containerized app with:

kamal deploy

This command:

  • Builds the Docker image
  • Pushes to your registry
  • Deploys with zero downtime
  • Runs health checks automatically

For Kamal configuration details, see our Kamal introduction guide.

Security Best Practices

Run as Nonroot User

Rails’ Dockerfile runs as a nonroot user by default:

RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000

Rule: never change the production Dockerfile to run as root. If we need root for debugging, use a one-off command with an explicit user instead.

Use Specific Image Tags

Rule: use specific versions.

ARG RUBY_VERSION=3.4.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim  # Good
FROM ruby:latest                                # Bad

Keep RUBY_VERSION aligned with the application’s .ruby-version.

Scan for Vulnerabilities

Regularly scan images:

docker scout quickview myapp:latest
docker scout cves myapp:latest

In CI, fail on high and critical vulnerabilities:

docker scout cves --exit-code --only-severity critical,high myapp:latest

Block the merge until the image is fixed or a risk exception is documented.

Environment Variables

Secrets live in Kamal env or a vault. No exceptions in the repo.

env:
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
  clear:
    RAILS_LOG_TO_STDOUT: true

Database Management

Running Migrations Safely

Always run migrations before deploying new code:

kamal app exec 'bin/rails db:migrate'

For automated migrations, use deployment hooks to run them before traffic switches to new containers.

Database Connection Pooling

Configure Puma’s database connections:

# config/puma.rb
on_worker_boot do
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

This ensures each Puma worker has its own database connection.

Health Checks

Configure health checks for zero downtime deployments:

# config/deploy.yml
healthcheck:
  path: /up
  interval: 10s
  timeout: 5s
  max_attempts: 7

Rails 7.1+ includes a /up endpoint by default.

For custom health checks:

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def show
    # Check database connectivity
    ActiveRecord::Base.connection.execute("SELECT 1")

    # Check Redis connectivity
    Redis.new.ping

    render json: { status: 'ok' }, status: :ok
  rescue => e
    render json: { status: 'error', message: e.message }, status: :service_unavailable
  end
end

Production Optimization

Puma Configuration

Optimize Puma for containerized environments:

# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count

workers ENV.fetch("WEB_CONCURRENCY") { 2 }

preload_app!

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

Set via environment variables:

env:
  clear:
    WEB_CONCURRENCY: 2
    RAILS_MAX_THREADS: 5

Memory and CPU Limits

Set resource limits to prevent container overconsumption:

servers:
  web:
    hosts:
      - 192.168.1.10
    options:
      memory: 512m
      cpus: 1.0

Monitor with:

kamal app exec 'ps aux --sort=-%mem | head -10'

Asset Optimization

Ensure assets are precompiled during build:

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

For CDN delivery, configure Active Storage:

# config/environments/production.rb
config.active_storage.service = :amazon
config.asset_host = 'https://cdn.example.com'

Scaling Strategies

Horizontal Scaling

Add more servers to handle increased traffic:

servers:
  web:
    - 192.168.1.10
    - 192.168.1.11
    - 192.168.1.12

Traffic is automatically load balanced across all instances.

Vertical Scaling

Increase container resources:

servers:
  web:
    options:
      memory: 1g
      cpus: 2.0

Background Jobs

Separate web and job processing:

servers:
  web:
    hosts:
      - 192.168.1.10

  job:
    hosts:
      - 192.168.1.20
    cmd: bundle exec sidekiq -C config/sidekiq.yml

This isolates resource intensive background jobs from web request handling.

Monitoring and Debugging

Kamal Commands

# View logs
kamal app logs --follow

# Check status
kamal app details

# Run Rails console
kamal app exec --interactive 'bin/rails console'

# Check resource usage
kamal app exec 'top -bn1'

Application Monitoring

Integrate APM tools via environment variables:

env:
  secret:
    - NEW_RELIC_LICENSE_KEY
    - SENTRY_DSN

CI/CD Integration

GitHub Actions

name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.4.9
          bundler-cache: true

      - name: Deploy
        run: |
          gem install kamal -v 2.11.0
          echo "$" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          kamal deploy

Common Pitfalls

Large Image Sizes

Use the generated .dockerignore:

.git
log/*
tmp/*
node_modules
coverage

Hardcoded Secrets

Always use environment variables, never commit secrets to the repository.

Skipping Health Checks

Always configure health checks for zero downtime deployments.

Not Testing Locally

Test before deploying:

docker build -t myapp:test .
docker run -p 3000:80 myapp:test

Pre-merge Checklist

These should pass before merging:

  • RUBY_VERSION matches .ruby-version
  • Final image does not include build tools
  • Container runs as uid/gid 1000
  • Assets are precompiled during build
  • Health check passes locally
  • CI blocks high and critical CVEs, except documented risk exceptions
  • Migrations run through a deploy hook or release task, never at container boot

Conclusion

Rails’ built in Docker support provides a production ready foundation for containerized applications.

By following these best practices, we can build secure, performant, and scalable Rails applications that deploy confidently to any infrastructure.

Key takeaways:

  • Use Rails’ optimized Dockerfile
  • Implement proper security measures (nonroot user, specific tags)
  • Scan images with Docker Scout
  • Configure health checks for zero downtime
  • Optimize Puma and resource limits for production
  • Scale horizontally by adding servers
  • Separate concerns with role based deployments

For deployment workflows, see our Kamal introduction guide.

For more information, visit the Kamal documentation.

Need expert help with Rails security?

Saeloun is a Rails Foundation Contributing Member helping teams modernize, upgrade, scale, and maintain production Rails applications.

Our Expertise

  • Rails contributors
  • 500+ Technical Articles
  • Production Rails consulting
  • Performance Optimization

Services

  • Rails application development
  • Code Audits
  • Rails upgrades
  • Team Augmentation

Need help on your Ruby on Rails or React project?

Join Our Newsletter