Docker Multi-Stage Builds for Rails 8: Cut Your Image Size by 60%
A default Rails 8 Docker image built from ruby:3.3 weighs around 1.2GB. That’s 1.2GB pushed to your registry on every deploy, pulled on every node scale-up, and sitting in memory on every server. With multi-stage builds, you can get that under 500MB — sometimes under 300MB if you’re aggressive about it.
Rails 8 ships with a Dockerfile generator (rails new creates one automatically), and it already uses multi-stage builds. But the generated version is a starting point, not the finish line. Here’s how to squeeze real savings out of it.
How Multi-Stage Builds Work
A multi-stage Dockerfile uses multiple FROM statements. Each FROM starts a new build stage. You can copy artifacts from earlier stages into later ones, leaving behind everything you don’t need in production.
# Stage 1: install dependencies
FROM ruby:3.3.6-slim AS build
# ... install gems, precompile assets
# Stage 2: production image
FROM ruby:3.3.6-slim AS production
# ... copy only what's needed from build stage
The build stage can have compilers, dev headers, and Node.js — none of that ends up in your final image.
The Dockerfile, Stage by Stage
Stage 1: Base
Start with ruby:3.3.6-slim instead of ruby:3.3.6. The slim variant is Debian Bookworm without the extras. That alone saves about 400MB.
FROM ruby:3.3.6-slim AS base
WORKDIR /rails
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development:test"
Setting BUNDLE_WITHOUT here means Bundler skips dev/test gems entirely. On a typical Rails app, that eliminates 30-40% of your gem dependency tree.
Stage 2: Build
This is where the heavy lifting happens. Install build dependencies, bundle gems, precompile assets, then throw it all away (except the output).
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential \
git \
libpq-dev \
node-gyp \
pkg-config \
python-is-python3 && \
rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache \
"${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
A few things worth calling out:
--no-install-recommends prevents APT from pulling in suggested packages. On Debian Bookworm, this can save 100-200MB per stage.
Clearing the bundle cache (rm -rf ... cache) removes the .gem files that Bundler keeps around. They’re only needed for reinstallation, which doesn’t happen in a container.
SECRET_KEY_BASE_DUMMY=1 is a Rails 7.1+ feature. It lets asset precompilation run without a real secret key, which means you don’t need to pass secrets at build time.
Stage 3: Production
Copy only what you need from the build stage.
FROM base AS production
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
curl \
libpq5 \
libvips && \
rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
RUN chown -R rails:rails db log storage tmp
USER 1000:1000
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 3000
CMD ["./bin/thrust", "./bin/rails", "server"]
Notice what’s not here: no build-essential, no git, no node-gyp, no libpq-dev (we use libpq5, the runtime library only). The C compiler alone accounts for about 150MB.
Running as a non-root user (UID 1000) is a security baseline. If your container gets compromised, the attacker doesn’t land as root.
Real Numbers
I measured these on a mid-sized Rails 8 app with 85 gems, Tailwind CSS, and PostgreSQL. All images built on linux/amd64:
| Approach | Image Size | Build Time |
|---|---|---|
ruby:3.3.6 single stage |
1.24 GB | 2m 14s |
ruby:3.3.6-slim single stage |
843 MB | 2m 31s |
| Multi-stage (as above) | 467 MB | 2m 48s |
Multi-stage + .dockerignore tuning |
423 MB | 2m 44s |
Build time increases slightly because Docker processes multiple stages, but the deploy-time savings (smaller push/pull) more than compensate. On a 100Mbps connection, the difference between pushing 1.2GB and 423MB is about 60 seconds per deploy.
The .dockerignore File Most People Forget
Docker sends your entire project directory to the daemon as build context. Without a .dockerignore, that includes .git (often 100MB+), node_modules, tmp, log, and test fixtures.
.git
.github
.gitignore
log/*
tmp/*
storage/*
node_modules
.bundle
vendor/bundle
test/
spec/
.rspec
coverage/
.env*
docker-compose*.yml
README.md
On the Rails app I tested, adding this .dockerignore cut the build context from 340MB to 12MB. Build time dropped by about 15 seconds because Docker wasn’t copying hundreds of megabytes it would never use.
Going Further: Alpine vs Slim
You’ll see recommendations to use ruby:3.3.6-alpine instead of slim. Alpine uses musl libc instead of glibc and produces smaller images (typically 50-100MB smaller). But there are tradeoffs.
Some gems with C extensions don’t compile cleanly on Alpine. nokogiri, grpc, and pg usually work, but less common gems can cause hours of debugging. And musl’s memory allocator behaves differently from glibc’s — I’ve seen Rails apps on Alpine use 15-20% more RSS memory at steady state because musl’s malloc doesn’t return memory to the OS as aggressively.
If you control your gem dependencies tightly and test on Alpine in CI, it’s a valid choice. For most teams, slim gives you 90% of the size savings with none of the compatibility headaches.
Layer Caching for Faster Rebuilds
Docker caches layers based on the instructions and files involved. The order of your COPY statements matters.
# Good: Gemfile changes rarely, so this layer is cached most of the time
COPY Gemfile Gemfile.lock ./
RUN bundle install
# Application code changes frequently, but it's a fast copy
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
If you put COPY . . before bundle install, every code change invalidates the bundle cache and forces a full reinstall. That turns a 10-second rebuild into a 90-second one.
For CI pipelines, consider using BuildKit cache mounts to persist the bundle cache between builds:
RUN --mount=type=cache,target=/usr/local/bundle/cache \
bundle install
This keeps downloaded .gem files in a persistent cache volume, so even a full reinstall only needs to compile C extensions rather than re-downloading everything.
Health Checks
Add a health check to your Dockerfile so orchestrators (Docker Swarm, ECS, Kubernetes) can detect unhealthy containers:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/up || exit 1
Rails 8 includes a /up endpoint by default (via Rails.application.routes health check). Using curl here means you need curl in your production image — that’s why we included it in the production stage’s apt-get install.
Deploying with Kamal 2
If you’re using Kamal 2 for deployment, it builds and pushes your Docker image automatically. Your config/deploy.yml just needs to point at the right registry:
image: your-org/your-app
registry:
server: ghcr.io
username:
- KAMAL_REGISTRY_USERNAME
password:
- KAMAL_REGISTRY_PASSWORD
Kamal runs docker build with your Dockerfile, tags it, and pushes it. The multi-stage Dockerfile we built here works with Kamal without any modifications.
Common Mistakes
Copying .bundle/config from development. Your local Bundler config might set BUNDLE_PATH to vendor/bundle or include platform-specific settings. The Dockerfile should control Bundler configuration entirely through environment variables.
Installing Node.js in the production stage. If you’re using Propshaft (the Rails 8 default) with Tailwind’s standalone CLI or importmap, you don’t need Node.js at all — not even in the build stage. Only install it if your asset pipeline requires it.
Skipping rm -rf /var/lib/apt/lists/*. Every apt-get update downloads package lists that consume 30-40MB. Remove them in the same RUN layer.
FAQ
How much memory does a slim-based Rails container use compared to the full image?
The image size doesn’t directly affect runtime memory. A Rails 8 app typically uses 150-300MB of RSS memory regardless of whether the base image is slim or full. The size savings are in disk and network transfer. Your containers boot faster and your registry bills shrink, but the running process uses the same amount of RAM.
Should I use distroless images for Rails?
Distroless images remove the package manager and shell entirely, producing very small images. The Ruby distroless image is experimental and not officially supported by the Ruby team. You can’t docker exec into a distroless container to debug, and you can’t install monitoring agents. For most Rails apps, slim is the practical choice. Reserve distroless for high-security environments where the attack surface reduction justifies the debugging limitations.
Does multi-stage building work with Docker Compose in development?
Yes, but you should target a specific stage. Add target: base in your docker-compose.yml to avoid running the production build during development:
services:
web:
build:
context: .
target: base
volumes:
- .:/rails
command: ./bin/rails server -b 0.0.0.0
This mounts your source code as a volume and skips asset precompilation entirely, giving you the fast feedback loop you need in development.
Why is my image still large after multi-stage builds?
Check for gems with large native extensions. grpc alone adds about 50MB of compiled shared objects. Run docker history your-image:latest to see which layers are consuming space. Also verify your .dockerignore is actually being applied — Docker silently ignores syntax errors in that file.
Can I use multi-stage builds with GitHub Actions CI?
Absolutely. GitHub Actions supports BuildKit natively. Enable layer caching with actions/cache to speed up repeated builds:
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/your-org/your-app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
The gha cache backend stores layers in GitHub’s cache storage, which persists across workflow runs. First build takes full time; subsequent builds with unchanged Gemfile complete in under a minute.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation