Ruby #

Ruby is often labeled a “heavy” language in Docker. Ruby images are synonymous with hundreds of MB, many OS packages, and slow builds. Yet Ruby can be made very slim and production-grade if treated with the same discipline as Java (distroless), Node.js (bundling), and Python (build/runtime split).

This article discusses small, secure, production-ready Ruby Docker image strategies, focusing on Rails (the most common) and general patterns that apply to all Ruby frameworks (Sinatra, Hanami, roda, etc.).

1. The Reality of Ruby Image Sizes #

SetupImage Size
ruby:latest + bundle install (everything)700-900 MB
ruby:slim without optimization400-600 MB
ruby:slim + multi-stage + cleanup180-250 MB
ruby:alpine + multi-stage120-180 MB
Distroless + custom Ruby80-130 MB

Insight: The difference between 900 MB and 80 MB is more than 10x. Slim Ruby images are very possible with disciplined Dockerfiles.

2. Why Ruby Images Bloat Easily #

Ruby Is a Runtime + Toolchain #

The Ruby interpreter itself is already fairly large (~50-100 MB). Add Bundler, RubyGems, and auto-installed tools. Without separating build from runtime, all these tools end up in the final image.

Native Gems #

Ruby has many gems with native extensions (C extensions compiled at install):

  • nokogiri — XML/HTML parser (binding to libxml2).
  • pg — PostgreSQL driver.
  • grpc — gRPC client/server.
  • mysql2 — MySQL driver.
  • rdiscount — Markdown parser.
  • rugged — Git library binding.
  • eventmachine — network library.
  • json (C implementation) — JSON parser.

Each native gem pulls compilers (gcc, make) and development libraries (*-dev). Without multi-stage, all of these enter runtime.

Rails Carries Its Own World #

Rails (full stack) pulls in many gems:

  • ActiveRecord (ORM).
  • ActionCable (WebSocket).
  • ActiveStorage (file upload).
  • ActionMailer (email).
  • ActionText (rich text).
  • ActionMailbox (incoming email).
  • Bootsnap (caching).
  • Image processing (binding to libvips).
  • Puma (web server).
  • Rack (web server interface).

Combined, these easily produce 500+ MB images if not disciplined.

3. The Main Principle: Runtime Image = Interpreter + Production Gems Only #

An ideal Ruby runtime image contains only: the Ruby interpreter + production gems + application code.

Full stop. No Bundler, no compilers, no header libraries, no gem documentation, no test frameworks.

4. Multi-Stage Build Strategies #

4.1 The Basic Pattern: Slim + Bundler #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM ruby:3.3.4-slim-bookworm AS builder

WORKDIR /app

# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libxml2-dev \
    libxslt1-dev \
    libffi-dev \
    libyaml-dev \
    libssl-dev \
    libvips-dev \
    nodejs \
    yarn \
 && rm -rf /var/lib/apt/lists/*

# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
 && bundle config set --local deployment 'true' \
 && bundle config set --local path 'vendor/bundle' \
 && bundle install --jobs 4

# Pre-compile assets and bootsnap
COPY . .
RUN SECRET_KEY_BASE=dummy \
    RAILS_ENV=production \
    bundle exec rails assets:precompile \
 && bundle exec bootsnap precompile --gemfile

# ==== Stage 2: Runtime ====
FROM ruby:3.3.4-slim-bookworm

WORKDIR /app

# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    libxml2 \
    libxslt1.1 \
    libffi8 \
    libyaml-0-2 \
    libssl3 \
    libvips42 \
    tzdata \
    curl \
 && rm -rf /var/lib/apt/lists/*

# Copy gems from the build stage
COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY --from=builder /app /app

# Non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app
RUN chown -R app:app /app
USER app

ENV RAILS_ENV=production \
    RAILS_SERVE_STATIC_FILES=true \
    RAILS_LOG_TO_STDOUT=true

EXPOSE 3000

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

Typical size: 200-300 MB (Rails with all standard extensions).

Key explanations:

Stage 1: Build

  • ruby:3.3.4-slim-bookworm — Ruby 3.3 on Debian slim. Smaller than the default ruby:3.3 (which carries many tools).
  • Install build dependencies: build-essential (gcc, make), libpq-dev (PostgreSQL headers), libxml2-dev (Nokogiri), libvips-dev (image processing), nodejs/yarn (for Rails assets).
  • bundle config set without 'development test' — skip dev/test gems during install.
  • bundle config set deployment 'true' — install per Gemfile.lock, won’t modify.
  • bundle config set path 'vendor/bundle' — install gems in a local directory, not globally.
  • bundle install --jobs 4 — parallel install to speed things up.
  • rails assets:precompile — pre-compiles CSS/JS to public/assets.
  • bootsnap precompile — pre-compiles bootsnap caches for faster startup.

Stage 2: Runtime

  • ruby:3.3.4-slim-bookworm — a slim Ruby image.
  • Install runtime libraries only (without -dev). Example: libpq5, not libpq-dev.
  • tzdata for time zone data.
  • curl (optional) for healthchecks if the image has a shell.
  • Copy vendor/bundle from the build stage.
  • Copy the whole source code.
  • Non-root user.
  • RAILS_LOG_TO_STDOUT=true — makes Rails log to STDOUT.
  • RAILS_SERVE_STATIC_FILES=true — lets Puma serve static assets (if not using a separate Nginx).

When to use: The default for production Ruby services. The size vs debugging capability trade-off is still balanced — slim has a shell.

4.2 The Alpine Version: Smaller, More Complex #

# ==== Stage 1: Build ====
FROM ruby:3.3.4-alpine3.20 AS builder

WORKDIR /app

RUN apk add --no-cache \
    build-base \
    postgresql-dev \
    libxml2-dev \
    libxslt-dev \
    libffi-dev \
    yaml-dev \
    openssl-dev \
    vips-dev \
    nodejs \
    yarn \
    tzdata

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
 && bundle config set --local deployment 'true' \
 && bundle config set --local path 'vendor/bundle' \
 && bundle install --jobs 4

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

# ==== Stage 2: Runtime ====
FROM ruby:3.3.4-alpine3.20

WORKDIR /app

RUN apk add --no-cache \
    postgresql-client \
    libxml2 \
    libxslt \
    libffi \
    yaml \
    openssl \
    vips \
    tzdata \
    curl

COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY --from=builder /app /app

RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup \
 && chown -R appuser:appgroup /app
USER appuser

ENV RAILS_ENV=production \
    RAILS_LOG_TO_STDOUT=true

EXPOSE 3000

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

Typical size: 120-180 MB.

Alpine notes for Ruby:

  • musl libc — some native gems can have issues. Test before using.
  • apk add is faster than apt-get install.
  • apk add --no-cache — doesn’t store the index cache.
  • apk del at the end for build-base cleanup (but usually build-base is only in the build stage, not needed at runtime).

4.3 The Distroless Version: Mature Production #

Ruby has no official distroless, but you can build a custom one:

# ==== Stage 1: Install Ruby ====
FROM debian:bookworm-slim AS ruby-base

RUN apt-get update && apt-get install -y --no-install-recommends \
    ruby-full \
    ruby-dev \
    build-essential \
 && rm -rf /var/lib/apt/lists/*

# Set up Ruby
RUN gem install bundler --no-document

# ==== Stage 2: Build ====
FROM ruby-base AS builder

WORKDIR /app

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

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
 && bundle config set --local deployment 'true' \
 && bundle config set --local path 'vendor/bundle' \
 && bundle install --jobs 4

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

# ==== Stage 3: Distroless Runtime ====
FROM gcr.io/distroless/base-debian12:nonroot

WORKDIR /app

# Copy Ruby + libraries from the build stage
COPY --from=ruby-base /usr/bin/ruby /usr/bin/ruby
COPY --from=ruby-base /usr/lib/ruby /usr/lib/ruby
COPY --from=ruby-base /usr/lib/x86_64-linux-gnu/libruby* /usr/lib/x86_64-linux-gnu/
COPY --from=ruby-base /usr/local/lib/ruby /usr/local/lib/ruby
COPY --from=ruby-base /usr/local/bin/bundle /usr/local/bin/bundle
COPY --from=ruby-base /usr/local/bin/bundler /usr/local/bin/bundler

# Copy runtime OS libraries
COPY --from=builder /usr/lib/x86_64-linux-gnu/libpq* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libxml2* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libxslt* /usr/lib/x86_64-linux-gnu/
COPY --from=builder /usr/lib/x86_64-linux-gnu/libvips* /usr/lib/x86_64-linux-gnu/

# Copy the app
COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY --from=builder /app /app

USER nonroot:nonroot

ENV RAILS_ENV=production \
    RAILS_LOG_TO_STDOUT=true \
    PATH="/usr/local/bin:$PATH"

EXPOSE 3000

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

Typical size: 80-130 MB.

Important notes for distroless Ruby:

  • Very advanced — you must understand Ruby’s dependencies manually.
  • OS libraries must be copied manually from the build stage.
  • No shell — interactive debugging is impossible.
  • PATH must be set explicitly.

When to use: High-maturity production with very solid observability.

5. Bundler Optimization #

5.1 Production Install Flags #

bundle config set --local without 'development test'
bundle config set --local deployment 'true'
bundle config set --local path 'vendor/bundle'
bundle install --jobs 4 --retry 3

Explanation:

  • without 'development test' — skips the development and test gem groups (defined in Gemfile).
  • deployment 'true' — installs per Gemfile.lock, won’t modify.
  • path 'vendor/bundle' — installs in a local directory (not /usr/local/bundle).
  • --jobs 4 — parallel install.
  • --retry 3 — retries on network blips.

5.2 Disable Documentation Installs #

For smaller images, disable RDoc and Ri:

gem: --no-document

Put it in ~/.gemrc or the Gemfile:

# Gemfile
install_if -> { false } do
  gem 'rdoc'
  gem 'psych'
end

5.3 Caching Problematic Gems #

For gems that take a long time to compile (nokogiri, grpc), keep the binaries in cache:

# In the build stage
RUN bundle config set --local force_ruby_platform false

6. Asset Compilation (Rails) #

Rails apps usually need to compile assets. Do it in the build stage so the runtime image stays slim:

# In the build stage
RUN SECRET_KEY_BASE=dummy \
    RAILS_ENV=production \
    bundle exec rails assets:precompile

Important:

  • SECRET_KEY_BASE=dummy — Rails needs a secret key to precompile.
  • RAILS_ENV=production — precompile per the production environment.
  • The precompile output is in public/assets (or per config.assets.prefix).

The runtime must not have Node.js, Yarn, or build tools. All assets are compiled in the build stage.

For runtime, add:

ENV RAILS_SERVE_STATIC_FILES=true

So Puma serves static files from public/. Or better, use a separate Nginx.

7. Bootsnap Optimization #

Bootsnap is a tool that speeds up Ruby/Rails startup by caching parsed files and bytecode. Very useful for container cold starts.

Gemfile setup:

gem 'bootsnap', require: false

In config/boot.rb:

require 'bootsnap/setup'

In the build stage, pre-compile the bootsnap cache:

RUN bundle exec bootsnap precompile --gemfile \
 && bundle exec bootsnap precompile app/ config/ lib/

This cache gets copied to the runtime image, making startup much faster.

8. Production Logging #

Rails defaults to logging to log/development.log or log/production.log. For containers, redirect to STDOUT.

config/environments/production.rb:

config.logger = ActiveSupport::Logger.new(STDOUT)
config.log_level = ENV.fetch('LOG_LEVEL', 'info')
config.log_tags = [:request_id]

Or use the rails_stdout_logging gem (for Rails 6+):

gem 'rails_stdout_logging'

JSON structured log output (for production) with Lograge:

gem 'lograge'

config.lograge.enabled = true
config.lograge.formatter = Lograge::Formatters::Json.new
config.lograge.base_controller_class = 'ActionController::API'

9. Healthchecks #

Create a /health endpoint in Rails:

# config/routes.rb
Rails.application.routes.draw do
  get '/health', to: 'health#show'
end
# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def show
    head :ok
  end
end

In the Dockerfile (for alpine/slim, not distroless):

HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

For distroless: move it to the orchestrator.

10. Signal Handling #

Puma (and Unicorn) handle SIGTERM correctly by default, but make sure:

  • Exec form in CMD (CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]).
  • Graceful worker shutdown set in puma.rb:
# config/puma.rb
worker_timeout 60
worker_shutdown_timeout 30
worker_boot_timeout 60
preload_app!

11. Security Hardening #

Non-Root Users #

# For alpine
RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup

# For slim
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app

# For distroless
USER nonroot:nonroot

Secret Management #

Rails has credentials.yml.enc, which is encrypted. Don’t commit the master key to Git. Mount it at runtime:

# docker-compose.yml
services:
  app:
    secrets:
      - rails_master_key

secrets:
  rails_master_key:
    file: ./config/master.key

Vulnerability Scanning #

- name: Build
  run: docker build -t myapp:${{ github.sha }} .
- name: Scan
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

12. Anti-Patterns to Avoid #

✗ Using ruby:latest #

// ✗ Non-deterministic builds, 700+ MB images
FROM ruby:latest

Solution: Pin the tag: ruby:3.3.4-slim-bookworm.

✗ Unfiltered Bundle Installs #

// ✗ Test and development gems go to production
RUN bundle install

Solution: bundle config set without 'development test'.

✗ Build Tools in Runtime #

// ✗ gcc, make, libpq-dev sit in the runtime image
FROM ruby:3.3-slim
RUN apt-get install -y build-essential libpq-dev
RUN bundle install
CMD ["rails", "server"]

Solution: Multi-stage; build tools only in the build stage.

✗ Asset Compilation at Runtime #

// ✗ The runtime image needs nodejs, yarn
RUN bundle exec rails assets:precompile
CMD ["rails", "server"]

Solution: Pre-compile assets in the build stage; keep the runtime image slim.

✗ Logging to Files #

# ✗ Logs disappear when the container restarts
config.logger = Logger.new('log/production.log')

Solution: Log to STDOUT with ActiveSupport::Logger.new(STDOUT).

✗ Tagging Images Without a Strategy #

# ✗ Can't roll back
docker build -t myapp:latest .

Solution: Semantic version + git hash.

13. A Production-Grade Rails Dockerfile Example #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM ruby:3.3.4-slim-bookworm AS builder

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    libpq-dev \
    libxml2-dev \
    libxslt1-dev \
    libffi-dev \
    libyaml-dev \
    libssl-dev \
    libvips-dev \
    nodejs \
    yarn \
 && rm -rf /var/lib/apt/lists/*

# Cache gems
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
 && bundle config set --local deployment 'true' \
 && bundle config set --local path 'vendor/bundle' \
 && bundle install --jobs 4 --retry 3

# Build the app
COPY . .

RUN SECRET_KEY_BASE=dummy \
    RAILS_ENV=production \
    bundle exec rails assets:precompile \
 && bundle exec bootsnap precompile --gemfile \
 && bundle exec bootsnap precompile app/ config/ lib/

# ==== Stage 2: Runtime ====
FROM ruby:3.3.4-slim-bookworm

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    libxml2 \
    libxslt1.1 \
    libffi8 \
    libyaml-0-2 \
    libssl3 \
    libvips42 \
    tzdata \
    curl \
 && rm -rf /var/lib/apt/lists/*

# Copy the app
COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY --from=builder /app /app

# Non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app \
 && chown -R app:app /app
USER app

ENV RAILS_ENV=production \
    RAILS_LOG_TO_STDOUT=true \
    RAILS_SERVE_STATIC_FILES=true \
    BUNDLE_PATH=vendor/bundle

EXPOSE 3000

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

14. When to Use Which Strategy #

ConditionChoiceReason
Standard Rails APIslim + multi-stageReasonable size, debugging capability
Full Rails appslim + multi-stageNeeds the asset pipeline, needs tools
Microservicesalpine + multi-stageSmaller images
High-maturity productionDistroless + customMinimal size, solid observability
Lightweight Rails APIalpine + multi-stageCold-start time matters
Sidekiq workersslim + multi-stageNo web server needed

15. Ruby Dockerfile Review Checklist #

BASE IMAGE:
  □ Explicit tag (ruby:3.3.4-slim-bookworm, not latest)
  □ Slim runtime stage (slim, alpine, or distroless)
  □ Not ruby:latest (too large)

BUILD:
  □ Multi-stage build
  □ bundle config without 'development test'
  □ bundle config deployment 'true'
  □ bundle install --jobs 4
  □ Build tools (build-essential, *-dev) only in the build stage
  □ Asset precompile in the build stage
  □ bootsnap precompile in the build stage

RUNTIME:
  □ USER nonroot
  □ Runtime libraries (without -dev) in the runtime stage
  □ RAILS_LOG_TO_STDOUT=true
  □ BUNDLE_PATH=vendor/bundle
  □ Logs to STDOUT
  □ Healthcheck
  □ CMD in exec form

SIZE:
  □ < 300 MB for slim runtime
  □ < 200 MB for alpine runtime
  □ < 150 MB for distroless runtime
  □ docker history shows no odd layers

SECURITY:
  □ No secrets in the image
  □ credentials.yml.enc encrypted, master key mounted
  □ Strict .dockerignore
  □ .env excluded
  □ Image scanned with trivy/grype
  □ Non-root user
  □ Base image up to date

FRAMEWORK:
  □ Asset precompile in the build stage
  □ bootsnap precompile
  □ Lograge for JSON logs
  □ Puma config tuned

Summary #

  • Slim Ruby images are very possible — Ruby can be as small as Java or Node.js with disciplined Dockerfiles.
  • Size reality: 80-130 MB (distroless), 120-180 MB (alpine), 200-300 MB (slim multi-stage), 400-600 MB (slim without optimization), 700-900 MB (ruby:latest — anti-pattern).
  • Multi-stage builds are mandatorybuild-essential, *-dev packages, and compilers only in the build stage. The runtime stage only needs runtime libraries.
  • bundle config set without 'development test' — skips test, development, and debug gems during install. This is what most distinguishes slim images from fat ones.
  • Asset precompile in the build stagerails assets:precompile needs Node.js/Yarn. Do it in the build stage; keep the runtime image slim.
  • Bootsnap precompile — cache parsing and bytecode in the build stage for faster cold starts. Very useful for serverless and Kubernetes.
  • Runtime libraries (without -dev) — install libpq-dev in the build stage, but only libpq5 at runtime. This is an important compromise for slim images.
  • Rails precompile needs SECRET_KEY_BASE — set a dummy value in the Dockerfile: SECRET_KEY_BASE=dummy RAILS_ENV=production bundle exec rails assets:precompile.
  • Lograge for JSON logs — Rails’ default multi-line logs are hard to parse. Lograge + JsonFormatter for production-grade structured logs.
  • RAILS_LOG_TO_STDOUT=true — makes Rails log to STDOUT, not files. Mandatory for containers.
  • Explicit tagsruby:3.3.4-slim-bookworm, not latest. Build reproducibility matters for auditing.
  • Slim images need solid observability — JSON logs, metrics, healthchecks, and graceful shutdown. Ruby can be as slim as Java with the right Dockerfile discipline.
  • Alpine for microservices, slim for monoliths — alpine is smaller but has musl compatibility risks. slim is bigger but safer.

← Previous: TypeScript   Next: Rust →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact