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.
Table of Contents
- 1. The problem with builds on production servers
- 2. The concept: the release image as an immutable artifact
- 3. Multi-stage builds: separating builder and runtime
- 4. Using the layer cache effectively without invalidation traps
- 5. CI pipeline: build, tag and registry push
- 6. PHP and Node projects: concrete Dockerfile patterns
- 7. Ensuring reproducibility: pinning and build args
- 8. Release image vs. build on server compared
- 9. Security: handling secrets safely in the build process
- 10. Summary
- 11. FAQ
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.