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:
- Create new Web Service
- Select
Docker
as environment - Connect repository
- 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.