Reservalia's pipeline already runs the tests on every pull request, but it still does not build anything: it works on the source code and stops there. The piece that turns a repository into something executable is missing. In this lesson we will look at what "building" means exactly, why it must be a single reproducible command and not a sequence of steps somebody remembers, and how you orchestrate the build of a monorepo where one package depends on another. Then we will tackle the module's central concept — repeatability: why npm ci is not the same as npm install, what role the lockfile plays and what things stop a build from being reproducible. We will finish by packaging apps/api into a multi-stage Dockerfile explained stage by stage, and adding the build job to ci.yml. What we will not do here is publish or version that artifact: that is lesson 02-06.

Contents

  1. What "building" really is
  2. Reservalia's build, package by package
  3. Repeatability: the lockfile and npm ci
  4. What stops a build from being reproducible
  5. Packaging with Docker: the multi-stage Dockerfile
  6. .dockerignore: what must not go in
  7. Build caches and the danger of invalidating them badly
  8. The build job in Reservalia's ci.yml
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. What "building" really is

Building is transforming source code into the artifact that will run, with no human intervention. Depending on the technology, that transformation includes compiling, transpiling, bundling, minifying, generating code or creating a container image.

What defines a good build is not what it does, but four properties:

Property What it means How to check it
A single command npm run build and nothing else Does anybody have to run something "first"?
Reproducible The same code produces the same result Build it twice and compare
Stateless It does not depend on leftovers from previous builds rm -rf node_modules dist && npm ci && npm run build
Self-verifying It fails loudly, with an exit code ≠ 0 Break something on purpose and see whether it turns red

The first is the one most often violated. Reservalia starts from a real case: Diego builds the API by running npm install && npm run build, but beforehand he copies a .env file by hand that only exists on his laptop, and if he has touched packages/shared-types he has to build that package first and remember to do so. That is not a command: it is an oral procedure. And an oral procedure cannot be automated until somebody writes it down.

The rule of thumb. If you need more than one sentence to explain to somebody how to build the project, the next step is not writing the pipeline: it is fixing the build command.

  1. Reservalia's build, package by package

The monorepo has three packages and one real dependency between them: both the API and the web app import types from @reservalia/shared-types.

flowchart LR
    T["packages/shared-types<br/>tsc → dist/"] --> A["apps/api<br/>tsc → dist/"]
    T --> W["apps/web<br/>vite build → dist/"]

This means order matters: if apps/api compiles before shared-types, TypeScript will not find the declarations and will fail. The three commands, one per package:

npm run build --workspace packages/shared-types   # generates JS + .d.ts files
npm run build --workspace apps/api                # tsc → apps/api/dist/
npm run build --workspace apps/web                # vite build → apps/web/dist/

Each one does something different:

  • tsc (shared package and API) transpiles TypeScript into JavaScript that Node can run and generates the .d.ts files. It does not bundle or minify: the result keeps the folder structure of src/.
  • vite build (web) does far more: it resolves every import, bundles the code into a handful of files, removes what nobody uses, minifies, processes the CSS and adds a hash to each file name (index-4a7f2b.js) so that the browser can cache them indefinitely.

At the monorepo root, lesson 01-04 already defined the single command: "build": "npm run build --workspaces --if-present". The --workspaces option walks every package and npm resolves the topological order: since apps/api declares "@reservalia/shared-types": "*" in its dependencies, npm builds the depended-on package first. This is an excellent reason to declare internal dependencies properly: the package manager can work out the order and you do not have to write it down.

In tools from other ecosystems the idea is identical: Maven, Gradle, Nx or Turborepo resolve the same graph. What you must never do is write the order by hand in the pipeline's YAML, because the day somebody adds a package, the pipeline will not notice.

  1. Repeatability: the lockfile and npm ci

This section is the heart of the lesson. Reproducible means: the same commit, built today on your laptop and in six months' time on a clean runner, produces the same artifact.

3.1. The role of the lockfile

In package.json you declare intentions ("express": "4.19.2" or, worse, "^4.19.2"). In package-lock.json the exact result of resolving those intentions is recorded: the specific version of every one of the hundreds of transitive dependencies, its URL and its integrity hash.

"node_modules/express": {
  "version": "4.19.2",
  "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
  "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZ..."
}

The integrity field is what makes the lockfile a security control as well: if the downloaded package does not match that hash, the installation fails. That is why the lockfile is versioned in the repository, always, and in Reservalia there is a single one for the whole monorepo.

3.2. npm ci versus npm install

