You've heard about Docker. Everyone is using it. You want to use it too.

But every time you try, you run into problems. Permissions issues. Missing gems. Assets not compiling. Containers that won't start.

Let me fix that.

Here's how to Dockerize Rails applications correctly, with working examples and explanations for every step.


The Short Answer

Docker lets you package your Rails app with all its dependencies into a single container. That container runs the same everywhere—your laptop, your server, your colleague's machine.

No more it works on my machine.

No more Ruby version mismatches.

No more missing system dependencies.


Part 1: Why Dockerize Rails?

The Problems Docker Solves

Problem Without Docker With Docker
Ruby version But I'm using 3.2.0 Container has exact version
System dependencies Install ImageMagick first Container includes it
Environment variables .env file missing Built into container config
Onboarding new devs 2 days of setup 10 minutes to pull and run

When to Use Docker

Scenario Use Docker?
Deploying to production Yes
Onboarding multiple developers Yes
Running CI/CD pipelines Yes
Local development only Optional
One-person project Optional

When NOT to Use Docker

Scenario Skip Docker
Simple deployment on Fly.io/Render Their buildpacks are easier
You're the only developer Extra complexity
Production uses Heroku Buildpacks work fine

Part 2: The Dockerfile

The Dockerfile is a recipe for building your container.

Minimal Working Dockerfile

Create Dockerfile in your Rails root:

# Dockerfile
FROM ruby:3.2.0-slim

# Install system dependencies
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    nodejs \
    yarn \
    && rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Copy Gemfiles first (for better caching)
COPY Gemfile Gemfile.lock ./
RUN bundle install

# Copy the rest of the application
COPY . .

# Precompile assets
RUN bundle exec rake assets:precompile

# Expose port
EXPOSE 3000

# Start the server
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

What Each Line Does

Line Purpose
FROM ruby:3.2.0-slim Base image with Ruby (slim = smaller)
RUN apt-get update... Install system dependencies
WORKDIR /app All commands run from /app
COPY Gemfile* ./ Copy gem files before code (caching)
RUN bundle install Install gems
COPY . . Copy all application code
RUN rake assets:precompile Compile CSS/JS
EXPOSE 3000 Document which port to use
CMD [...] Command to run when container starts

Optimized Dockerfile for Production

# Dockerfile (production optimized)
FROM ruby:3.2.0-slim AS builder

# Install build dependencies
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test

# Final stage (smaller image)
FROM ruby:3.2.0-slim

RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends \
    libpq-dev \
    nodejs \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy gems from builder stage
COPY --from=builder /usr/local/bundle /usr/local/bundle

# Copy application code
COPY . .

# Precompile assets
RUN SECRET_KEY_BASE=placeholder bundle exec rake assets:precompile

# Create non-root user
RUN useradd --create-home railsuser && \
    chown -R railsuser:railsuser /app
USER railsuser

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

Why Two Stages?

Stage Purpose Size
Builder Install gems, compile assets Large (has build tools)
Final Run the app Small (only runtime)

Result: Smaller image, faster deploys.


Part 3: The .dockerignore File

Prevent unnecessary files from being copied into your container.

# .dockerignore
/tmp
/log
/storage
/public/assets
/node_modules
/vendor/bundle
.git
.gitignore
.env
.env.*
.DS_Store
docker-compose.yml
config/master.key
config/credentials.yml.enc

What to Ignore

Pattern Why Ignore
/tmp, /log Runtime files, not needed for build
/storage User uploads, use S3 instead
/node_modules Reinstalled during build
/vendor/bundle Reinstalled during build
.git Not needed in container
.env Use environment variables instead
config/master.key Secret, use Docker secrets

Part 4: docker-compose.yml for Development

docker-compose lets you run multiple containers together (Rails + PostgreSQL + Redis).

Development docker-compose.yml

# docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:15
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_USER: railsuser
      POSTGRES_DB: myapp_development
    ports:
      - "5432:5432"

  redis:
    image: redis:7
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails server -b 0.0.0.0 -p 3000"
    volumes:
      - .:/app
      - bundle_data:/usr/local/bundle
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://railsuser:password@db:5432/myapp_development
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - db
      - redis

