Build Once, Deploy Many: Moving One Docker Image Through Every Stage
AI generated
FROM
RUN
Docker · CI/CD · Container Workflows
Build once, deploy many
moving one Docker image through every stage

Anyone who builds a separate Docker image for every environment loses the exact guarantee containers are supposed to provide. Build once, deploy many separates the build step from configuration and ensures that the artifact running in production is exactly the one that was tested in staging, without a single rebuild in between.

18 min read Immutable images · environment variables · Twelve Factor Docker · GitLab CI · GitHub Actions

1. Why build once, deploy many is more than a slogan

The principle build once, deploy many sounds self evident at first, yet it gets violated constantly in practice. The idea: a Docker image is built exactly once from source code, and afterward that same, unchanged artifact travels through every stage of the pipeline, from development through staging to production. It is never recompiled just because the target environment changes. That single property is the real value containers add over classic deployment approaches with configuration management tools that run their own build steps on every server.

The underlying idea of build once, deploy many traces directly back to the Twelve Factor App methodology, in particular the principle of strictly separating the build and run stages. A team that applies this pattern consistently can say with high confidence that a container successfully tested in staging behaves identically in production, because it is literally the same bytes. That drastically reduces the class of bugs that otherwise arise from diverging build environments, different compiler versions, or forgotten dependencies.

In practice, build once, deploy many also means a cultural shift for the team. Developers have to accept that environment differences may no longer be encoded inside the image itself, and must flow exclusively through external configuration. That forces a cleaner architecture from the start, one where the application reads its runtime parameters from the environment instead of baking them in at build time. The following sections show concretely how this pattern is implemented with Docker, Dockerfiles and CI pipelines.

2. The anti pattern: building one image per environment

The opposite of build once, deploy many is an approach found in many grown projects: every environment gets its own build run, often with its own Dockerfile or its own build arguments that bake environment variables into the image at build time. The result is three or four different images, one for dev, one for staging, one for production, all originating from the same source code but technically distinct artifacts. A bug that only surfaces in production can then no longer be traced back with certainty to the image that was tested in staging.

A typical symptom of this anti pattern is build arguments like --build-arg APP_ENV=production, used to copy different configuration files or set different compiler flags inside the Dockerfile. That may look pragmatic, but it violates the core idea of build once, deploy many: as soon as a build argument changes the content of the resulting image, it is no longer a single, uniform artifact but one of several variants that were never tested together. Exactly these variants are the source of many production incidents later described as "it worked in staging".


# ANTI-PATTERN: environment baked in at build time
docker build --build-arg APP_ENV=staging -t myapp:staging .
docker build --build-arg APP_ENV=production -t myapp:production .
# Two different images from the same source -- never tested together

# PATTERN: build once, tag with a stable, traceable version
docker build -t myapp:1.4.2 .
docker tag myapp:1.4.2 registry.example.com/myapp:1.4.2
docker push registry.example.com/myapp:1.4.2

# The same image ID runs everywhere -- only the environment differs
docker run --env-file staging.env registry.example.com/myapp:1.4.2
docker run --env-file production.env registry.example.com/myapp:1.4.2

3. A Dockerfile without baked in environment logic

For build once, deploy many to work at all, the Dockerfile has to be written so that no environment decision is made at build time. Concretely that means no ARG directives that copy configuration values such as API endpoints, database hosts or feature flags into the image. Instead, the image contains only code, dependencies and executable artifacts that stay identical in every environment. The image does not know whether it is about to start in dev or in production, and that is exactly the desired property.

For PHP and Node projects, the build once, deploy many pattern means in practice: Composer and npm dependencies are installed finally during the build step, compiled assets ship fully built inside the image, but configuration files such as .env are explicitly never copied. Multi stage builds help separate the build container from the lean runtime stage, so that only the finished, environment agnostic artifact ends up in the final image.


# Multi-stage Dockerfile with zero environment-specific logic
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist

FROM php:8.4-fpm AS runtime
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY . .
# No .env, no environment-specific config copied here
RUN chown -R www-data:www-data /var/www/html
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

4. Configuration at runtime instead of at build time

If the image itself contains no environment information, configuration must necessarily be injected at runtime. That is the core of build once, deploy many: environment variables, mounted config files, or secrets providers supply everything the application needs to behave correctly at the moment the container starts. The container itself remains completely unchanged, whether it is started in dev, staging or production.

In practice, for a Magento or any other PHP project, that means database credentials, cache backend addresses, feature flags and external API keys are set via docker run --env-file, via Kubernetes ConfigMaps and Secrets, or through a Vault client at runtime. What matters for build once, deploy many is that these values are never written into the image itself, they only ever exist as external input that differs per environment without touching the image.

5. Entrypoint scripts as the bridge to the environment

A clean entrypoint script is the technical bridge that makes build once, deploy many practical in everyday container operations. Instead of anchoring configuration inside the image, the entrypoint script reads environment variables at container start, validates required values, and generates configuration files if needed, such as an Nginx configuration or a PHP ini with environment specific limits. The image stays identical, only the behavior at startup adapts to the respective environment.

A common pattern with build once, deploy many is fail fast validation inside the entrypoint: if a required variable such as DATABASE_URL is missing, the container aborts immediately with a clear error message instead of continuing in a half functional state. That prevents a misconfigured container from silently accepting traffic in production and only surfacing cryptic errors later.


#!/usr/bin/env bash
# docker-entrypoint.sh -- reads runtime config, same image everywhere
set -euo pipefail

: "${DATABASE_URL:?DATABASE_URL is required}"
: "${APP_ENV:?APP_ENV is required (dev, staging, production)}"
CACHE_BACKEND="${CACHE_BACKEND:-redis}"

echo "[entrypoint] Starting in environment: ${APP_ENV}"
echo "[entrypoint] Cache backend: ${CACHE_BACKEND}"

# Render environment-specific config from a template at runtime,
# never at build time -- the image itself stays unchanged
envsubst < /etc/nginx/templates/default.conf.template \
  > /etc/nginx/conf.d/default.conf

exec "$@"

6. The pipeline: build once, deploy multiple times

The CI/CD pipeline must structurally reflect build once, deploy many: a single build job produces the image and pushes it to the registry with a unique, immutable tag reference, usually the Git commit hash or a semver version. Every subsequent deploy job for staging and production references exactly that one tag, without rebuilding it. Only once a deploy has been validated successfully in staging is the same tag promoted to production.

In GitLab CI or GitHub Actions, build once, deploy many can be mapped with a single build stage and several downstream deploy stages that reference the same image tag via pipeline variables. It is important that no deploy job ever contains its own docker build call, otherwise the anti pattern sneaks back in through the back door.


# .gitlab-ci.yml -- build once, reference the same tag in every stage
stages: [build, deploy-staging, deploy-production]

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

deploy-staging:
  stage: deploy-staging
  script:
    # Same tag, no rebuild -- only runtime config differs
    - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n staging

deploy-production:
  stage: deploy-production
  when: manual
  script:
    - kubectl set image deployment/app app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA -n production

7. Cleanly separating secrets from environment specific values

A point often underestimated with build once, deploy many is the separation between non critical configuration and actual secrets. Both flow into the container the same way, via an environment variable or a mounted file, but should be managed differently. API keys, database passwords and certificates belong in a secrets manager such as Vault, AWS Secrets Manager or Kubernetes Secrets, never in a plaintext file inside the Git repository.

For build once, deploy many this produces a clear rule: the image itself must never contain secrets, not even temporarily during the build. Multi stage builds with BuildKit secrets (RUN --mount=type=secret) allow access to a secret during the build, for instance for a private Composer registry, without that secret ending up in any layer of the final image.

8. Common mistakes when adopting build once, deploy many

The most common mistake when introducing build once, deploy many is removing configuration values only partially from the image. A team might remove database credentials from the Dockerfile, for example, but leave a hardcoded API endpoint for an external payment provider in the code, because it "never changes anyway". Exactly such assumptions break later, when a staging environment suddenly needs to test against a sandbox endpoint and the image would have to be rebuilt for that.


# WRONG: partial migration -- config still baked in
FROM php:8.4-fpm
ENV PAYMENT_API_URL=https://api.payment-provider.com/v1  # hardcoded!
COPY . /var/www/html

# RIGHT: no environment value in the image at all
FROM php:8.4-fpm
COPY . /var/www/html
# PAYMENT_API_URL is supplied at `docker run` / deploy time only

A second widespread mistake is accidentally changing the image tag while promoting from staging to production, for example because a deploy script references :latest out of habit instead of the concrete version. That no longer guarantees that production runs the same artifact tested in staging. Fixed, immutable tags per build are not a recommendation under build once, deploy many, they are a hard requirement.

9. Build strategies compared side by side

The following table contrasts the classic approach with environment specific builds against the consistent build once, deploy many pattern, together with the practical consequences for testability and traceability.

Aspect One image per environment Build once, deploy many Consequence
Builds per release 3 to 4 separate builds Exactly 1 build Less CI time, one artifact to verify
Staging equals production? Not guaranteed Bit for bit guaranteed Fewer "worked in staging, broke in prod"
Configuration source Build arguments in the Dockerfile Environment variables at runtime Configuration changeable without rebuild
Rollback Requires a new build Redeploy the old tag Rollback in seconds instead of minutes
Traceability Which image ran where? One tag, one commit, one artifact Auditable down to the exact commit

In practice, the benefit of build once, deploy many shows up particularly during incident analysis: when staging and production are guaranteed to run the same artifact, the time consuming first question after an outage, whether the same version even ran, disappears. That time saved during stressful situations usually justifies the initial migration effort within a few weeks.

Mironsoft

Docker pipelines, deployment architecture and container workflows

One image, every environment: implemented cleanly?

We analyze existing Docker pipelines, remove environment specific build logic, and set up build once, deploy many so that staging and production are guaranteed to run the exact same artifact.

Pipeline audit

Checking existing builds for environment specific logic and build arguments

Dockerfile refactoring

Setting up multi stage builds without baked in environment values

Secrets management

Setting up runtime configuration and secrets cleanly separated

10. Summary

Build once, deploy many is not a nice to have, it is the precondition for Docker to keep its central promise: what was tested also runs in production, unchanged down to the last byte. The Dockerfile contains no environment decisions, configuration flows exclusively at runtime through environment variables, mounted files or secrets managers. An entrypoint script handles validation and adaptation at container start, without altering the image itself.

The CI pipeline builds the image exactly once, tags it with an immutable tag, and every subsequent deploy step references exactly that tag without rebuilding. Anyone who applies this pattern consistently gains faster rollbacks, simpler incident analysis, and the certainty that an artifact validated in staging behaves identically in production.

Build once, deploy many — the essentials at a glance

One image, one tag

Exactly one build per release, referenced by commit hash or semver, never by :latest.

Configuration at runtime

Environment variables, ConfigMaps and secrets supply all environment specific values only at startup.

Entrypoint validates

Fail fast on missing required variables, instead of continuing in a half functional state.

Rollback in seconds

An old, unchanged tag can be redeployed immediately, without a new build.

11. FAQ: Build Once, Deploy Many with Docker

1What does build once, deploy many actually mean?
An image is built exactly once and deployed unchanged through every environment, with no rebuild per stage.
2Why is one image per environment problematic?
It produces different artifacts that were never tested together. Staging results cannot reliably be carried over.
3How does configuration reach the container without a rebuild?
Through environment variables, mounted files, ConfigMaps or a secrets manager, all at runtime.
4What role does the entrypoint script play?
It validates required values and generates configuration at runtime, without changing the image.
5How should the image tag be chosen?
Immutable and unique, such as a commit hash or semver, never :latest.
6Are build arguments allowed?
Only without content specific environment changes, for example version labels. Configuration values do not belong in build arguments.
7How are secrets managed?
Separately, through a secrets manager, never in the image itself, not even temporarily, thanks to BuildKit secrets.
8How does a rollback work?
The old tag is redeployed, without a new build, usually within seconds to minutes.
9Does the pattern fit multi stage Dockerfiles?
Yes, even recommended: build stage compiles, runtime stage contains only the finished, environment agnostic artifact.
10What is the most common mistake when adopting this?
A partial migration where individual values remain hardcoded in the Dockerfile or code.