npm install npm ci
What it uses package.json, with the lockfile as a suggestion Only the lockfile
If there is a discrepancy Modifies the lockfile silently Fails with an explicit error
Existing node_modules Updates it incrementally Deletes it and installs from scratch
Determinism Not guaranteed Guaranteed
Speed in CI Lower Higher (it does not resolve the tree)
Correct use On your laptop, when adding a dependency In the pipeline, always

The case that illustrates the difference: somebody adds "lodash": "^4.17.20" to package.json and does not update the lockfile. With npm install, the pipeline installs whatever it finds and rewrites the lockfile inside the runner, so the build passes; and since the runner is ephemeral, that modified lockfile disappears without anybody noticing. Every run could install a different version. With npm ci, the pipeline stops with a clear message — npm ci can only install packages when your package.json and package-lock.json are in sync — and that error is not an annoyance: it is the pipeline doing exactly its job.

  1. What stops a build from being reproducible

The usual enemies, and how to neutralise them:

Enemy Typical symptom Antidote
Version ranges (^, ~) with no lockfile applied "It worked yesterday and today it does not, with nothing touched" Exact versions + npm ci
Unpinned runtime Works with Node 20 and fails with Node 22 .nvmrc + engines + node-version-file
Floating base image (node:20, postgres:latest) The build changes when the provider publishes Pin the full version
Unversioned files (local .env, certificates) "It works on my machine" Everything into the repository, or injected as variables
Downloads from the internet during the build Fails when the URL changes or goes down Declared dependencies, not hand-downloaded ones
Leftovers from previous builds Passes on the second attempt but not the first Clean dist/ before building
Dependence on the date or the machine The binary artifact changes between runs Fixed timestamps if you need bit-for-bit equality

The definitive repeatability test fits in four lines and is worth running now and then:

git clone https://github.com/reservalia/reservalia.git /tmp/clean && cd /tmp/clean
git checkout a3f9c21           # the exact commit
npm ci                         # installation from the lockfile
npm run build                  # does it build with no manual step?

If this fails, the pipeline will fail. If it works, the pipeline will be boring, which is exactly what we are after.

  1. Packaging with Docker: the multi-stage Dockerfile

apps/api/dist/ is loose JavaScript: to run it you need Node at the right version and the production dependencies. A container image packages all three together and wipes out the difference between environments in one stroke.

The problem is that building requires TypeScript, and running does not. Putting everything into the final image gives you an enormous image with more attack surface than necessary. The solution is a multi-stage build.

# apps/api/Dockerfile

# ───────── STAGE 1: full dependencies ─────────
FROM node:20.11.0-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json               apps/api/
COPY packages/shared-types/package.json  packages/shared-types/
RUN npm ci

# ───────── STAGE 2: build ─────────
FROM node:20.11.0-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build --workspace packages/shared-types \
 && npm run build --workspace apps/api

# ───────── STAGE 3: production dependencies ─────────
FROM node:20.11.0-bookworm-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json               apps/api/
COPY packages/shared-types/package.json  packages/shared-types/
RUN npm ci --omit=dev

# ───────── STAGE 4: final image ─────────
FROM node:20.11.0-bookworm-slim AS runtime
ENV NODE_ENV=production TZ=Europe/Madrid
WORKDIR /app
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build     --chown=node:node /app/apps/api/dist ./dist
COPY --from=build     --chown=node:node /app/packages/shared-types/dist ./node_modules/@reservalia/shared-types/dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Stage by stage:

Stage 1 (deps). It copies only the package.json files and the lockfile, not the code, and runs npm ci. This ordering is deliberate and is Docker's most profitable caching trick: as long as the dependencies do not change, this layer is reused and you save a full npm ci on every build. If you copied the code first, any change to a .ts would invalidate the installation.

Stage 2 (build). It reuses the previous stage's node_modules with COPY --from=deps, now copies all the code and builds in the correct order: the shared package first, then the API.

Stage 3 (prod-deps). It installs again, but with --omit=dev: no TypeScript, no Vitest, no ESLint. It greatly reduces the final size and, above all, keeps dozens of packages that should never be there out of the production container. Stage 4 (runtime). The only one that ends up in the registry. It contains only Node, the production dependencies and the compiled dist/. Four important decisions:

  • node:20.11.0-bookworm-slim: a fully pinned version — the same as the .nvmrc — and the slim variant, far lighter than the default image. Never node:20 or node:latest.
  • USER node: the process does not run as root. The official Node images already ship with that user created. It is one line that genuinely reduces the impact of a vulnerability: if somebody manages to run code inside the container, they do so without privileges.
  • --chown=node:node on each COPY: the files belong to the user that is going to use them, not to root.
  • ENV NODE_ENV=production: many libraries, Express among them, change their behaviour based on this variable (fewer logs, more caching, error messages without internal traces).

  1. .dockerignore: what must not go in

