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

  1. Build once, deploy many times
  2. The immutable artifact and the rebuild-per-environment anti-pattern
  3. Versioning strategies
  4. Why latest is dangerous
  5. Automatic versioning from conventional commits
  6. Where artifacts are stored: registries and retention
  7. Promotion between environments
  8. Traceability: knowing what is running in production
  9. Artifact signature and provenance
  10. Reservalia's publish job
  11. Common Mistakes and Tips
  12. Exercises
  13. Conclusion

  1. 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 in staging and the one that ends up serving requests in prod. 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.

  1. 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.

  1. 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 main

Only the first is immutable; the other two are pointers that can be reassigned.

  1. Why latest is dangerous

latest is not a version: it is a moving tag pointing at the last thing somebody pushed. Inside a pipeline it causes four specific problems:

  1. It is not reproducible. docker pull reservalia/api:latest today and tomorrow can bring back different images. If a container restarts on its own, it may come up on a different version.
  2. It breaks rollback. "Go back to the previous version" has no answer: latest keeps no history.
  3. It makes diagnosis impossible. Two machines of the same service can be running different code and both report latest.
  4. 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.

  1. 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.

  1. 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.

  1. 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, staging or prod-2026-03-02 is 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.

  1. 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.

  1. 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.

  1. Reservalia's 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=max
  1. needs: [quality, test, build] makes this job wait for all three to be green. It is the guarantee that nothing is published unverified.
  2. 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.
  3. permissions: id-token: write enables 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.
  4. $GITHUB_OUTPUT is the mechanism for passing values between steps of the same job: what you write there is later read as steps.meta.outputs.short_sha.
  5. role-to-assume references the AWS_ROLE_CI secret, which contains the role's ARN — not a credential.
  6. push: true with two tags pointing at the same image: the short SHA, immutable and traceable, and main, 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, staging and prod; 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. latest is 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 /version endpoint 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 publish job depends on quality, test and build, only runs on main, authenticates with AWS via OIDC and pushes to ECR the image tagged with the SHA and with main.

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

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved