Deploying Ruby on Rails with Docker: Multi-stage Builds, Asset Precompile, and Sidekiq Workers in Production

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Real-world Problems After 6 Months Running Rails on Docker

I containerized a Rails app for the first time at the start of last year. I thought it’d be simple — pull the ruby image, copy the code in, run bundle install, and call it a day. It did work, but running it that way meant a 1.8GB image, 15-minute CI push times, and manual production deploys because pulling the image took forever.

Three specific problems our team ran into:

  • Unnecessarily bloated image: dev dependencies, Node.js, yarn, and build tools all ended up in the production image
  • Constant asset precompile failures: Rails 7 + Sprockets needs Node for certain gems, but container setups often broke due to path errors or missing environment variables
  • Sidekiq dying alongside the web process: cramming Sidekiq into the Procfile and running it in the same container as Puma meant that when the web container crashed, any in-flight jobs vanished with it

Root Cause Analysis

The bloated image problem comes from the traditional single-stage build — everything gets packed into a single layer:

# Wrong approach — single stage, everything ends up in production
FROM ruby:3.3
RUN apt-get install -y nodejs npm yarn build-essential
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
RUN bundle exec rails assets:precompile
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

An image like this drags Node.js, npm, the compiler, and header files into production — everything you only need at build time. Rails in production doesn’t need Node to run; it just needs the pre-compiled assets.

Running Sidekiq alongside Puma is a clear anti-pattern: they’re two completely different workloads with very different resource profiles. The web process needs high concurrency and fast response times; Sidekiq needs stable memory and long-running job processing. Bundle them together and you can’t scale them independently — and when one crashes, it takes the other down with it.

Solutions

Option 1: Precompile Assets Outside the Container and COPY Them In

The simplest approach is to precompile on the CI/CD machine and copy the output into the image:

yarn build && bundle exec rails assets:precompile
docker build -t myapp:latest .

This reduces image size but requires the CI environment to have all the right tools installed. It’s not portable and hard to reproduce — I tried it for two weeks and then abandoned it.

Option 2: Multi-stage Build (The Right Approach)

Use a builder stage to install tools and compile, then have the production stage copy only what’s actually needed. This is the Docker-recommended approach and what our team currently uses:

# Stage 1: Builder — install dependencies and compile
FROM ruby:3.3-slim AS builder

RUN apt-get update -qq && apt-get install -y \
    build-essential \
    libpq-dev \
    curl \
    git

# Install Node.js only in the builder stage
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /app

# Copy Gemfile first to leverage Docker layer cache
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' && \
    bundle install --jobs 4 --retry 3

COPY . .
RUN SECRET_KEY_BASE=dummy \
    RAILS_ENV=production \
    bundle exec rails assets:precompile

# Stage 2: Production image — lean, no build tools
FROM ruby:3.3-slim AS production

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

WORKDIR /app

# Only copy gems and compiled assets from the builder
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app/public/assets /app/public/assets
COPY --from=builder /app/public/packs /app/public/packs
COPY . .

ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true

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

Important note: SECRET_KEY_BASE=dummy is required when precompiling in CI/CD. Some gems like devise validate this key at Rails boot time — leave it out and you’ll get an immediate error. It took me quite a while to track down the root cause.

The Best Approach: A Docker Compose Stack with Web and Sidekiq Fully Separated

Once you have a solid Dockerfile, the next step is configuring Docker Compose so Sidekiq runs as an independent service — sharing the same image but with an overridden command:

# docker-compose.yml
version: '3.9'

services:
  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp_production
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  web:
    build:
      context: .
      target: production
    image: myapp:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://myapp:${DB_PASSWORD}@db:5432/myapp_production
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      RAILS_ENV: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: bundle exec puma -C config/puma.rb

  sidekiq:
    image: myapp:latest     # Reuse the already-built image, no rebuild needed
    environment:
      DATABASE_URL: postgres://myapp:${DB_PASSWORD}@db:5432/myapp_production
      REDIS_URL: redis://redis:6379/0
      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
      RAILS_ENV: production
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    command: bundle exec sidekiq -C config/sidekiq.yml
    deploy:
      resources:
        limits:
          memory: 512M

volumes:
  postgres_data:
  redis_data:

The key point: the sidekiq service uses image: myapp:latest instead of build: — no rebuilding from scratch, just a different command. This guarantees both web and Sidekiq run on the exact same code version, and they can scale independently:

# Scale Sidekiq to 3 workers without touching the web service
docker compose up -d --scale sidekiq=3 --no-deps sidekiq

Configuring Sidekiq and Queue Priority

# config/sidekiq.yml
:concurrency: 5
:queues:
  - [critical, 3]
  - [default, 2]
  - [low, 1]
:max_retries: 3

Database Migration — Don’t Run It in CMD

Migrations should not run automatically when the container starts. If you scale to multiple web containers, migrations will run in parallel and conflict. The correct approach is to run them as a separate step in your deploy script:

# deploy.sh
docker compose pull
docker compose run --rm web bundle exec rails db:migrate
docker compose up -d --no-deps web sidekiq

Debugging in Practice

When your stack has multiple services, Rails API responses often return deeply nested JSON that’s hard to read. I usually paste it into the JSON Formatter at toolcraft.app — it’s faster than installing an extension, especially when debugging on a VPS without a GUI.

To check if Sidekiq is connected to Redis:

docker compose exec redis redis-cli info | grep connected_clients
docker compose exec web bundle exec rails runner "puts Sidekiq::Stats.new.to_json"

Results After 6 Months in Production

Here’s a before-and-after comparison after refactoring the entire setup:

  • Image size: 1.8GB → 380MB (79% reduction)
  • CI push time: 15 minutes → 4 minutes
  • Sidekiq scales and restarts completely independently from the web service
  • Background jobs are no longer lost when the web container crashes or redeploys

The most commonly overlooked part is the healthcheck for db and redis. Without it, the web and sidekiq containers will start before the database is ready, crash on first boot, and Docker will keep restart-looping indefinitely. The first time I ran into this, it took me about two hours to debug.

Share: