What is it? #
The build stage turns source code into the thing you deploy: a container image, a compiled bundle, a package.
The rule that matters most is build once, deploy many. One artefact is built, tested, and promoted through staging to production unchanged.
Rebuilding per environment breaks the guarantee that what you tested is what you shipped. Dependencies move, base images change, and the production build can differ from the one that passed.
The other two concerns are speed, which caching largely solves, and traceability — being able to map a running artefact back to an exact commit.
Think of it like this #
Manufacturing one item and shipping it to be inspected, then sold.
Making a second item for the customer, because the first went to inspection, means the inspected one is not the one delivered. Everything depends on it being the same object.
Simple example #
CI builds a container image tagged with the commit hash, caches layers between runs, embeds version metadata, and pushes it once. Staging and production both pull that exact tag.
Code #
build:
needs: [quality, test]
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.image }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history, for version derivation
- id: meta
run: |
VERSION=$(git describe --tags --always --dirty)
IMAGE=ghcr.io/<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>g</mi><mi>i</mi><mi>t</mi><mi>h</mi><mi>u</mi><mi>b</mi><mi mathvariant="normal">.</mi><mi>r</mi><mi>e</mi><mi>p</mi><mi>o</mi><mi>s</mi><mi>i</mi><mi>t</mi><mi>o</mi><mi>r</mi><mi>y</mi></mrow><mo>:</mo><mi>s</mi><mi>h</mi><mi>a</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">{{ github.repository }}:sha-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">u</span><span class="mord mathnormal">b</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">p</span><span class="mord mathnormal">os</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em;">or</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.7778em;vertical-align:-0.0833em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord">−</span></span></span></span>{{ github.sha }}
echo "version=<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>V</mi><mi>E</mi><mi>R</mi><mi>S</mi><mi>I</mi><mi>O</mi><mi>N</mi><mi mathvariant="normal">"</mi><mo>></mo><mo>></mo><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">VERSION" >> "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.7335em;vertical-align:-0.0391em;"></span><span class="mord mathnormal" style="margin-right:0.2222em;">V</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0278em;">O</span><span class="mord mathnormal" style="margin-right:0.109em;">N</span><span class="mord">"</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">>></span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord">"</span></span></span></span>GITHUB_OUTPUT"
echo "image=<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>I</mi><mi>M</mi><mi>A</mi><mi>G</mi><mi>E</mi><mi mathvariant="normal">"</mi><mo>></mo><mo>></mo><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">IMAGE" >> "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.7335em;vertical-align:-0.0391em;"></span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.109em;">M</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">GE</span><span class="mord">"</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">>></span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord">"</span></span></span></span>GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ steps.meta.outputs.image }}
ghcr.io/<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>g</mi><mi>i</mi><mi>t</mi><mi>h</mi><mi>u</mi><mi>b</mi><mi mathvariant="normal">.</mi><mi>r</mi><mi>e</mi><mi>p</mi><mi>o</mi><mi>s</mi><mi>i</mi><mi>t</mi><mi>o</mi><mi>r</mi><mi>y</mi></mrow><mo>:</mo></mrow><annotation encoding="application/x-tex">{{ github.repository }}:</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">u</span><span class="mord mathnormal">b</span><span class="mord">.</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">p</span><span class="mord mathnormal">os</span><span class="mord mathnormal">i</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em;">or</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span></span></span></span>{{ steps.meta.outputs.version }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # build attestation
# Embed traceability into the image
ARG VERSION=dev
ARG COMMIT=unknown
ENV APP_VERSION=<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>V</mi><mi>E</mi><mi>R</mi><mi>S</mi><mi>I</mi><mi>O</mi><mi>N</mi><mi>A</mi><mi>P</mi><msub><mi>P</mi><mi>C</mi></msub><mi>O</mi><mi>M</mi><mi>M</mi><mi>I</mi><mi>T</mi><mo>=</mo></mrow><annotation encoding="application/x-tex">VERSION APP_COMMIT=</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8333em;vertical-align:-0.15em;"></span><span class="mord mathnormal" style="margin-right:0.2222em;">V</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0278em;">O</span><span class="mord mathnormal" style="margin-right:0.109em;">N</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.1389em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0715em;">C</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mord mathnormal" style="margin-right:0.0278em;">O</span><span class="mord mathnormal" style="margin-right:0.109em;">M</span><span class="mord mathnormal" style="margin-right:0.109em;">M</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span></span></span></span>COMMIT
LABEL org.opencontainers.image.revision=$COMMIT \
org.opencontainers.image.version=$VERSION \
org.opencontainers.image.source=https://github.com/org/repo
# Expose it, so a running instance can say what it is
@app.get("/version")
def version():
return {
"version": os.environ.get("APP_VERSION", "dev"),
"commit": os.environ.get("APP_COMMIT", "unknown"),
}
Build once, deploy many
build (once)
│
┌────┴────┬──────────┐
▼ ▼ ▼
staging production rollback target
same image, same digest, different configuration only
Configuration comes from the environment at run time — never from
a separate build per environment.
Making builds fast
layer caching cache-from/cache-to; the single biggest factor
dependency caching package manager caches keyed on the lockfile
parallel jobs build independent artefacts simultaneously
do less skip the build entirely if nothing relevant changed
smaller context a good .dockerignore reduces upload time
How it works #
fetch-depth: 0 fetches the full git history so git describe can derive a version from tags. The default shallow clone has no tags to describe against.
The image is tagged twice: with the commit hash, which is immutable and unique, and with a human-readable version. Deployments use the hash; humans refer to the version.
Build arguments pass the version and commit into the image, where ENV makes them available at run time and LABEL records them as image metadata.
The /version endpoint is more useful than it looks. During an incident, asking a running instance what it is removes a whole category of uncertainty about what is actually deployed.
cache-from and cache-to with the GitHub Actions cache backend persist Docker layers between runs. Combined with a well-ordered Dockerfile, this turns most builds into a matter of seconds.
provenance: true generates an attestation describing how the image was built, which is part of the supply-chain security story.
The job's outputs make the image reference available to later jobs, so the deployment job deploys exactly what was built rather than reconstructing the name.
Promotion means deploying the same digest to each environment with different configuration. Nothing is rebuilt, so nothing can differ.
Real-world use #
Build once, deploy many is the practice that makes staging meaningful. Without it, a staging pass only tells you that a similar build worked.
Traceability matters during incidents. "Which commit is running in production" should be answerable in seconds, from the running instance itself.
Build times affect every developer. A ten-minute build that becomes ninety seconds through caching changes how often people push.
Reproducible builds — the same source producing a bit-identical artefact — are a stronger property and require pinned dependencies and controlled timestamps. Most teams settle for deterministic dependencies and traceable metadata.
Artefact retention needs a policy. Registries fill with images from every commit, and pruning old untagged ones is standard.
Common mistakes #
- Rebuilding for each environment, so production runs something untested.
- No layer caching, making every build slow.
- Tagging only with a moving tag, so the running version is unknown.
- Not embedding version metadata, making incident diagnosis harder.
- No retention policy, so registry storage grows without limit.
Practice #
Add a build job that produces a container image tagged with the commit hash, embeds version and commit metadata, and uses layer caching. Expose a /version endpoint and confirm a running container reports the exact commit it was built from.