volumes:
  postgres_data:
  redis_data:
  bundle_data:

What Each Service Does

Service Purpose
db PostgreSQL database
redis Redis for caching/jobs
web Your Rails app

Running with docker-compose

# Build and start all services
docker-compose up -d

# Run migrations
docker-compose exec web rails db:create db:migrate

# View logs
docker-compose logs -f web

# Stop all services
docker-compose down

# Stop and remove volumes (resets database)
docker-compose down -v

Part 5: Production docker-compose.yml

# docker-compose.prod.yml
version: '3.8'

services:
  db:
    image: postgres:15
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_DB: ${DB_NAME}
    restart: always

  redis:
    image: redis:7
    volumes:
      - redis_data:/data
    restart: always

  web:
    build: .
    command: bundle exec puma -C config/puma.rb
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      RAILS_MASTER_KEY: ${RAILS_MASTER_KEY}
    depends_on:
      - db
      - redis
    restart: always

  worker:
    build: .
    command: bundle exec rake solid_queue:start
    environment:
      DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379/0
    depends_on:
      - db
      - redis
    restart: always

volumes:
  postgres_data:
  redis_data:

Running Production

# Create .env file with secrets
echo "SECRET_KEY_BASE=$(rails secret)" >> .env
echo "DB_PASSWORD=secure_password" >> .env

# Build and start
docker-compose -f docker-compose.prod.yml up -d

# Run migrations
docker-compose -f docker-compose.prod.yml exec web rails db:migrate

# View logs
docker-compose -f docker-compose.prod.yml logs -f

Part 6: Database Configuration for Docker

Your config/database.yml needs to work both locally and in Docker.

# config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: <%= ENV.fetch("DB_USER") { "railsuser" } %>
  password: <%= ENV.fetch("DB_PASSWORD") { "" } %>
  host: <%= ENV.fetch("DB_HOST") { "localhost" } %>

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  url: <%= ENV["DATABASE_URL"] %>

Why This Works

Environment DB_HOST Comes From
Local development localhost Default
Docker development db docker-compose.yml
Production db docker-compose.prod.yml

Part 7: Common Docker Problems and Solutions

Problem 1: Permission Denied on /app

Error:

Permission denied @ rb_file_s_rename - (/app/tmp/cache, /app/tmp/cache)

Solution:

# In Dockerfile
RUN useradd --create-home railsuser
RUN chown -R railsuser:railsuser /app
USER railsuser

Problem 2: Gem Installation Fails

Error:

Gem::Ext::BuildError: ERROR: Failed to build gem native extension

Solution:

# Install build dependencies
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libssl-dev \
    libreadline-dev \
    zlib1g-dev

Problem 3: Assets Not Loading

Error:

ActionController::RoutingError (No route matches [GET] "/assets/application.css")

Solution:

# config/environments/production.rb
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
# Set environment variable
ENV RAILS_SERVE_STATIC_FILES=true

Problem 4: Database Connection Refused

Error:

PG::ConnectionBad: could not connect to server: Connection refused

Solution: Wait for database to be ready:

# Add script to wait for database
COPY bin/docker-entrypoint /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint
ENTRYPOINT ["docker-entrypoint"]
# bin/docker-entrypoint
#!/bin/bash
set -e

# Wait for database
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
  >&2 echo "Postgres is unavailable - sleeping"
  sleep 1
done

>&2 echo "Postgres is up - executing command"
exec "$@"

Problem 5: Slow Build Times

Problem: Every build reinstalls all gems.

Solution: Copy Gemfile before code:

# Bad
COPY . .
RUN bundle install

# Good
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .

Problem 6: Large Image Size

Problem: Image is 1GB+.

Solution:

# Use slim image
FROM ruby:3.2.0-slim  # instead of ruby:3.2.0

# Clean up after apt
RUN apt-get update && \
    apt-get install -y --no-install-recommends ... && \
    rm -rf /var/lib/apt/lists/*

# Multi-stage build (as shown earlier)

Part 8: Docker Commands Cheat Sheet

Building

# Build image
docker build -t myapp .

# Build with specific tag
docker build -t myapp:latest .

# Build without cache
docker build --no-cache -t myapp .

Running

# Run container
docker run -p 3000:3000 myapp

# Run with environment variables
docker run -p 3000:3000 -e SECRET_KEY_BASE=secret myapp

# Run in detached mode
docker run -d -p 3000:3000 myapp

# Run with volume mount (for development)
docker run -p 3000:3000 -v $(pwd):/app myapp

Managing

# List running containers
docker ps

# List all containers (including stopped)
docker ps -a

# List images
docker images

# Stop container
docker stop <container_id>

# Remove container
docker rm <container_id>

# Remove image
docker rmi myapp

# Remove unused images/containers
docker system prune -a

Debugging

# View logs
docker logs <container_id>
docker logs -f <container_id>

# Open shell in container
docker exec -it <container_id> bash

# Run rails console
docker exec -it <container_id> rails console

# Run migrations
docker exec -it <container_id> rails db:migrate

Part 9: Deploying Dockerized Rails

Deploy to Fly.io

Fly.io automatically detects Dockerfiles:

fly launch
fly deploy

Deploy to Render

Render also detects Dockerfiles:

  1. Create new Web Service
  2. Select Docker as environment
  3. Connect repository
  4. Deploy

Deploy to VPS with Kamal

Kamal uses Docker under the hood:

kamal init
kamal server bootstrap
kamal deploy

Deploy to AWS ECS

More complex, but works:

# Build and push to ECR
aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_URL
docker build -t myapp .
docker tag myapp:latest $ECR_URL/myapp:latest
docker push $ECR_URL/myapp:latest

# Then use ECS console or Terraform to deploy

Part 10: Real-World Example

Here's a complete, production-ready setup.

Project Structure

myapp/
├── Dockerfile
├── .dockerignore
├── docker-compose.yml
├── docker-compose.prod.yml
├── bin/
│   └── docker-entrypoint
└── .github/
    └── workflows/
        └── deploy.yml

CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Push to registry
        run: |
          echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
          docker tag myapp:${{ github.sha }} ${{ secrets.DOCKER_REGISTRY }}/myapp:latest
          docker push ${{ secrets.DOCKER_REGISTRY }}/myapp:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_REGISTRY }}/myapp:latest
            docker-compose -f /app/docker-compose.prod.yml down
            docker-compose -f /app/docker-compose.prod.yml up -d

The Docker Checklist

Before Dockerizing

  • [ ] Rails app runs locally with PostgreSQL
  • [ ] All secrets are in environment variables
  • [ ] No hardcoded paths or URLs

Dockerfile

  • [ ] Uses slim Ruby image
  • [ ] Copies Gemfile before application code
  • [ ] Cleans up apt packages
  • [ ] Creates non-root user
  • [ ] Has proper .dockerignore

docker-compose

  • [ ] Development compose works locally
  • [ ] Production compose uses environment variables
  • [ ] Database volumes are named

Deployment

  • [ ] Database migrations run automatically
  • [ ] Assets precompile during build
  • [ ] Health check endpoint exists
  • [ ] Logs go to stdout

Summary

Dockerizing Rails is not magic. It's a series of deliberate choices.

Component Purpose
Dockerfile Recipe for building the container
.dockerignore Prevents unnecessary files
docker-compose.yml Orchestrates multiple services
database.yml Works in both Docker and local

Quick Start Commands

# Create Dockerfile (copy from above)
# Create .dockerignore (copy from above)
# Create docker-compose.yml (copy from above)

# Build and run
docker-compose up -d

# Setup database
docker-compose exec web rails db:create db:migrate

# View logs
docker-compose logs -f web

# Stop
docker-compose down

Once you have Docker working, every developer has the exact same environment. Every deployment is identical. No more surprises.

That's the power of Docker.