← Back to list

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…

Alexander Netrusov · 2026-04-21 19:04 · 0 claps · 5.2 min read
#gitlab #gitlab-ci #gitlab-ci-docker #docker #dockerfiles
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔓 · Open Source

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.

  • base contains packages needed by both CI and production.
  • build contains compilers and everything needed to build runtime dependencies.
  • ci extends build with CI-only gems and tools.
  • the final stage starts again from base and 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