In the previous lesson the Reservalia team agreed their six rules without opening an editor. Now it is time for the opposite: writing the first real configuration file and letting the machine start working. By the end of this lesson, every pull request opened against main will automatically trigger a pipeline that downloads the code onto a clean machine, installs exactly Node 20.11.0, brings up a genuine PostgreSQL 16.3 and runs the tests. We are going to build that file from scratch and line by line, without copying magic templates from the internet, because understanding what each keyword does is the difference between maintaining a pipeline and praying to it. We will also look at where that work actually runs — hosted versus self-hosted runners, with their cost and their implications — how variables and secrets are passed, and — perhaps most useful of all — how to debug a workflow that fails when the log says nothing obvious.

Contents

  1. Where the pipeline lives and what triggers it
  2. Reservalia's first ci.yml, line by line
  3. checkout and setup-node: the two actions you will always use
  4. Hosted versus self-hosted runners
  5. Environment variables and secrets
  6. Support services: a real PostgreSQL for the tests
  7. Debugging a failing workflow
  8. Common Mistakes and Tips
  9. Exercises
  10. Conclusion

  1. Where the pipeline lives and what triggers it

GitHub Actions looks for YAML files in a fixed path: .github/workflows/. Ours will be .github/workflows/ci.yml. The pipeline living inside the repository has three consequences:

  • It is versioned with the code. Changing the pipeline is a reviewable, revertible commit.
  • Each branch can have its own version. The workflow that runs on a PR is the one in that branch, which lets you test changes to the pipeline itself in a PR.
  • Configuration and code evolve together. If you add a dependency that needs another tool, both changes go in the same commit.

  1. Reservalia's first ci.yml, line by line

This is the complete file Reservalia starts with. It is deliberately cut down to a single job: we would rather have something small that is green today than something complete that never manages to work.

# .github/workflows/ci.yml
name: CI                                  # 1

on:                                       # 2
  pull_request:
    branches: [main]                      # 3
  push:
    branches: [main]                      # 4

jobs:                                     # 5
  test:                                   # 6
    name: Tests
    runs-on: ubuntu-22.04                 # 7
    timeout-minutes: 15                   # 8
    steps:                                # 9
      - name: Check out the code
        uses: actions/checkout@v4         # 10

      - name: Set up Node.js
        uses: actions/setup-node@v4       # 11
        with:
          node-version-file: .nvmrc       # 12
          cache: npm                      # 13

      - name: Install dependencies
        run: npm ci                       # 14

      - name: Run the tests
        run: npm test                     # 15

Now, each number:

  1. name: CI is the label that will appear in the Actions tab and in the pull request. It is purely cosmetic, but a clear name saves confusion once you have five workflows.
  2. on: declares the events that trigger the workflow. It is the piece that turns a script into a pipeline: nobody launches it by hand.
  3. pull_request with branches: [main]: it runs when somebody opens a PR towards main and on every new push to that PR's branch. This run is what implements rule 3 of the agreement: nothing red gets merged.
  4. push with branches: [main]: it also runs after the merge. As we saw in 02-01, main may have received other commits while the PR was open, so verifying it again is not redundant.
  5. jobs: opens the list of units of work. Each one will run on its own machine.
  6. test: is the job's identifier — the name other jobs will use to depend on it with needs: and the one we will configure as a required check in 02-07. name: Tests is only what gets shown in the interface.
  7. runs-on: ubuntu-22.04 picks the machine. Note that we are not using ubuntu-latest: that label changes operating system with no warning and one fine day your build breaks with nobody having touched anything. It is the same principle as postgres:16.3 versus postgres:latest in lesson 01-04.
  8. timeout-minutes: 15 kills the job if it hangs. Without this, a test waiting on a connection that never arrives can burn six hours of runner. Always set it, even generously.
  9. steps: are the sequential steps within the job. If one returns an exit code other than 0, the following ones do not run and the job is marked red.
  10. uses: actions/checkout@v4 downloads the repository code onto the runner. Without this step the disk is empty: the runner knows nothing about your project. The @v4 pins the action's major version.
  11. uses: actions/setup-node@v4 installs Node.js on the runner.
  12. node-version-file: .nvmrc is the most important detail in the file. Instead of writing node-version: 20.11.0 — duplicating the version in two places that will drift apart — we tell it to read the repository's .nvmrc. A single source of truth for Diego's laptop and for the runner.
  13. cache: npm stores the npm download cache between runs, keyed by the hash of package-lock.json. Since the lockfile is unique in the monorepo (lesson 01-04), a single cache covers the whole project. We develop this in 02-03.
  14. run: npm ci runs a command in the runner's shell. npm ci (and not npm install) is the reproducible installation; the why is the subject of 02-03.
  15. run: npm test runs the tests for every workspace. It is exactly the same command Diego types on his laptop, and that is the point: the pipeline invents no commands, it only runs them on a clean machine.

