Why To Use A Multi-Stage Dockerfile
Docker has made it easy to use the same environment everywhere, from development to production. But the most basic Dockerfile, where all your dependencies are lumped together in one image, has hidden costs. In this article, we’ll learn the advantages of multi-stage Dockerfiles both from a security and a performance standpoint, primarily for production images.
What is a Multi-Stage Dockerfile?
You might have a Dockerfile in your application that looks something like this:
FROM ruby:3.2
WORKDIR /app
# Install system dependencies needed to compile native gems
RUN apt-get update && apt-get install -y \
gcc \
make \
libpq-dev
# Install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install
# Copy application code
COPY . .
EXPOSE 3000
CMD ["ruby", "app.rb"]
This is a typical single-stage setup. There’s one base image, for example, ruby:3.2, and everything your application needs gets installed inside it: the full Bundler toolchain, build utilities like gcc and make for compiling native gem extensions, and dev dependencies like RSpec or Pry, all living side by side. The appeal is obvious. It’s straightforward to write, easy to reason about, and gets the job done.
But that simplicity comes with a trade-off. For example, every tool you used to build your application is still sitting inside the image you ship to production even though you no longer need it there.
In contrast, a multi-stage Dockerfile introduces the idea of stages, discrete phases of your build process, each defined by its own FROM statement. Here’s what that looks like in practice:
- Multiple
FROMstatements: each one starts a new stage with its own base image and its own filesystem. You might have a builder stage that installs compilers and build tools, and a separate production stage that only contains what your application needs to run. - Selective artifact copying stages can pull specific files from a previous stage using
COPY --from=<stage>. This means your final image gets only the output of the build such as compiled code and static assets, and not the tools that produced it.
Here’s an example of what a multi-stage Dockerfile looks like in practice. This multi-stage Dockerfile is from the most recent version of Rails:
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t rails_demo .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name rails_demo rails_demo
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=4.0.4
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libvips libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
Here’s what’s happening across the three stages:
base: this stage sets up the common foundation (slim Ruby image, shared env vars) that both other stages build on.build: the throw-away stage. Installsbuild-essential,git, compiles gems, precompiles assets. None of these tools ship to production.- the unnamed third
FROM base: this copies only the built artifacts frombuildand runs as a non-root user.
Why Use a Multi-Stage Dockerfile?
Now that we know what the differences are between a single-stage and multi-stage Dockerfile, let’s talk about why you might choose a multi-stage Dockerfile.
Two main advantages of multi-stage builds are performance and security:
Performance
Multi-stage builds usually have a smaller final image size since the build tools and dev dependencies don’t make it into the final image. Multi-stage builds also play nicely with Docker’s layer caching. Because each stage is isolated, Docker can cache them independently. If your dependencies haven’t changed, the builder stage can be skipped entirely on the next run. You only rebuild the stages that actually changed, which adds up to significant time savings over hundreds of pipeline runs. Smaller images and cached stages mean faster pulls, faster deployments, and less time waiting around in your CI/CD pipeline.
Security
A multi-stage build is more secure than a single-stage build because the production image contains only what’s needed to run the application, nothing more.
Every package installed in your image is a potential vulnerability. Build tools like gcc, make, and curl are common in single-stage images and they’re also common exploit vectors. With a multi-stage build, those tools never make it into your production image. Fewer packages means fewer CVEs to worry about, and a much smaller surface for attackers to target.
You can reduce this even further by pairing your final stage with a slim base image. For example, ruby:4.0-slim strips out packages that aren’t needed for most Ruby apps, and ruby:4.0-alpine goes even further, using Alpine Linux as its base, a minimal OS that keeps only the bare essentials by default.
Tips and Common Pitfalls
- Skip multi-stage for development and test environments. The security and build-performance wins mostly matter for what you ship to production. In dev you’re rebuilding constantly and want to run commands like
bundle install,rails console, or add a gem on the fly. A multi-stage setup adds friction for little payoff there, since your dev tools need to stay in the image anyway. Keep a single-stage Dockerfile (or a dedicated dev stage in the same file) for local development and testing, and reserve the multi-stage build for your production image. - Name your stages. Using
AS builderandAS productionin yourFROMstatements makes your Dockerfile much easier to read and maintain, especially as the number of stages grows. - Know what to copy. The most common stumbling block is figuring out exactly which files need to move between stages. For a Ruby app, that’s typically your installed gems directory and your application code. Take the time to map this out before you write the
COPY --fromlines. - Run as a non-root user in your final stage, like the Rails example above does. Even if an attacker gets into the container, they won’t have root access to work with.
- Build cache ordering matters: Put the steps least likely to change at the top of each stage and most likely to change at the bottom. For example, copy your Gemfile and run
bundle installbefore copying your application code. That way, if you change a Ruby file, Docker doesn’t re-runbundle installunnecessarily. - Match your base images when going slim or Alpine. If your final stage switches to a musl-based image like
ruby:4.0-alpine, but your builder stage compiled native gem extensions on a glibc-based image likeruby:4.0orruby:4.0-slim, those extensions may fail at runtime. Either build and run on matching bases, or compile your gems inside an Alpine builder stage. - Forgetting build dependencies at runtime. A very common mistake is copying your compiled gems into the final stage but forgetting to install the runtime system libraries they depend on. For example, the
pggem needslibpq5at runtime even though it only needslibpq-devto compile. Your app will build fine but crash at runtime. This is probably the most common real-world gotcha with multi-stage builds. - Secret leakage between stages. People sometimes assume that because secrets (API keys, credentials) used in the build stage don’t get copied to the final stage, they’re safe. But they can still be exposed in the image’s layer history. Use Docker BuildKit secrets instead:
RUN --mount=type=secret,id=my_secret ... - If you want to go even further than slim images, look into distroless images. These images contain absolutely nothing except your app and its runtime- no shell, no package manager, nothing. They represent the ultimate reduction in attack surface. Just be aware that Ruby support for distroless is still limited compared to languages like Go or Java, so do your research before committing to it for a Ruby project.
Conclusion
Multi-stage Dockerfiles require slightly more work upfront. But in return, you get images that are dramatically smaller, meaningfully more secure, and faster to build. That’s a trade-off worth making for any production workload.
Want to convert your single-stage Dockerfiles to multi-stage? We can help!