How I Use Multi-Stage Docker Builds in GitLab CI
In the previous article, I wrote about using Docker images to speed up CI. This article is the next step: using multi-stage builds so the…
How I Use Multi-Stage Docker Builds in GitLab CI
In the previous article, I wrote about using Docker images to speed up CI. This article is the next step: using multi-stage builds so the CI image and the production image can share most of the logic without becoming the same image.
That distinction matters.
- CI wants a convenient image with everything needed for linting, tests, and helper tools.
- Production wants a smaller runtime image with less baggage and fewer CVEs.
Multi-stage builds let me optimize for both.
Problem
The CI environment usually needs:
- compilers;
- header packages;
- extra gems or tools used only in CI;
- debugging conveniences.
The production environment usually wants the opposite:
- only runtime dependencies;
- smaller image size;
- less attack surface;
- fewer CVEs to track.
If the same image is used for both CI and production, one side usually pays for what the other side needs.
The Structure
Here is the simplified structure of the Dockerfile I use:
# Shared runtime foundation.
FROM ruby:<version>-slim AS base
# Build-time dependencies and application gems.
FROM base AS build
# Extra dependencies used only in CI.
FROM build AS ci
# Final runtime image for production.
FROM base
The important part is that each stage has a clear purpose.
basecontains packages needed by both CI and production.buildcontains compilers and everything needed to build runtime dependencies.ciextendsbuildwith CI-only gems and tools.- the final stage starts again from
baseand copies in only what production needs.
That last point is the key to the whole setup. The production image stays cleaner because it does not inherit the build environment.
Dockerfile Example
Here is the real layout of my Dockerfile.
# I use the modern Dockerfile frontend mainly for heredoc RUN blocks.
# syntax=docker/dockerfile:1.3-labs
# `REGISTRY` can point to Docker Hub, a private registry,
# or a dependency proxy (GitLab Dependency Proxy in my case).
ARG REGISTRY="docker.io"
ARG RUBY_VERSION
# Shared base for both CI and production.
# This stage contains packages that are needed at runtime as well.
FROM ${REGISTRY}/ruby:${RUBY_VERSION}-slim AS base
SHELL ["/bin/bash", "-c"]
WORKDIR /app
RUN <<'EOF'
# I use a small helper around apt to keep package installation consistent
# and to make cleanup easy to reuse across stages.
cat > /docker-apt.sh <<'SCRIPT'
#!/usr/bin/env sh
set -ex
update() {
apt-get update --quiet
}
cleanup() {
apt-get autoclean --assume-yes
apt-get autoremove --assume-yes
rm -rf /tmp/* /var/tmp/* /var/lib/apt/lists/* /var/cache/apt/*
}
install() {
apt-get install --assume-yes --no-install-recommends "${@}"
}
install_inline() {
update
install "${@}"
cleanup
}
"${@}"
SCRIPT
chmod +x /docker-apt.sh
# The entrypoint script is optional. It is not the main point of this article.
cat > /docker-entrypoint.sh <<'SCRIPT'
#!/usr/bin/env bash
exec "${@}"
SCRIPT
chmod +x /docker-entrypoint.sh
EOF
# I like heredoc RUN blocks because they stay readable,
# keep related commands together, and still produce a single image layer.
RUN <<'EOF'
set -e -o pipefail
/docker-apt.sh update
/docker-apt.sh install \
curl \
file \
gpg \
imagemagick \
less \
patchelf \
pkg-config \
shared-mime-info
# This PostgreSQL client setup is project-specific.
# Not everyone needs it, but it is a good example of something that belongs
# in the shared runtime base rather than in a build-only stage.
curl --silent --fail --location https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/pgdg.gpg
(source /etc/os-release && echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list)
/docker-apt.sh update
/docker-apt.sh install postgresql-client-18
/docker-apt.sh cleanup
EOF
# ==
# This stage adds build-only packages.
# They are intentionally not present in the final runtime image.
FROM base AS build
RUN /docker-apt.sh install_inline \
bzip2 \
g++ \
gcc \
git \
make \
libpq-dev \
libyaml-dev \
zlib1g-dev
# Copy only the files that affect gem resolution first.
# This keeps the bundle layer reusable until dependencies change.
COPY engines/<engine>/<engine>.gemspec /app/engines/<engine>/<engine>.gemspec
COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
RUN <<'EOF'
set -e -o pipefail
bundle config set no-cache true
bundle config set without development test
bundle install --jobs "$(nproc)"
# I encourage removing obvious leftovers before the layer is saved.
# Keeping layers small matters, especially when many runners pull these images.
rm -rf /usr/local/bundle/cache /usr/local/bundle/ruby/*/cache
EOF
# ==
# CI extends the build stage instead of starting from scratch.
# It keeps runtime gems and adds only CI-specific dependencies.
FROM build AS ci
# In this project I keep some CI-only Ruby dependencies in a separate file.
# The exact mechanism is not important. The bigger idea is that CI can add
# whatever extra dependencies it needs here without leaking them into production.
COPY Gemfile-ci /app/Gemfile-ci
COPY Gemfile-ci.lock /app/Gemfile-ci.lock
RUN <<'EOF'
set -e -o pipefail
# The previous stage excluded development and test groups.
# Here I remove that restriction so the CI image can install everything it needs.
bundle config unset without
bundle install --jobs "$(nproc)"
bundle install --jobs "$(nproc)" --gemfile Gemfile-ci
rm -rf /usr/local/bundle/cache /usr/local/bundle/ruby/*/cache
EOF
# ==
# Final production image.
# It starts from the clean base image and copies in only built outputs.
FROM base
# This is the key step: copy the built runtime dependencies from `build`
# without dragging compilers and other build-only tools into production.
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY . /app
# The last step is preparing the image for runtime.
# In my case, I precompile bootsnap cache so the runtime container
# has less work to do on boot.
RUN bundle exec bootsnap precompile --gemfile app/ config/ gems/ lib/ engines/
ENV BOOTSNAP_READONLY="1"
ENV RAILS_LOG_TO_STDOUT="1"
ENTRYPOINT ["/docker-entrypoint.sh"]
This approach adds a bit of structure, but that structure is the point. It makes the boundaries explicit instead of letting CI and production drift into each other.
GitLab CI Example
The same Dockerfile can produce different images depending on the build target, so I do not need separate Dockerfiles for CI and production.
stages:
- setup
- test
- release
variables:
CI_IMAGE: "${CI_REGISTRY_IMAGE}/ci:${CI_COMMIT_REF_NAME}"
CACHE_IMAGE: "${CI_REGISTRY_IMAGE}/cache:${CI_COMMIT_REF_NAME}"
DEFAULT_BRANCH_CACHE_IMAGE: "${CI_REGISTRY_IMAGE}/cache:${CI_DEFAULT_BRANCH}"
default:
image: "${CI_IMAGE}"
.docker:
image: "docker:28-cli"
services:
- name: "docker:28-dind"
alias: docker
variables:
DOCKER_BUILDKIT: "1"
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_CERTDIR: ""
before_script:
- docker buildx create --use
- echo "${CI_REGISTRY_PASSWORD}" | docker login "${CI_REGISTRY}" --username "${CI_REGISTRY_USER}" --password-stdin
setup:
stage: setup
extends: .docker
script:
- |
docker buildx build --push \
--file Dockerfile \
--target ci \
--cache-from "type=registry,ref=${DEFAULT_BRANCH_CACHE_IMAGE}" \
--cache-from "type=registry,ref=${CACHE_IMAGE}" \
--cache-to "type=registry,ref=${CACHE_IMAGE},mode=max" \
--build-arg "REGISTRY=${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}" \
--tag "${CI_IMAGE}" \
"${CI_PROJECT_DIR}"
test:
stage: test
needs:
- job: setup
artifacts: false
script:
- bundle exec rspec
release:
stage: release
extends: .docker
needs:
- job: test
artifacts: false
script:
- |
docker buildx build --push \
--file Dockerfile \
--cache-from "type=registry,ref=${DEFAULT_BRANCH_CACHE_IMAGE}" \
--cache-from "type=registry,ref=${CACHE_IMAGE}" \
--build-arg "REGISTRY=${CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX}" \
--tag "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA}" \
"${CI_PROJECT_DIR}"
The setup job builds the ci stage explicitly via --target ci flag and pushes it under a branch-specific tag. Later test jobs run on that image directly.
The release job builds the final stage, because the final stage is the default target when --target is not specified.
I also reuse both the branch cache and the default branch cache here. I covered that cache setup in the previous article, so I will not go deep into it again.
Why It Works Well
I like this layout because it makes the boundaries explicit.
When someone adds a package or a gem, the question becomes simple:
- does it belong in runtime;
- does it belong only in the build stage;
- or is it needed only in CI.
That keeps one Dockerfile, but still gives me different images for different jobs.
Tradeoffs
This approach is not free.
- You have to think carefully about stage boundaries.
- Dockerfile order matters a lot for caching.
- Copying too much from one stage to another can quietly make the final image larger.
- The clean separation between runtime and CI dependencies has to be maintained over time.
Still, for me and my team, this has been much easier to live with than maintaining several different Docker build flows.
Summary
Multi-stage builds add some discipline, but in return they give me one Dockerfile, reusable dependency layers, a CI image that can stay practical, and a production image that stays closer to runtime.
메타데이터
- post_id
- 110b3dd6caef
- slug
- how-i-use-multi-stage-docker-builds-in-gitlab-ci-110b3dd6caef
- url
- https://medium.com/@netrusov/how-i-use-multi-stage-docker-builds-in-gitlab-ci-110b3dd6caef
- canonical_url
- https://medium.com/@netrusov/how-i-use-multi-stage-docker-builds-in-gitlab-ci-110b3dd6caef
- author_url
- https://medium.com/@netrusov
- status
- ok
- fetched_at
- 2026-07-23 00:52:37