The COPY . . in stage 2 copies the entire build context. With no filter, that includes local node_modules (from another architecture), the git history and, in the worst case, a .env with credentials. The .dockerignore file goes at the repository root:

node_modules          .git             .env
**/node_modules       .github          .env.*
**/dist               *.log            infra/
**/coverage           docker-compose.yml

Three reasons, in order of importance. Security: a .env copied into a layer stays inside the image forever even if a later instruction deletes it, because layers are immutable and anyone with the image can read them. Correctness: copying node_modules from a macOS machine into a Linux image drags in incompatible native binaries. Speed: a smaller context transfers faster and stops an irrelevant file from invalidating layers.

  1. Build caches and the danger of invalidating them badly

There are two distinct caches in play. The dependency cache is the one we enabled in 02-02 with cache: npm: it stores the npm downloads between runs, with a key derived from the hash of package-lock.json; if the lockfile does not change, the key matches and nothing has to be downloaded from the network. The Docker layer cache works per instruction: each line of the Dockerfile produces a layer identified by its content, and if the instruction and its inputs have not changed, Docker reuses it. Hence the golden rule of ordering the Dockerfile from what changes least to what changes most, which is exactly what our stage 1 does.

Now the important part: a badly invalidated cache is worse than no cache at all.

Imagine a fixed cache key such as cache-key: api-dependencies, which does not include the lockfile hash. Somebody updates express from 4.19.2 to 4.19.3 and the lockfile changes; but since the key is the same, the pipeline restores the old node_modules. Result: the pipeline verifies a version of the code that does not exist. The tests pass, the artifact is published and something else runs in production. That failure can take weeks to show up and is fiendishly hard to diagnose, because the pipeline says everything is fine.

Four rules for not falling into this: the key must be derived from the content of what it caches (a hash of package-lock.json, never a fixed name); never cache the build output, only the inputs, because a cached dist/ is a potentially stale artifact; when in doubt, invalidate — rebuilding costs minutes, publishing the wrong artifact costs an incident; and a missing cache must not break the build: if it is not there, you rebuild and that is that.

Advanced time optimisation — shared remote caches, building only what is affected — is the subject of lesson 04-04.

  1. The build job in Reservalia's ci.yml

We add the second job to the previous lesson's workflow:

  build:
    name: Build
    runs-on: ubuntu-22.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci

      - name: Build every package
        run: npm run build            # 1

      - name: Check that the artifact exists
        run: |                        # 2
          test -f apps/api/dist/index.js
          test -d apps/web/dist
          echo "Web app size: $(du -sh apps/web/dist | cut -f1)"

      - name: Build the API image
        uses: docker/build-push-action@v5   # 3
        with:
          context: .
          file: apps/api/Dockerfile
          push: false                       # 4
          tags: reservalia/api:${{ github.sha }}
          cache-from: type=gha              # 5
          cache-to: type=gha,mode=max
  1. npm run build at the root: a single command builds the three packages in order. The pipeline knows nothing about the dependency graph, and that is how it should be.
  2. The explicit check looks redundant, but it catches a real failure: a misconfiguration can make tsc finish successfully without writing anything. Verifying that the artifact exists turns a false green into an honest red.
  3. docker/build-push-action builds the image using BuildKit, with support for caching between runs. context: . is the monorepo root, because the Dockerfile needs the root lockfile.
  4. push: false: on a pull request we build to check that the Dockerfile still works, but we publish nothing. Publishing is lesson 02-06.
  5. cache-from/cache-to of type gha store the Docker layers in the Actions cache. mode=max also stores the intermediate layers, which speeds up multi-stage builds a great deal in exchange for more space.

Common Mistakes and Tips

Mistake 1: npm install in the pipeline. It is the most widespread mistake and the quietest: every run may install something different and the modified lockfile disappears with the runner. In CI, always npm ci.

Mistake 2: copying the code before the package.json in the Dockerfile. It invalidates the dependency layer on every one-line code change and turns a 40-second build into a 4-minute one.

Mistake 3: having no .dockerignore. The context balloons in size, incompatible node_modules sneak in and, in the worst case, a file with credentials stays inside the image forever. Mistake 4: running the container as root, which is the default: without USER node, your production process has privileges it does not need.

