Build Once, Deploy Many: Artifacts, Versioning and Immutable Packages
Building an artifact once and carrying that same artifact, by tag or digest, from staging to production guarantees that what was tested on staging is what reaches production; even building the same commit twice yields different images. Reproducible builds with lock files and npm ci, stamping versions with the commit SHA, the :latest trap, and a runtime config.json for Angular.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), Angular 22.2, Node.js 24, Docker 29, nginx 1.30, act 0.2.97 and actionlint 1.7.12.
Guess First
You publish the Bookstore API (Application Programming Interface) twice from the same commit, deleting the bin and obj folders in between. Will the two Bookstore.Api.dll files have the same SHA (Secure Hash Algorithm) 256-bit digest, SHA-256 for short? Then you build two images from the same publish output with docker build --no-cache. Will the image IDs be the same? Write down both answers; the fact that they come out differently is the main idea of this post.
What an Artifact Is and Where It Lives
An artifact is what the build stage of the pipeline produces and what gets deployed. Bookstore has four kinds:
- The
dotnet publishoutput: the API's runnable files, as a folder or a zip. - A NuGet package: if you distribute a library that other projects use (for example a shared
Bookstore.Contracts). - A Docker image: the publish output and the runtime in one package, Bookstore's real unit of deployment.
- The Angular
distfolder: static files, copied to a web server or put into an image of its own.
An artifact needs an address. For temporary files inside a pipeline, the CI (Continuous Integration) tool's own artifact storage is enough; in GitHub Actions these are kept for 90 days by default, and you can shorten that with the retention-days input of upload-artifact. Packages that will be deployed should live somewhere permanent: a registry for container images (GitHub Container Registry, Azure Container Registry, Harbor) and a package feed for NuGet and npm packages (GitHub Packages, Azure Artifacts, Nexus, Artifactory). Rollback requires reaching the previous version, so retention should be at least long enough to "go back to the previous release".
Same Commit, Two Different Packages
The real answer to the opening question:
$ dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
$ shasum -a 256 artifacts/api/Bookstore.Api.dll
31e4cc153cb47bdec85fcec57df6e271da3c988a93a623cf09514f9a9320c59a artifacts/api/Bookstore.Api.dll
$ rm -rf artifacts src/*/bin src/*/obj
$ dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
$ shasum -a 256 artifacts/api/Bookstore.Api.dll
31e4cc153cb47bdec85fcec57df6e271da3c988a93a623cf09514f9a9320c59a artifacts/api/Bookstore.Api.dll
$ docker build --no-cache -q -f src/Bookstore.Api/Dockerfile -t bookstore/api:rebuild1 .
sha256:8953f0dc3f06320b49852b4931e35a6a74ae7eaa7242ed253eb132f3ce5b401a
$ docker build --no-cache -q -f src/Bookstore.Api/Dockerfile -t bookstore/api:rebuild2 .
sha256:bbcc266e11feafec62d8a1bd171d660e2bbaeffa69a499cfe59d1275ec7f41be
The DLL (Dynamic Link Library) is byte for byte identical: in the .NET SDK the C# compiler runs deterministically by default and produces the same bytes from the same input. The images, however, differ. Same code, same base image, same files; but image layers also carry metadata such as creation time, and that changes with every build. The Dockerfile I used is as plain as it gets:
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY artifacts/api/ .
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]
In this example the difference is harmless metadata. In real life, rebuilding can change much more: the aspnet:10.0 tag points to a new image with every patch, a NuGet range such as Version="10.*" pulls a new package, the SDK on the runner may have moved one patch ahead. We saw a live example of that in the first post of the series: global.json said 10.0.402, but the runner in act installed 10.0.403 because of rollForward: latestFeature. If the package you tested on staging differs from the one that goes to production, the staging test tells you less about production.
Build Once, Deploy Many
The fix is simple: build once and carry the resulting artifact between environments by its name or digest. Bookstore's release workflow:
name: release
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.image.outputs.ref }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet publish src/Bookstore.Api -c Release -o artifacts/api -p:Version=1.4.0
- id: image
run: echo "ref=bookstore/api:${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- run: docker build -f src/Bookstore.Api/Dockerfile -t "${{ steps.image.outputs.ref }}" .
deploy-staging:
needs: build
runs-on: ubuntu-latest
steps:
- run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
deploy-production:
needs: [build, deploy-staging]
runs-on: ubuntu-latest
steps:
- run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
The build job exposes the image reference as an output; the deploy jobs use that reference and rebuild nothing. A real deploy step here would be the command that hands the image to Kubernetes or an application server; to show the concept I print the ID of the image each environment receives. I ran it with act, allowing the jobs to use the host's Docker:
[release/build] | #8 naming to docker.io/bookstore/api:ae67bbd done
[release/build] 🏁 Job succeeded
[release/deploy-staging] | sha256:14dbbe93691273ae42fb02acb6a8bdeb630d27bd1ce29500ba0f73eb090dd8b8
[release/deploy-staging] 🏁 Job succeeded
[release/deploy-production] | sha256:14dbbe93691273ae42fb02acb6a8bdeb630d27bd1ce29500ba0f73eb090dd8b8
[release/deploy-production] 🏁 Job succeeded
Both environments saw the same ID. What goes to production is exactly what was tried on staging.
In the first version of the workflow, deploy-production only said needs: deploy-staging. actionlint caught it immediately:
.github/workflows/release.yml:32:59: property "build" is not defined in object type {deploy-staging: {outputs: {}; result: string}} [expression]
|
32 | - run: docker image inspect --format '{{.Id}}' "${{ needs.build.outputs.image }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~
The needs context only carries the outputs of direct dependencies; build is an indirect dependency, so the expression evaluates to an empty string. In act the same mistake silently turned into the command docker image inspect --format '{{.Id}}' "" and a red job; in a real deploy step it would have been an attempt to deploy an empty image reference. Running workflow files through actionlint before committing them catches this class of error within minutes.
Reproducible Builds: Locking Versions
Building once guarantees the same package for every environment. But if you want to rebuild the same commit a month later (a hotfix branch, a security review), you still want the same inputs. That has three legs.
global.json for the SDK version. Setting rollForward to latestPatch or disable stops the runner from moving to another feature band on its own. How tightly you lock is a trade-off: a tight lock gives reproducibility but means you have to bump security patches by hand.
A lock file for NuGet dependencies. When you add <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> to Directory.Build.props, every project gets a packages.lock.json that also records the exact versions of transitive dependencies. In the pipeline, dotnet restore --locked-mode refuses a restore that does not match the lock file. When someone adds a package to a csproj and forgets to update the lock file:
$ dotnet restore --locked-mode
src/Bookstore.Api/Bookstore.Api.csproj : error NU1004: The package references have changed for net10.0. Lock file's package references: None, project's package references: Npgsql >= 10.0.3.The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. Disable the RestoreLockedMode MSBuild property or pass an explicit --force-evaluate option to run restore to update the lock file. [Bookstore.slnx]
Failed to restore src/Bookstore.Api/Bookstore.Api.csproj (in 27 ms).
On the npm side the equivalent is package-lock.json and npm ci instead of npm install. If the lock file and package.json disagree, npm ci stops without installing anything:
$ npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: date-fns@4.4.0 from lock file
Both errors look annoying, but they are exactly what you want: a dependency change arrives as a deliberate commit that goes through review, not silently inside the pipeline.
Stamping the Version
When you investigate a production bug, the first question is "which code is running?". The answer should be written on the artifact. The Bookstore API exposes a small endpoint for that:
app.MapGet("/api/info", (IHostEnvironment env) => new
{
Environment = env.EnvironmentName,
Version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
});
The pipeline passes the version with -p:Version=1.4.0 when it publishes. Running the same image in two containers, one as staging and one as production:
$ curl -s localhost:18101/api/info
{"environment":"Staging","version":"1.4.0+d3369d479c520bcde062728a03ac3a233768048b"}
$ curl -s localhost:18102/api/info
{"environment":"Production","version":"1.4.0+d3369d479c520bcde062728a03ac3a233768048b"}
I did not add the part after the +: when the repository is a Git repository, the .NET SDK appends the full commit SHA to InformationalVersion by itself. So the human-readable version and the exact commit sit on one line.
The same idea applies to image tags: tags derived from the commit SHA such as bookstore/api:ae67bbd, and bookstore/api:1.4.0 when a tag is pushed. I will cover SemVer (Semantic Versioning) rules, changelogs and release notes in the upcoming Versioning and Release Management series; deriving a version from a tag is covered in the Git & GitHub series post "Git Tags and Releases: Marking Versions, git describe and GitHub Releases".
Avoid the :latest tag. latest is not a version; it is a moving label that shows who pushed last. If production says image: bookstore/api:latest, applying the same manifest on two different days can run two different pieces of code, and the "previous version" you want to roll back to has no name.
Angular: A Runtime config.json Instead of Config Baked into the Build
Bookstore's web side is an SPA (Single Page Application). Angular's classic approach is to bake the API address into the code at build time with files like environment.prod.ts. That conflicts with the "build once" principle: staging and production need two separate builds, which means two separate artifacts. I covered those files in the Angular series post "Angular Build and Deploy: ng build, Environment Files, Budgets and SPA Routing"; here we look at the alternative. Let the application read its configuration from a JSON (JavaScript Object Notation) file when it starts:
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { RUNTIME_CONFIG, RuntimeConfig } from './app/runtime-config';
fetch('/config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) =>
bootstrapApplication(App, {
providers: [...appConfig.providers, { provide: RUNTIME_CONFIG, useValue: config }],
}),
)
.catch((err) => console.error(err));
The image contains a config.json; at deploy time each environment mounts its own file over it. I ran the same bookstore/web image in two containers with two different configs:
$ docker run -d -p 127.0.0.1:18111:80 -v "$PWD/deploy/staging/config.json":/usr/share/nginx/html/config.json:ro bookstore/web:d3369d4
$ docker run -d -p 127.0.0.1:18112:80 -v "$PWD/deploy/production/config.json":/usr/share/nginx/html/config.json:ro bookstore/web:d3369d4
$ curl -s localhost:18111/config.json
{
"apiUrl": "https://api.staging.bookstore.example",
"environment": "Staging"
}
$ curl -s localhost:18111/ | grep -o 'main-[A-Z0-9]*\.js'
main-S3Z6CNMD.js
$ curl -s localhost:18112/config.json
{
"apiUrl": "https://api.bookstore.example",
"environment": "Production"
}
$ curl -s localhost:18112/ | grep -o 'main-[A-Z0-9]*\.js'
main-S3Z6CNMD.js
Both environments serve the same main-S3Z6CNMD.js; since the hash in the file name is derived from the content, it is certainly the same JavaScript. The price is one extra HTTP (Hypertext Transfer Protocol) request when the application starts, and giving config.json its own cache rule so that it does not go stale in the browser cache.
Digests, Checksums and Provenance
A tag is a name; a digest is content. The bookstore/api:1.4.0 tag can be moved to another image in a registry; the sha256:14dbbe… digest cannot, because it is computed from the content itself. The strictest approach is to reference the image by digest in the deploy manifest. The same idea applies to artifacts inside a pipeline: upload-artifact writes the SHA-256 of the zip it uploaded to the log, and the downloading side can compare it.
One step further is provenance: proving with a signed document which commit, which pipeline and which inputs produced the artifact. The SLSA (Supply-chain Levels for Software Artifacts) framework splits this into levels; we will look at it briefly in the seventh post and in detail in the upcoming DevSecOps series. I covered the risks on the dependency side, and SRI (Subresource Integrity), in the Web Security (OWASP Top 10) series post "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit and SRI"; that item of the OWASP (Open Worldwide Application Security Project) list also explains why lock files matter.
In Production
Turn on tag immutability in your registry. Registries such as Amazon ECR (Elastic Container Registry), Azure Container Registry and Harbor can prevent pushing over an existing tag. If it is off, accidentally pushing a new image with the same version number means that what runs in production as "1.4.0" changes.
Write a retention policy. If every commit produces an image, the registry grows quickly. A rule like "keep the last 30 days of images and every tagged release" covers both cost and the need for rollback.
Update base images deliberately. The aspnet:10.0 tag is useful because it brings security patches automatically, but it also breaks reproducibility. Pinning the base image by digest and updating it through PRs (pull requests) with a tool like Dependabot or Renovate sits between the two worlds.
Common Pitfalls
A separate build per environment: a "staging build from the staging branch, production build from main" setup means that what was tested on staging is not what goes to production.
Configuration baked into the build: if the API address, feature flags and connection details live inside the artifact, every environment needs a new build. We will cover separating configuration from the artifact in detail in the fifth post.
Deploying with :latest and reusing tags: both leave the question "what is running?" unanswered.
npm install and restore without a lock file: versions drift silently in the pipeline and yesterday's green commit is red today. To find what changed, you end up comparing dependency trees instead of code.
Indirect needs outputs: as you saw above, a job only sees the outputs of the jobs it depends on directly.
A Small Experiment
★☆☆ Expose an endpoint like /api/info in your own API and confirm that InformationalVersion contains the commit SHA.
★★☆ Add RestorePackagesWithLockFile to your project, commit the lock files and switch the pipeline's restore to --locked-mode. Then add a package to a csproj and push without updating the lock file; at which step, and with which error, did the pipeline break?
★★★ Build two images from the same commit with docker build --no-cache and compare their IDs. Then try to get the same ID using the SOURCE_DATE_EPOCH environment variable and BuildKit's rewrite-timestamp option; note where you got stuck.
In the next post we move to the part that decides the artifact's quality: in what order to line up tests in the pipeline, quality gates, and the flaky tests that quietly eat the team's trust.