With these 20 lines, Reservalia already meets practice 3 from the previous lesson: an automated build on every change.

  1. checkout and setup-node: the two actions you will always use

It is worth understanding what an action is. A run: executes a shell command; a uses: invokes a reusable component published in a repository, with its own parameters under with:. They are the ecosystem's Lego bricks.

      - name: Check out the code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0        # clones the FULL history, not just the last commit

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm
          cache-dependency-path: package-lock.json

By default, checkout makes a shallow clone of a single commit: it is faster and enough almost every time. You will need fetch-depth: 0 when something in the pipeline reads the history: git describe for versioning (lesson 02-06), the lead time calculation from 01-05 or the analyses that compare against the base branch (lesson 02-05).

In setup-node, cache-dependency-path states which file determines the cache key. In Reservalia it is the root package-lock.json; in a monorepo with several lockfiles you would have to list them all.

A transferable tip. The pattern check out code → prepare the runtime at the pinned version → install dependencies reproducibly → run the command is identical in any language. Swap setup-node for setup-python, setup-java or setup-go, and npm ci for pip install -r requirements.txt, mvn -B verify or go build ./....

  1. Hosted versus self-hosted runners

The runner is the machine that executes a job. There are two ways of getting one.

Hosted (GitHub) Self-hosted (yours)
Who maintains it The provider You
Initial state A clean machine every time Whatever you guarantee
Getting started Zero configuration Install, register and maintain the agent
Cost Per minute consumed The machine's cost, whether it runs or not
Access to a private network No, unless you tunnel Yes, it is inside your network
Special hardware Limited to the catalogue Whatever you like (GPU, macOS, ARM)
Security risk Isolated and ephemeral Persistent: what one job leaves behind, the next can see

A self-hosted runner pays off in four situations: you need access to resources not exposed to the internet; your build requires hardware the catalogue does not offer or that is disproportionately expensive per minute; you consume so many minutes that a machine of your own works out cheaper; or a regulation requires the code not to leave your infrastructure.

The security warning that cannot be skipped: a self-hosted runner must never run pull request workflows coming from forks. A stranger opens a PR, modifies the workflow and their code runs on a machine inside your internal network. Because self-hosted runners are persistent, a malicious job can leave files or credentials behind for the next one to find. The minimum mitigation is to run every job in an ephemeral container and isolate the runner in its own subnet. Serious pipeline hardening — token permissions, OIDC, secret isolation — is the subject of lesson 04-03.

Reservalia's decision: hosted runners. There are 340 paying businesses and three people; the minutes are cheap compared with Nuria's time maintaining machines. It is the right decision for the vast majority of small teams.

  1. Environment variables and secrets

The pipeline needs configuration values. Some are public (the AWS region) and some are not (a password). GitHub Actions distinguishes between them.

env:                                    # variables for the WHOLE workflow
  NODE_ENV: test
  TZ: Europe/Madrid
jobs:
  test:
    runs-on: ubuntu-22.04
    env:                                # variables for this job only
      DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test
    steps:
      - run: npm run test:integration
        env: { LOG_LEVEL: debug }       # variables for this step only

The three scopes combine: the most specific one wins. TZ: Europe/Madrid deserves special attention: runners run in UTC, and an appointment booking application is full of time logic. Setting the time zone explicitly avoids the classic "the test only fails in CI and only after 22:00".

Secrets are declared in the repository's (or the organisation's) settings and read through the secrets context, for example SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} inside a step's env:. Basic usage rules, without getting into serious hardening yet:

  • They are never written in the YAML (the file is in the repository; the secret is not) and they are injected as environment variables or with: parameters, never concatenated into a string that later gets printed.
  • The platform masks known values in the logs, replacing them with ***. Do not rely on that as your only protection: if your script echos a JSON that contains the secret in transformed form (base64, for instance), the masking will not catch it.
  • Secrets do not reach workflows triggered by PRs from forks. It is a deliberate security measure, and it explains why an external PR can fail on steps that need credentials.

At Reservalia, for now, there are only two secrets: SONAR_TOKEN (lesson 02-05) and AWS_ROLE_CI (lesson 02-06). The PostgreSQL password in CI is not a secret: it is an ephemeral database that lives for nine minutes inside the runner and then dies. Treating something as a secret when it is not generates noise and makes it harder to protect what really matters.

  1. Support services: a real PostgreSQL for the tests

Reservalia's integration tests query a genuine database. Locally that is handled by the docker-compose.yml from lesson 01-04; in CI it is handled with services:, which brings up auxiliary containers alongside the job.

