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 #
| Setup | Image Size |
|---|---|
ruby:latest + bundle install (everything) | 700-900 MB |
ruby:slim without optimization | 400-600 MB |
ruby:slim + multi-stage + cleanup | 180-250 MB |
ruby:alpine + multi-stage | 120-180 MB |
| Distroless + custom Ruby | 80-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 defaultruby: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 perGemfile.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 topublic/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, notlibpq-dev. tzdatafor time zone data.curl(optional) for healthchecks if the image has a shell.- Copy
vendor/bundlefrom 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 addis faster thanapt-get install.apk add --no-cache— doesn’t store the index cache.apk delat the end forbuild-basecleanup (but usuallybuild-baseis 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.
PATHmust 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 thedevelopmentandtestgem groups (defined inGemfile).deployment 'true'— installs perGemfile.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 perconfig.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 #
| Condition | Choice | Reason |
|---|---|---|
| Standard Rails API | slim + multi-stage | Reasonable size, debugging capability |
| Full Rails app | slim + multi-stage | Needs the asset pipeline, needs tools |
| Microservices | alpine + multi-stage | Smaller images |
| High-maturity production | Distroless + custom | Minimal size, solid observability |
| Lightweight Rails API | alpine + multi-stage | Cold-start time matters |
| Sidekiq workers | slim + multi-stage | No 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 mandatory —
build-essential,*-devpackages, 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 stage —
rails assets:precompileneeds 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) — installlibpq-devin the build stage, but onlylibpq5at 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 +
JsonFormatterfor production-grade structured logs.RAILS_LOG_TO_STDOUT=true— makes Rails log to STDOUT, not files. Mandatory for containers.- Explicit tags —
ruby:3.3.4-slim-bookworm, notlatest. 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.