Reservalia's pipeline builds a perfectly correct container image on every pull request… and throws it in the bin when the runner dies. The piece that turns a verification into something you can deploy is missing: storing the result, giving it a name that means something and making it travel between environments without rebuilding it. This lesson revolves around a single principle — build once, deploy many times — and everything that follows from it: what an immutable artifact is, why rebuilding for each environment destroys traceability, what versioning strategies exist and when to use each one, why the latest tag is dangerous inside a pipeline, where artifacts are stored and what they cost, how the same reservalia/api:a3f9c21 gets promoted from dev to staging and to prod, and how to find out from a machine in production which exact commit it is running. What we will not see is how that artifact is deployed: that is the whole of module 3.
Contents
- Build once, deploy many times
- The immutable artifact and the rebuild-per-environment anti-pattern
- Versioning strategies
- Why
latestis dangerous - Automatic versioning from conventional commits
- Where artifacts are stored: registries and retention
- Promotion between environments
- Traceability: knowing what is running in production
- Artifact signature and provenance
- Reservalia's
publishjob - Common Mistakes and Tips
- Exercises
- Conclusion
- Build once, deploy many times
The principle. The pipeline builds a single artifact per commit. That artifact, unmodified and unrebuilt, is the one tested in
dev, the one validated instagingand the one that ends up serving requests inprod. The only thing that changes between environments is the configuration injected from outside.
It already came up in lesson 01-04, when we defined Reservalia's three environments. Now we can see why it matters so much: if you rebuild for each environment, what you test and what you deploy are different objects, and all the tests in the world stop guaranteeing anything about production.
The differences do not have to be dramatic to do damage. It is enough for a transitive dependency to have published a patch between the staging build (Tuesday) and the prod one (Thursday), or for the runner to have changed base image. Two builds of the same commit 48 hours apart may not be the same software.
- The immutable artifact and the rebuild-per-environment anti-pattern
An artifact is the deployable unit the pipeline produces: at Reservalia, the image reservalia/api:a3f9c21 and the bundle of static files from apps/web. For it to be immutable means that, once published under a tag, that content never changes. If something has to be fixed, a new artifact is published under a different tag.
flowchart LR
subgraph GOOD["Build once"]
C1["commit a3f9c21"] --> B1["build"] --> A1["reservalia/api:a3f9c21"]
A1 --> D1[dev] --> S1[staging] --> P1[prod]
end
subgraph BAD["Rebuild per environment"]
C2["commit a3f9c21"] --> B2["build dev"] --> D2[dev]
C2 --> B3["build staging"] --> S2[staging]
C2 --> B4["build prod"] --> P2["prod ← is it what you tested?"]
end
What you lose by rebuilding per environment:
| What is lost | Practical consequence |
|---|---|
| Traceability | "It works in staging and fails in prod" becomes unsolvable |
| Validity of the tests | You tested artifact A and deployed artifact B |
| Time | Three builds instead of one |
| Reliable rollback | Going back requires a rebuild, and it may no longer come out the same |
And the uncomfortable corollary: if the artifact is the same in all three environments, it cannot contain anything environment-specific. Not the database URL, not the payment gateway key, not the log level. All of that comes in through environment variables or a secrets manager at start-up time. An artifact built with NODE_ENV=staging baked in is not promotable.
- Versioning strategies
Naming the artifact is not cosmetic: it is what lets you talk about it unambiguously.
| Strategy | Example | Advantage | Drawback | When to use it |
|---|---|---|---|---|
| SemVer | 2.4.1 |
Communicates the impact of the change | Requires deciding the number | Libraries and public APIs |
| Commit SHA | a3f9c21 |
Unique and instantly traceable | Says nothing to a human | Continuously deployed services |
git describe |
v2.4.0-13-ga3f9c21 |
Readable and traceable | Requires tags and fetch-depth: 0 |
When you want both |
| Date-based | 2026.03.02.1 |
Sortable, intuitive | Does not identify the code | Periodic releases |
| Incremental | build-1284 |
Simple | Lost when you change tools | Legacy systems |
Reservalia uses the short SHA as its primary tag: reservalia/api:a3f9c21. The reason is that it is a SaaS platform with a single deployment — it does not distribute versions to customers — and what it needs is to answer the question "what code is running?" in one second. SemVer would solve a problem Reservalia does not have.
Nothing stops you combining strategies: the same image can carry several tags pointing at the same content.
reservalia/api:a3f9c21 # identity: the exact commit (never changes)
reservalia/api:v2.4.0 # human-readable release
reservalia/api:main # moving pointer to the latest green mainOnly the first is immutable; the other two are pointers that can be reassigned.
- Why
latest is dangerous
latest is dangerouslatest is not a version: it is a moving tag pointing at the last thing somebody pushed. Inside a pipeline it causes four specific problems:
- It is not reproducible.
docker pull reservalia/api:latesttoday and tomorrow can bring back different images. If a container restarts on its own, it may come up on a different version. - It breaks rollback. "Go back to the previous version" has no answer:
latestkeeps no history. - It makes diagnosis impossible. Two machines of the same service can be running different code and both report
latest. - It is a race. Two simultaneous pipelines write the same tag and the last one to finish wins, which need not be the most recent commit.
The rule: in a pipeline, every deployment references an immutable tag. If you want a friendly tag for your laptop, go ahead; for deploying, the SHA.
- Automatic versioning from conventional commits
The commit conventions from the previous lesson enable something very practical: deriving the next version by reading the history, with nobody deciding anything by hand.
| Commits since the last version | Bump | From 2.4.1 to |
|---|---|---|
Only fix:, chore:, docs: |
Patch | 2.4.2 |
At least one feat: |
Minor | 2.5.0 |
Any with BREAKING CHANGE: |
Major | 3.0.0 |
Tools such as semantic-release or Changesets do three things in a single step: they calculate the version, they generate the changelog by grouping the commits by type, and they create the git tag and the release. The resulting changelog writes itself:
## 2.5.0 (2026-03-02)
### Features
* **appointments:** allow weekly recurring bookings (a3f9c21)
### Fixes
* **schedule:** do not offer slots overlapping the break (b7e2d10)Reservalia adopts it in mixed mode and for a very specific reason: the SHA remains the artifact's identity — it is what gets deployed and what appears in /version — while the SemVer version and the changelog serve for communicating with people: release notes, notices to the businesses and the incident message when something breaks.
- Where artifacts are stored: registries and retention
Not all artifacts are alike, nor should they live in the same place.
| Destination | What it stores | Lifetime | Use at Reservalia |
|---|---|---|---|
actions/upload-artifact |
Files from the workflow itself | Days | Passing dist/ between jobs, coverage reports |
| Amazon ECR | Container images | Months or years | reservalia/api:a3f9c21, what gets deployed |
| GitHub Packages | Images and npm packages | Configurable | An alternative when everything lives on GitHub |
| S3 | Static files | Years | The dist/ of apps/web served by CloudFront |
The ephemeral workflow artifacts solve the problem we saw in 02-01: jobs do not share a disk. If build generates something publish needs, it has to be passed explicitly:
- uses: actions/upload-artifact@v4 # in the build job
with:
name: web-dist
path: apps/web/dist
retention-days: 7 # ← the default is 90
- uses: actions/download-artifact@v4 # in the job that consumes it
with: { name: web-dist, path: apps/web/dist }retention-days deserves attention because storage is billed. A 40 MB coverage report per run, with 20 runs a day and 90 days of retention, is about 72 GB of accumulated rubbish. A reasonable policy at Reservalia: 7 days for reports and intermediate artifacts; for the ECR images, a lifecycle rule that keeps the last 30 from main and deletes PR branch ones after 14 days. What is never deleted automatically is an image that is deployed in some environment.
- Promotion between environments
Promoting is declaring that an already-validated artifact moves on to the next environment. Nothing is rebuilt, nothing is recompiled: it is pointed at.
flowchart LR
M["merge into main<br/>commit a3f9c21"] --> B["build + publish"]
B --> ECR["ECR<br/>reservalia/api:a3f9c21"]
ECR --> D["dev<br/>automatic"]
D -- "smoke tests OK" --> S["staging<br/>automatic"]
S -- "validation + approval" --> P["prod"]
P --> T["tag prod-2026-03-02<br/>on the SAME image"]
Two ways of recording the progression, with different implications:
- Additional tags on the same digest. On promotion,
stagingorprod-2026-03-02is added to the existing image. It is visible from the registry itself and copies no bytes. - External metadata. A table or a versioned file stating which SHA is in each environment. It fits GitOps better and leaves an auditable history.
What both share, and this is the essential part: the content does not change. A digest — sha256:4f3c… — identifies the exact bytes, and that digest is the same in dev, in staging and in prod. Tags are names; the digest is the identity.
How each promotion is carried out (manual approvals, progressive deployment, rollback) is the content of module 3.
- Traceability: knowing what is running in production
It is 23:40, there is an incident and Nuria's first question is always the same: "what version is deployed?". It must be answerable in under a minute, from outside and without privileged access. Two complementary mechanisms.
OCI labels on the image. Standard metadata embedded in the image itself:
ARG COMMIT_SHA
ARG BUILD_DATE
LABEL org.opencontainers.image.revision="${COMMIT_SHA}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/reservalia/reservalia" \
org.opencontainers.image.version="2.5.0"They are queried without starting the container using docker inspect, and they survive even if somebody renames the tag.
A /version endpoint. The fastest way, and the one that requires no access to the infrastructure:
// apps/api/src/routes/version.ts
export const version = {
commit: process.env.COMMIT_SHA ?? 'unknown', // a3f9c21
version: process.env.APP_VERSION ?? '0.0.0', // 2.5.0
built: process.env.BUILD_DATE ?? 'unknown', // 2026-03-02T09:14:00Z
environment: process.env.ENVIRONMENT ?? 'unknown', // prod
};
// GET /version → { "commit": "a3f9c21", "version": "2.5.0", ... }The values are injected into the docker build as ARGs and become ENVs of the image. A security detail: /version must not reveal internal paths, dependencies or configuration; the SHA and the date are enough. And a practical detail: this endpoint is what lets you check that a deployment has taken effect. If after deploying b7e2d10 the /version still says a3f9c21, the deployment has not landed, however green the pipeline is.
- Artifact signature and provenance
An artifact being immutable does not prove who built it or from what. An attacker with access to the registry could publish an image under the expected tag.
Two mechanisms answer that: the artifact's cryptographic signature (with tools such as Sigstore/cosign, which let you verify before deploying that the image was signed by your pipeline) and provenance, a verifiable document declaring which commit, which workflow and which runner produced that digest, along the lines of the SLSA framework. Both also rest on an SBOM, the inventory of everything the image contains.
All three belong to the software supply chain and are covered in lesson 04-03, Security in CI/CD. Here it is enough to know that they exist and that the prior step — immutable artifacts identified by their digest — is already in place.
- Reservalia's
publish job
publish job publish:
name: Publish artifact
runs-on: ubuntu-22.04
needs: [quality, test, build] # 1
if: github.ref == 'refs/heads/main' # 2
permissions:
id-token: write # 3
contents: read
steps:
- uses: actions/checkout@v4
- name: Compute the tags
id: meta
run: | # 4
echo "short_sha=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
- name: Authenticate with AWS
uses: aws-actions/configure-aws-credentials@v4 # 5
with:
role-to-assume: ${{ secrets.AWS_ROLE_CI }}
aws-region: eu-west-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- name: Build and publish the image
uses: docker/build-push-action@v5
with:
context: .
file: apps/api/Dockerfile
push: true # 6
tags: |
${{ steps.ecr.outputs.registry }}/reservalia/api:${{ steps.meta.outputs.short_sha }}
${{ steps.ecr.outputs.registry }}/reservalia/api:main
build-args: |
COMMIT_SHA=${{ steps.meta.outputs.short_sha }}
BUILD_DATE=${{ steps.meta.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=maxneeds: [quality, test, build]makes this job wait for all three to be green. It is the guarantee that nothing is published unverified.if: github.ref == 'refs/heads/main': on pull requests we build to check, but we do not publish. Publishing every PR would fill the registry with images nobody will deploy.permissions: id-token: writeenables OIDC: the runner obtains temporary AWS credentials by assuming a role, instead of storing permanent access keys as secrets. It is the correct way of authenticating against the cloud, and it is detailed in 04-03.$GITHUB_OUTPUTis the mechanism for passing values between steps of the same job: what you write there is later read assteps.meta.outputs.short_sha.role-to-assumereferences theAWS_ROLE_CIsecret, which contains the role's ARN — not a credential.push: truewith two tags pointing at the same image: the short SHA, immutable and traceable, andmain, a convenient moving pointer for knowing which is the latest green one. What gets deployed is always the first.
With this job, Reservalia's state changes qualitatively: every commit that lands in main leaves behind an identified, verified, stored artifact, ready to deploy.
Common Mistakes and Tips
Mistake 1: rebuilding for each environment. The lesson's central anti-pattern. It breaks traceability, invalidates the tests and makes rollback irreproducible.
Mistake 2: deploying latest. It is not reproducible, it does not allow going back and it makes it impossible to know what is running. Always use an immutable tag.
Mistake 3: putting environment configuration inside the artifact. An artifact with the staging database URL baked in can no longer be promoted; it will have to be rebuilt, and we are back to mistake 1.
Mistake 4: publishing from pull requests. It fills the registry with images nobody will use and multiplies the storage cost. Publish only from main or from tags.
Mistake 5: not setting retention policies. The cost grows silently until somebody looks at the bill. Define retention from day one, with the exception of anything that is deployed.
Tip 1: make the version visible. A /version endpoint with the SHA is the best five-minute investment in the whole module; you will be grateful for it at 23:40 on a Tuesday.
Tip 2: think in digests, not in tags. Tags are reassignable names; the digest sha256:… is the real identity. Serious deployment systems reference digests.
Tip 3: automate the changelog from the commits. If you are already writing conventional commits, generating it is free and the team stops maintaining by hand a file nobody updates.
Exercises
Exercise 1
A team has three workflows: deploy-dev.yml, deploy-staging.yml and deploy-prod.yml. All three check out main, run npm ci, npm run build, build the image as app:latest and deploy it. List four problems and describe the correct pipeline.
Exercise 2
Reservalia deploys reservalia/api:a3f9c21 to prod. Two hours later a serious bug appears. Nuria wants to go back to the previous version, b7e2d10. Explain why this is trivial with immutable artifacts and what would have to be done if the team used latest.
Exercise 3
Design the image tagging scheme for a product that is distributed to customers, with monthly SemVer releases and urgent fixes in between. State which tags you would apply, which are immutable and which you would use for deploying.
Solutions
Solution 1. Four problems: (1) it builds three times, so what is deployed to prod is not what was tested in staging — two builds days apart may differ; (2) latest makes it impossible to know what is running and makes rollback impossible; (3) checking out main on each deployment means deploying whatever is there now on the branch, not the validated commit, so an intervening merge slips into production by accident; (4) build time and cost are tripled with no additional information gained.
The correct pipeline: a single CI workflow that, when a commit lands in main, builds and publishes reservalia/api:<sha> after passing quality, test and build. The three deployments are separate workflows that receive the SHA as a parameter and limit themselves to pointing the environment at that already-existing image, promoting the same digest from dev to staging and to prod.
Solution 2. With immutable artifacts, reservalia/api:b7e2d10 still exists in ECR unmodified: the rollback consists of telling the service to use that tag and waiting for the new instances to come up. It is minutes, it requires nothing to be compiled and the result is exactly the software that was running before. It is precisely the case Nuria's line in module 1 was calling for, about undoing a deployment in five minutes.
With latest, by contrast, there is nothing to go back to: the tag points at the broken version and the previous one is not identified. You would have to work out which commit was the good one (with no reliable record, probably by eyeballing the git history), rebuild it — with the risk that the new build is not identical to the one that worked — and publish again. Instead of minutes, a good half hour in the middle of an incident, and with uncertainty about the outcome.
Solution 3. A reasonable scheme:
| Tag | Immutable? | What it is for |
|---|---|---|
app:2.5.0 |
Yes | The release communicated to the customer |
app:a3f9c21 |
Yes | The commit's exact identity; for diagnosis |
app:2.5 |
No | Pointer to the latest patch of that minor |
app:2 |
No | Pointer to the latest minor of that major |
app:latest |
No | Convenience for local testing; never for deploying |
For deploying, always use an immutable tag, preferably the SHA or, better still, the digest sha256:…. The moving tags 2.5 and 2 are a service to customers who want to receive patches automatically, and that is their decision, not yours. An urgent fix on top of 2.5.0 is published as 2.5.1 and reassigns the 2.5, 2 and latest pointers, without ever touching the previous immutable tags.
Conclusion
Reservalia now produces something that can be deployed, and it knows exactly what it is:
- Build once, deploy many times. One artifact per commit, the same one in
dev,stagingandprod; the only thing that changes between environments is the configuration injected from outside. Rebuilding per environment breaks traceability and cancels the value of the tests. - An immutable artifact never changes once published. If something has to be fixed, another one is published under another tag.
- Of the versioning strategies, Reservalia uses the short SHA as identity — what a continuously deployed SaaS needs — and reserves SemVer for communicating with people.
latestis never deployed: it is not reproducible, it prevents rollback and it is a race between pipelines. - Conventional commits allow the version to be derived and the changelog generated automatically, with nobody maintaining a file by hand.
- Artifacts live in different places depending on their nature — ephemeral workflow ones for passing files between jobs, ECR for the deployable images, S3 for the web app — and all of them need a retention policy, because storage is billed.
- Promoting is pointing, not rebuilding: the same digest moves from environment to environment, recorded with additional tags or with external metadata.
- Traceability is solved with OCI labels on the image and a
/versionendpoint that returns the commit; it is also how you check that a deployment has taken effect. - The artifact's signature and provenance exist and are important; their development is lesson 04-03.
- The
publishjob depends onquality,testandbuild, only runs onmain, authenticates with AWS via OIDC and pushes to ECR the image tagged with the SHA and withmain.
We now have the four jobs and a published artifact. What is missing is the piece that connects all of this with how the team actually works. In the next lesson, Integration with Version Control, we will look at how the branching model shapes CI — comparing trunk-based development, GitHub Flow and Git Flow — at why long-lived branches are incompatible with continuous integration, at how main's protection rules, CODEOWNERS and the merge queue are configured, at how to avoid useless runs with paths and concurrency, and at what effect each merge strategy has on the artifact's traceability and on the lead time from 01-05. And we will close the module with Reservalia's complete ci.yml.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