Mistake 5: cache keys that do not depend on content. The failure that makes the pipeline verify a version of the code that does not exist. If you ever suspect the cache, delete it before investigating further.

Tip 1: build once, test many times. Even though in this module the build job builds in order to verify, the goal — lesson 02-06 — is for there to be a single artifact per commit with everything else operating on it.

Tip 2: measure the image size and set a ceiling. An image that grows from 180 MB to 900 MB almost always means development dependencies have crept in; a docker images in the log is enough to spot it. And test the Dockerfile locally before pushing it: docker build -f apps/api/Dockerfile -t test . costs a minute and saves five red runs.

Exercises

Exercise 1

Order the following instructions of a single-stage Dockerfile to make the most of the cache, and explain the criterion:

COPY . .
RUN npm ci
COPY package.json package-lock.json ./
FROM node:20.11.0-bookworm-slim
RUN npm run build
WORKDIR /app

Exercise 2

A team's pipeline uses this cache configuration:

      - uses: actions/cache@v4
        with:
          path: node_modules
          key: node-modules

Describe the specific failure that will occur and why it is especially hard to diagnose. Propose the fix.

Exercise 3

The final apps/api image weighs 1.1 GB. List four likely causes ordered by impact and the fix for each.

Solutions

Solution 1. The correct order:

FROM node:20.11.0-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

The criterion is from what changes least to what changes most. The package.json files change a few times a week; the code, several times a day. By copying only the manifests first and running npm ci immediately afterwards, the dependency layer is only invalidated when the dependencies genuinely change. With COPY . . before npm ci, any change to any file would force a full reinstall.

Solution 2. The key node-modules is fixed: it does not depend on the lockfile's content. The first run stores the node_modules as they are at that moment; from then on, every run restores that same copy even if the lockfile changes. The pipeline will test and build with old dependencies.

It is hard to diagnose because the pipeline is green: there is no error, it is simply verifying code that is not the code that will be deployed. The symptom shows up much later and somewhere else — a function that does not exist in production, a failure that does not reproduce locally — with no clue pointing at the cache.

The fix is to cache ~/.npm (the download cache, not node_modules) with a content-derived key: key: npm-${{ hashFiles('package-lock.json') }}. And in Reservalia, better still: cache: npm in actions/setup-node, which already does exactly this.

Solution 3. By impact:

  1. Development dependencies in the final image: TypeScript, Vitest and ESLint weigh hundreds of megabytes. Fix: npm ci --omit=dev in a separate stage and copy only those node_modules, as stage 3 of the example does.
  2. Not using a multi-stage build: all the source code, the build tooling and the intermediate layers stay in the image. Fix: separate build from runtime and copy only dist/.
  3. A heavy base image: the full node:20.11.0 is around a gigabyte compared with the ~200 MB of the slim variant. Fix: use -slim.
  4. No .dockerignore: local node_modules, the full .git and coverage files creep in. Fix: add it with the entries from section 6.

Conclusion

Reservalia now builds automatically and reproducibly:

  • Building is transforming code into the executable artifact with a single command, with no prior state and failing loudly when something goes wrong. If the procedure has to be explained out loud, it is not automated yet.
  • In the monorepo, npm resolves the topological order from the declared dependencies: shared-types before api and web. The pipeline should not know that order.
  • Repeatability rests on three pillars: the versioned lockfile, npm ci instead of npm install and the runtime pinned by .nvmrc. The enemies are always the same: floating versions, unversioned files, downloads during the build and leftovers from previous runs.
  • The multi-stage Dockerfile for apps/api separates installation, build, production dependencies and runtime; the final image carries only Node pinned to 20.11.0, the production dependencies and dist/, runs with USER node and comes with a .dockerignore.
  • There are two distinct caches — dependencies and Docker layers — and one non-negotiable rule: the key must be derived from the content. A badly invalidated cache produces a green pipeline that verifies code that does not exist.
  • The build job is already part of ci.yml: it builds the three packages, checks that the artifacts exist and builds the image without publishing it.

So we have something that builds. What remains is to answer whether it works, and to answer it rigorously. In the next lesson, Automated Testing, we will look at the test pyramid applied to the pipeline — what runs on every PR and what does not — we will write with Vitest a unit test of the appointment availability logic and an integration test against the PostgreSQL from services:, we will talk about coverage without turning it into a target, and we will give a name and a policy to the greatest destroyer of trust in a pipeline: flaky tests.

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