Building Docker Release Images Instead of Compiling on Servers
AI generated
Docker · Release Images · Multi-Stage · CI/CD
Build Docker release images
instead of compiling on servers

Anyone who compiles on production servers couples the build environment and the runtime environment in a way that leads to non-reproducible deployments. Docker release images with multi-stage builds cleanly separate these phases, deliver lean artifacts, and turn every deployment into a controllable, reversible operation.

12 min read Multi-Stage · Build Container · CI Pipeline · Registry Docker 24+ · GitHub Actions · GitLab CI

1. The problem with builds on production servers

Many teams deploy by running git pull on the production server, then starting the package manager, and finally invoking the compiler or transpiler. It feels simple, but it creates a fatal coupling: the production server has to have every build tool installed, the compiler, Node.js, Maven, Composer with dev dependencies. The server mutates with every deployment, and a failed build leaves the server in an intermediate state that is hard to debug.

The deeper problem is the lack of reproducibility. When a Docker release image is built on the CI server and then run on the production server, you can be sure that exactly the artifact that was tested is the one running. Software compiled on the server, on the other hand, was built under different conditions: different library versions, different environment variables, a different state of the filesystem. "Works on my machine" is the symptomatic result of this approach.

Another drawback lies in the attack surface: a production server with an installed compiler, Git client and package manager is considerably harder to secure than a server that only runs a container runtime. Docker release images make it possible to reduce production servers to a minimal role: start containers, stop containers, read logs.

2. The concept: the release image as an immutable artifact

A Docker release image is an immutable artifact. It contains exactly what is needed at runtime and nothing more. It is built once, stored in a container registry, and can then be started as many times as needed on as many servers as needed. Every deployment is therefore identical to the previous test in the CI pipeline, because the same image is used. That is the fundamental difference from a build-on-server strategy, where every deployment is a new, potentially different build.

The tagging strategy for release images is crucial for traceability. An image tagged only as latest cannot be traced back to a specific commit. The recommended strategy combines the Git commit SHA with a semantic version tag: registry.example.com/app:1.4.2 and registry.example.com/app:sha-a1b2c3d point to the same image. The SHA tag enables rollbacks to an exactly known state, while the version tag communicates the semantic meaning to other teams.

This concept is especially valuable for the rollback scenario: instead of creating a revert commit, pushing it, and waiting for a new build, you simply restart the previous release image. Because the image stays available in the registry, the rollback takes seconds instead of minutes. The production environment is at every point in time in a defined, testable state.

3. Multi-stage builds: separating builder and runtime

Multi-stage builds are the central tool for clean Docker release images. A multi-stage Dockerfile has several FROM directives, each opening a new stage. The builder stage contains all the build tools and produces the compiled artifacts. The runtime stage starts from a minimal base image and copies in only the finished artifacts from the builder stage. Anything not explicitly copied does not end up in the final image.

The result is a release image that contains only the runtime dependencies: no compilers, no dev dependencies, no test frameworks, no temporary build files. For a PHP application, that means: the builder stage has Composer with all the dev packages and runs composer install, npm run build and the asset compile step. The runtime stage is based on php:8.4-fpm-alpine and contains only the vendor files and the compiled assets. Image sizes under 150 MB for applications that need several gigabytes at build time are realistic.


# Dockerfile: multi-stage release image for PHP/Node application
# Stage 1: Node asset builder
FROM node:22-alpine AS node-builder
WORKDIR /build
COPY package*.json ./
# Install only production-relevant node deps first (cache layer)
RUN npm ci --ignore-scripts
COPY web/tailwind/ ./web/tailwind/
COPY web/src/ ./web/src/
RUN npm run build

# Stage 2: PHP dependency builder
FROM composer:2.8 AS php-builder
WORKDIR /app
COPY composer.json composer.lock ./
# Install without dev dependencies for production
RUN composer install \
    --no-dev \
    --no-interaction \
    --no-progress \
    --optimize-autoloader \
    --classmap-authoritative
COPY . .

# Stage 3: Minimal runtime image
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache \
    nginx \
    supervisor \
    && docker-php-ext-install pdo_mysql opcache
WORKDIR /var/www/html
# Copy only built artifacts, no build tools in final image
COPY --from=php-builder /app .
COPY --from=node-builder /build/pub/static ./pub/static
COPY docker/php.ini /usr/local/etc/php/conf.d/app.ini
COPY docker/supervisord.conf /etc/supervisord.conf
USER www-data
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

4. Using the layer cache effectively without invalidation traps

The Docker layer cache is the most important lever for short build times in the CI pipeline. Every layer in a release image is cached and reused as long as its inputs have not changed. The most common invalidation trap: COPY . . early in the Dockerfile. As soon as any file in the project directory changes, even a README, this layer and every subsequent layer is marked invalid and rebuilt. For composer install or npm ci, which can take minutes, that is a significant loss of performance.

The correct pattern: first copy only the dependency manifests (package.json, composer.lock), run the package manager, and only then copy the rest of the source code. When only application code changes, the dependency layer stays in the cache and is not rerun. With docker buildx build --cache-from type=registry,ref=registry.example.com/app:buildcache --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max the cache can be used in CI environments that have no local layer history.

BuildKit, on by default since Docker 23, also offers mount-based caches: RUN --mount=type=cache,target=/root/.npm npm ci uses a persistent cache mount that survives between builds. This is especially effective for package manager caches, which work independently of the layer cache and continue to work even when package.json has changed and the layer cache has been invalidated.

5. CI pipeline: build, tag and registry push

A CI pipeline for Docker release images follows a fixed flow: build the image, test it, tag it, and push it to the registry. The build step should use the full commit SHA as a tag, so the artifact can be traced back to an exact source code state. In addition, a semantic version tag is set for tagged commits. The latest tag is only set on the main branch and always points to the current, production-ready state.

After the push, the deployment pipeline can automatically deploy the new release image to a staging environment and run integration tests. Only if these tests succeed is the image released for production. This pattern, build once and deploy multiple times, ensures that the production deployment is identical to the tested staging deployment. No new build, no "might have changed since then".


# .github/workflows/release.yml: Docker release image CI pipeline
name: Build and Push Release Image

on:
  push:
    branches: [main]
    tags: ['v*.*.*']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=semver,pattern={{version}}
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push release image
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
          build-args: |
            BUILD_DATE=${{ github.event.head_commit.timestamp }}
            GIT_SHA=${{ github.sha }}

6. PHP and Node projects: concrete Dockerfile patterns

PHP projects have a particular quirk when building release images: Composer downloads dev dependencies that are not needed in the production image. With composer install --no-dev --optimize-autoloader in the builder stage, followed by copying the vendor/ directory into the runtime stage, you get a clean production image without test frameworks, development tools and debugging libraries. Optimizing the autoloader with --classmap-authoritative also removes the need to query the filesystem during autoloading.

Node.js projects follow a comparable pattern: in the builder stage all dependencies are installed and the build is run. In the runtime stage, only node_modules from a separate npm ci --omit=dev step and the compiled files from dist/ are copied in. For Next.js and similar frameworks there is the standalone output mode, which bundles every required file into a minimal directory and makes copying the full node_modules unnecessary.

Magento 2, as a complex PHP framework, has particular requirements for release images: setup:di:compile and setup:static-content:deploy must run as part of the build process, not on the production server. That means the builder stage needs access to a database connection or a stub configuration for the DI compiler. With ARG variables and a dedicated build network, this can be solved without baking real production credentials into the image.

7. Ensuring reproducibility: pinning and build args

A Docker release image is only as reproducible as its inputs. The base image tag php:8.4-fpm-alpine points to a mutable reference: a new patch version could be used with every build. For full reproducibility, the base image should be pinned to its SHA256 digest: FROM php:8.4-fpm-alpine@sha256:abc123.... Renovate Bot or Dependabot can update these pins automatically when new versions become available, without endangering the reproducibility of the current build.

Build args with ARG make it possible to embed mutable values such as the commit SHA or the build date into the image without invalidating the layer cache. These values are typically stored as Docker labels with LABEL, so docker inspect on a running container immediately reveals the exact source code state. org.opencontainers.image.revision, org.opencontainers.image.created and org.opencontainers.image.source are standardized OCI labels for this metadata.


# Reproducibility: pinned base image + OCI labels + build-args
# Pin to digest for full reproducibility (update via Renovate/Dependabot)
FROM php:8.4-fpm-alpine@sha256:1a2b3c4d5e6f AS runtime

# Build-time metadata injected by CI, does NOT invalidate dependency cache
ARG BUILD_DATE
ARG GIT_SHA
ARG GIT_REF

# OCI standard labels for traceability
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.source="https://github.com/mironsoft/app" \
      org.opencontainers.image.ref.name="${GIT_REF}"

# Inspect running container: docker inspect <id> | jq '.[0].Config.Labels'
# Shows exact git SHA and build timestamp for any running container

# Verify reproducibility locally
# docker buildx imagetools inspect registry.example.com/app:sha-abc123

8. Release image vs. build on server compared

The choice between Docker release images and a build-on-server strategy has far-reaching consequences for operations, security and deployment speed. The following table compares the most relevant dimensions.

Dimension Build on production server Docker release image Advantage
Reproducibility Depends on server state Identical artifact Same build everywhere
Rollback Revert commit + new build Restart the old image Seconds instead of minutes
Attack surface Build tools in production Only runtime in production Minimal attack surface
Failed build Server left in intermediate state Old image keeps running No production interruption
Deployment duration Blocked by compile time Only image pull + start Faster deployments

One argument against release images is the initial effort of setting up the CI pipeline and the Dockerfile. That effort pays for itself, however, at the latest with the first failed production build that leaves a server in an inconsistent state, and that will happen. Investing in a clean image build pipeline is an investment in deployments you can actually control.

9. Security: handling secrets safely in the build process

One of the most common security pitfalls with Docker release images is embedding secrets into the image. Any ENV or ARG directive with a secret value ends up in an image layer and is visible with docker history or by inspecting the layers, even if the value is overwritten in a later layer. This applies in particular to Composer tokens for private repositories and npm auth tokens for private packages.

BuildKit offers the solution with secret mounts: RUN --mount=type=secret,id=composer_auth cat /run/secrets/composer_auth > ~/.composer/auth.json && composer install && rm ~/.composer/auth.json. The secret is available inside the container for the duration of the build command, but it is not written into the layer. It is invoked with docker buildx build --secret id=composer_auth,src=./auth.json .. The secret never leaves the build machine and never ends up in the release image.

SSH agent forwarding for private Git repositories follows the same principle: RUN --mount=type=ssh git clone git@github.com:private/repo.git uses the host's SSH agent without writing the private key into the layer. In CI environments, the SSH agent is set up with ssh-agent and the corresponding action secret. This way, release images can access private dependencies without embedding credentials.

Mironsoft

Docker release images, CI/CD pipelines and deployment infrastructure

Want release images that run reliably in production?

We build multi-stage Dockerfiles, CI pipelines with registry integration, and deployment strategies for reproducible, secure release images, from the first stage all the way to automatic rollback.

Dockerfile audit

Multi-stage analysis, cache optimization and a security review of existing images

CI pipeline setup

Integrate build, test, tag and push into GitHub Actions or GitLab CI

Registry setup

Set up a private registry, retention policies and automatic rollback

10. Summary

Docker release images solve the fundamental problem of non-reproducible deployments by cleanly separating the build environment from the runtime environment. Multi-stage builds in Dockerfiles make it possible to use a fully equipped builder and produce a minimal runtime image, without build tools, dev dependencies or temporary files. The layer cache, structured correctly, makes CI builds fast and incremental. BuildKit secret mounts prevent credentials from being embedded in images.

The tagging strategy with SHA and semantic versions makes every release image traceable to an exact source code commit. Rollbacks take seconds, because the previous image remains available in the registry. Failed builds leave production untouched, because the old image keeps running. The initial effort of building clean multi-stage Dockerfiles and CI pipeline integration pays off with every deployment, and even more so with the first failed production build, which would otherwise have caused downtime.

Docker release images: the essentials at a glance

Multi-stage builds

A builder stage with every tool, a runtime stage with only the artifacts. Drastically reduce image size without losing functionality.

Layer cache

Copy dependency manifests first, then run the package manager, then copy application code last. Keep cache invalidation to the essentials.

Tagging strategy

SHA tag for traceability, version tag for semantics, latest only on the main branch. Rollback means restarting the old image.

Secret handling

BuildKit --mount=type=secret for Composer tokens and npm tokens. Secrets never end up in an image layer, not even in intermediate layers.

11. FAQ: Docker Release Images

1Release image vs. development image?
Release image: only runtime dependencies, immutable, for production. Development image: every tool, for local work. Never mix them up.
2Controlling base image updates?
Pin the SHA256 digest. Renovate Bot or Dependabot submit digest updates as PRs, controlled and testable.
3Why no build tools in production?
A compiler in production massively increases the attack surface. An attacker with container access can compile and run their own code.
4Optimal image size?
Under 200 MB is achievable for PHP/Node. Alpine base, --no-dev Composer, no temp files in the layer. Distroless images for maximum reduction.
5Testing multi-stage builds locally?
docker build --target runtime . builds only up to the runtime stage. Every stage is testable on its own.
6What happens on a failed CI build?
No deployment. Production keeps running unchanged. No inconsistent server state. Fix the build and start it again.
7Rollback with release images?
Set the old image tag, restart the container. Takes seconds. A retention policy ensures old releases stay available in the registry.
8Composer tokens safe in the build?
BuildKit --mount=type=secret. Token available only during RUN, no entry in the layer. docker buildx build --secret id=x,src=file .
9Use the latest tag in production?
Never. latest is mutable, the image could have changed between pull and start. Always deploy to a concrete SHA or version tag.
10Release images with Kubernetes?
Set the image tag in the manifest to the new SHA, commit it. ArgoCD or Flux deploy it automatically. A GitOps workflow for traceable deployments.