NestJS course Β· Module 12: Containers and CI/CD
Advanced CI/CD - matrix builds, artifacts and cache
In this lesson6
The tests pass on your machine. Not on your colleague's - a different Node version. Not on the server either, because somebody forgot to rebuild the image. Each of those costs half a day, and all three share one cause: the check depends on who ran it and where.
Continuous Integration removes that dependency: it is the automatic building and testing of code after every push. Not manual weekly deployment, not memory monitoring, not database backups - just one check, always identical, on a machine nobody configured to their own taste.
When to run
A workflow begins by naming the events that wake it:
1name: CI
2
3on:
4 push:
5 branches: [main]
6 pull_request:on: pull_request runs the workflow when a pull request is created - and that is the moment that matters most, because the check happens before the change enters the main branch.
on: push reacts to code being pushed; the two are usually used together. Other triggers exist but do something else: on: schedule runs a workflow at an appointed time, and on: deployment on a deployment event. Neither fires when somebody opens a pull request.
The order of jobs
A pipeline splits into jobs, and their order is not arbitrary:
- Lint - a formatting check.
- Build - TypeScript compilation.
- Test - unit tests.
- Docker build & push - building and pushing the image.
The organising rule reads: cheapest and fastest first. Lint finishes in seconds, so there is no sense waiting five minutes for tests only to learn about a missing semicolon. Compilation precedes testing, because tests of uncompiled code will not run anyway. The Docker image is built last - it is the most expensive step and there is no point starting it if anything earlier failed.
Step by step
Each job is made of steps, and the first is always fetching the code:
1jobs:
2 test:
3 runs-on: ubuntu-latest
4 steps:
5 - name: Checkout code
6 uses: actions/checkout@v4
7
8 - name: Setup Node
9 uses: actions/setup-node@v4
10 with:
11 node-version: 20
12
13 - name: Install dependencies
14 run: yarn install --frozen-lockfile
15
16 - name: Tests
17 run: yarn test --coverageA step's shape is fixed: - name: Checkout code, then uses:, then actions/checkout@v4. name is the label shown in the interface, uses names a ready-made action, and run a command to execute in the shell.
The order inside the test job is equally fixed: actions/checkout@v4 fetches the code, actions/setup-node@v4 installs Node, yarn install --frozen-lockfile pulls the dependencies, and finally yarn test --coverage runs the tests.
The --frozen-lockfile flag deserves attention: it installs exactly the versions from the lockfile and aborts if they do not match. Without it the pipeline could quietly install a newer dependency and test something other than what you will run in production.
Secrets
A pipeline needs passwords - for the image registry, the server, the test database. We keep them in Settings > Secrets and variables > Actions, in the repository's settings.
Not in a .env file in the repository, not in a commit comment, not in README.md - all those places are public to anyone with access to the code, and Git history cannot simply be wiped.
A stored secret enters the workflow by reference, never in the open:
1 - name: Log in to the registry
2 run: docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_TOKEN }}The value is substituted at run time and appears masked in the logs.
Three improvements
Finally, three things separating a pipeline that works from one you can bear to wait for.
Matrix runs the same job across several configurations at once:
1 strategy:
2 matrix:
3 node-version: [18, 20, 22]Three Node versions, three parallel runs, one description. This is the answer to the problem from the start of the lesson - "it does not work on my colleague's machine" now surfaces at pull request time rather than a week later.
Cache remembers downloaded dependencies between runs. Without it every run fetches the same packages afresh; with it installation shrinks from minutes to seconds.
Artifacts are the files a job leaves behind - a coverage report, a built package, screenshots from tests. They outlive the run and can be downloaded from the interface, which is sometimes the only way to understand why something failed only on the CI machine.
Summary
The check no longer depends on who runs it:
- CI is the automatic building and testing of code after every push - not manual deployment, not memory monitoring, not backups,
on: pull_requestruns the workflow when a pull request is created;on: scheduleruns at an appointed time,on: deploymenton deployment,- job order: Lint β Build β Test β Docker build & push, following "cheapest first",
- a step's shape:
- name:, thenuses:, then the action name;run:executes a shell command, - the test job in order:
actions/checkout@v4,actions/setup-node@v4,yarn install --frozen-lockfile,yarn test --coverage, --frozen-lockfileinstalls exactly the lockfile versions and aborts on a mismatch,- secrets belong in Settings > Secrets and variables > Actions - not in
.env, not in a comment, not inREADME.md, - matrix runs a job across several configurations in parallel, cache shortens dependency installation, artifacts preserve files after the run ends.
In the next lesson we will drop from numbers to a single request - you will meet distributed tracing. For now remember: a pipeline is one check, always performed the same way - and a matrix is what stops "it works on my machine" from being an argument.
Code for this lesson: src/github-actions.ts
1// CI/CD with GitHub Actions - Cursus Publicus
2console.log("=== CI/CD PIPELINE ===\n");
3
4interface PipelineJob {
5 name: string;
6 description: string;
7 steps: string[];
8 runsAfter?: string;
9}
10
11const pipeline: PipelineJob[] = [
12 {
13 name: 'lint',
14 description: 'Code inspection',
15 steps: ['checkout', 'setup-node', 'yarn install', 'yarn lint'],
16 },
17 {
18 name: 'test',
19 description: 'Unit tests',
20 steps: ['checkout', 'setup-node', 'yarn install', 'yarn test --coverage'],
21 runsAfter: 'lint',
22 },
23 {
24 name: 'build',
25 description: 'Application build',
26 steps: ['checkout', 'setup-node', 'yarn install', 'yarn build'],
27 runsAfter: 'test',
28 },
29 {
30 name: 'docker',
31 description: 'Docker image build & push',
32 steps: ['checkout', 'docker login', 'docker build & push'],
33 runsAfter: 'build',
34 },
35];
36
37pipeline.forEach((job, i) => {
38 const arrow = job.runsAfter ? ` (after: ${job.runsAfter})` : ' (start)';
39 console.log(`Job ${i + 1}: ${job.name}${arrow}`);
40 console.log(` Description: ${job.description}`);
41 job.steps.forEach((s, j) => {
42 console.log(` Step ${j + 1}: ${s}`);
43 });
44 console.log();
45});
46
47// Triggers
48console.log("=== WORKFLOW TRIGGERS ===\n");
49console.log("push to main -> full pipeline + deploy");
50console.log("push to develop -> CI without deploy");
51console.log("pull_request -> CI (lint + test + build)\n");
52
53// Secrets
54console.log("=== GITHUB SECRETS ===\n");
55console.log("Store passwords and API keys in:");
56console.log(" Settings > Secrets > Actions");
57console.log(" Never in the source code!");
58Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does CI (Continuous Integration) mean?
2. Which trigger runs a GitHub Actions workflow when a pull request is created?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Vertical ordering
Arrange CI/CD pipeline jobs from first to last:
- Code editor
Define steps for three jobs: lint (checkout, setup-node, install, lint), test (+ coverage), build
- Horizontal ordering
Arrange the elements of the checkout step in GitHub Actions in the correct order:
- Click in order
Arrange the steps of the 'test' job in GitHub Actions in the correct order:
- Code editor
Configure helmet, compression, CORS, ValidationPipe, and ThrottlerGuard in the correct order