jobs:
  test:
    runs-on: ubuntu-22.04
    services:
      postgres:                                    # 1
        image: postgres:16.3                       # 2
        env:                                       # 3
          POSTGRES_USER: reservalia
          POSTGRES_PASSWORD: ci
          POSTGRES_DB: reservalia_test
        ports:
          - 5432:5432                              # 4
        options: >-                                # 5
          --health-cmd "pg_isready -U reservalia -d reservalia_test"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10
    steps:
      # ... checkout, setup-node and npm ci, exactly as in section 2 ...
      - name: Integration tests
        run: npm run test:integration --workspace apps/api
        env:
          DATABASE_URL: postgres://reservalia:ci@localhost:5432/reservalia_test   # 6
  1. postgres: is a label you choose yourself; it will also be the container's network name.
  2. image: postgres:16.3 is exactly the same version used by the local docker-compose.yml and the same family as RDS in production. Having all three match is half of repeatability.
  3. env: configures the container. We use the reservalia_test database, not reservalia: naming the test database differently avoids accidents the day somebody copies a connection string.
  4. ports: - 5432:5432 publishes the container's port on the runner, so the Node process can connect to localhost:5432.
  5. options: are Docker options. The healthcheck is essential: without it, the steps start as soon as the container exists, not when the database is ready to accept connections. The result would be an intermittent ECONNREFUSED — the worst kind of failure, because sometimes it happens and sometimes it does not. With --health-cmd, the platform waits for pg_isready to respond before running the first step.
  6. DATABASE_URL points at localhost, not at postgres. The steps run directly on the runner, not inside a container, so they see the published port. (If the job used container:, the correct address would be postgres:5432, the service name. It is one of the ecosystem's most frequent confusions.)
flowchart LR
    subgraph RUNNER["Runner ubuntu-22.04 - ephemeral"]
        S["steps: node + npm test"] -- "localhost:5432" --> P["postgres:16.3 service"]
    end

  1. Debugging a failing workflow

Sooner or later the workflow will turn red for a reason you do not understand. This is the order of attack, from the cheapest method to the most expensive.

Step 1: read the log of the right step. It sounds obvious and hardly anyone does it properly. Expand the first red step (the following ones do not run) and look for the first error line, not the last: the last one is usually the useless summary Process completed with exit code 1. Step 2: work out whether it is an environment problem or a code problem. The question that separates the two worlds: does this very commit pass on my laptop?

git checkout a3f9c21          # the exact commit that failed in CI
rm -rf node_modules           # imitate the clean machine
npm ci                        # the same installation the runner does
npm test

If it passes locally and not in CI, the difference is in the environment: Node version, time zone, missing variables, unversioned files that only exist on your disk, test ordering or a dependency on a database with pre-existing data.

Step 3: turn on diagnostic logging. Define two secret-type variables in the repository: ACTIONS_STEP_DEBUG: true (internal detail of each step: action inputs, commands executed) and ACTIONS_RUNNER_DEBUG: true (machine preparation, network, cache). On re-running, the logs will include ##[debug] lines. It is a huge amount of text: use it when the normal log is not enough, and turn it off afterwards.

Step 4: print the runner's state. A temporary diagnostic step solves a surprising percentage of cases:

      - name: Diagnostics
        run: |
          node --version            # does it match .nvmrc?
          echo "TZ=$TZ  date=$(date)"
          pwd && ls -la             # is the code where you think it is?
          env | sort | grep -v -i 'token\|secret\|password'   # variables, without leaking secrets

Note the grep -v: never dump the full environment into a public log.

Step 5: reproduce the pipeline locally.

act pull_request -W .github/workflows/ci.yml     # option A: simulate the workflow

docker run --rm -it -v "$PWD":/repo -w /repo \
  node:20.11.0-bookworm-slim bash                # option B: the same base container
#   inside: npm ci && npm test

act is convenient for iterating on the structure of the YAML, but its image is not identical to the real runner's and some actions do not behave the same. Option B is more laborious and reproduces reality better.

Step 6: the intermittent failure. If the same commit passes sometimes and fails other times, you are not facing a configuration problem but a flaky test. Do not solve it by re-running: write it down and apply the quarantine policy from lesson 02-04.

Common Mistakes and Tips

Mistake 1: using ubuntu-latest and floating versions. The day the label points at another version of the system, your build breaks with nobody having touched the repository, and you will lose half a day hunting your diff for a cause that is not there. Pin ubuntu-22.04, pin postgres:16.3, read the Node version from .nvmrc. Mistake 2: forgetting the checkout, the classic beginner's slip: the runner starts with an empty disk and npm ci fails with a bewildering "no package.json found".

Mistake 3: services: with no healthcheck. The symptom is an integration test that fails with ECONNREFUSED one run in five. The cause is not the network: it is that the steps started before PostgreSQL was ready.

Mistake 4: duplicating the Node version. Putting node-version: 20 in the YAML while the .nvmrc says 20.11.0 works until it stops working. One version, one place.

Mistake 5: not setting timeout-minutes. A hung job burns billable minutes up to the platform's default limit, which is measured in hours.

Tip 1: start with one job and grow it. This chapter's ci.yml has a single job and already delivers real value. In the following lessons we will add build, quality and publish.

Tip 2: test pipeline changes in a PR — the workflow is read from the PR's branch, so you can iterate without dirtying mainand always name your steps: - name: Install dependencies versus a bare run is the difference between a readable log and a wall of commands.

Exercises

Exercise 1

This workflow always fails at the test step with ECONNREFUSED 127.0.0.1:5432. Find the three problems and fix them.

name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:latest
        env: { POSTGRES_PASSWORD: ci }
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm install
      - run: npm run test:integration --workspace apps/api
        env:
          DATABASE_URL: postgres://postgres:ci@localhost:5432/postgres

Exercise 2

Diego says: "On my laptop npm test always passes; in CI the test calculates next day slots fails one run in three, and mostly in the afternoon". List three hypotheses ordered by likelihood and say what you would check in each case.

Exercise 3

Reservalia wants the pipeline not to run when a PR only changes files under infra/terraform/ or the README.md. Write the corresponding on: block and explain one risk of this optimisation.

Solutions

Solution 1. The three problems:

  1. actions/checkout@v4 is missing as the first step: the runner has no code, so npm install cannot find package.json. It is the root cause of nothing working.
  2. The service has no healthcheck and the port is not published. Without ports: - 5432:5432 the port is not reachable from the runner, and without --health-cmd the steps start before the database is ready. Both produce ECONNREFUSED.
  3. Floating versions: ubuntu-latest, postgres:latest and node-version: 20. They should be ubuntu-22.04, postgres:16.3 and node-version-file: .nvmrc.

As a bonus, two improvements: npm install should be npm ci, and timeout-minutes is missing.

Solution 2. Hypotheses in order:

  1. Time zone. It is the most likely one, and the "in the afternoon" is the decisive clue: the runner runs in UTC and Diego's laptop in Europe/Madrid. A test about "the next day" run at 23:30 Madrid time falls on a different day in UTC. Check: echo $TZ && date on the runner; fix: TZ: Europe/Madrid at workflow level, or better still, make the test independent of the clock by injecting the date.
  2. State shared between tests. If the execution order varies or there is parallelism, one test can leave appointments in the database that another finds. Check: run only that test in isolation against a freshly created database.
  3. Data that depends on the calendar. Public holidays or weekends: the test passes Monday to Thursday and fails on Friday because "the next day" is Saturday and the business is closed. Check: pin a specific date in the test.

Solution 3.

on:
  pull_request:
    branches: [main]
    paths-ignore: ['infra/terraform/**', '**/*.md']
  push:
    branches: [main]
    paths-ignore: ['infra/terraform/**', '**/*.md']

The risk: if the test check is configured as required for merging (lesson 02-07), a PR that only touches README.md will never run the check and will be blocked forever waiting for a result that never arrives. The usual solution is a twin workflow that returns green immediately for those paths, or using job-level filters instead of event-level ones. We will come back to it in 02-07.

Conclusion

Reservalia now has genuine continuous integration:

  • The pipeline lives in .github/workflows/ci.yml, versioned with the code, and triggers itself on every pull request towards main and every push to main.
  • The test job runs on a pinned ubuntu-22.04 runner, with timeout-minutes, and executes four steps: checkout, setup-node reading the version from the .nvmrc with npm caching, npm ci and npm test.
  • Hosted runners are the right choice for a small team; self-hosted ones only pay off for private network access, special hardware, volume or regulatory compliance, and they bring with them an isolation problem that has to be taken seriously.
  • Variables are declared in three scopes (workflow, job, step) and secrets are read with secrets.NAME without ever writing them in the YAML. TZ: Europe/Madrid prevents an entire family of time-related failures.
  • A postgres:16.3 service container with a healthcheck gives the integration tests a real database, identical to the local one and from the same family as production.
  • And you have a six-step debugging method, from the log to the container reproduced locally, with ACTIONS_STEP_DEBUG as the mid-range artillery.

What this pipeline still does not do is build anything: it runs the tests against the source code and stops there. In the next lesson, Build Automation, we will look at what "building" really means, in what order a monorepo's packages have to be compiled, why npm ci and the lockfile are the pillars of a reproducible build, how to package apps/api in a multi-stage Dockerfile with a non-root user, and how caches work — including why a badly invalidated cache is worse than no cache at all. By the end, ci.yml will have its second job: build.

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