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
- Where the pipeline lives and what triggers it
- Reservalia's first
ci.yml, line by line checkoutandsetup-node: the two actions you will always use- Hosted versus self-hosted runners
- Environment variables and secrets
- Support services: a real PostgreSQL for the tests
- Debugging a failing workflow
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- Reservalia's first
ci.yml, line by line
ci.yml, line by lineThis 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 # 15Now, each number:
name: CIis 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.on:declares the events that trigger the workflow. It is the piece that turns a script into a pipeline: nobody launches it by hand.pull_requestwithbranches: [main]: it runs when somebody opens a PR towardsmainand on every new push to that PR's branch. This run is what implements rule 3 of the agreement: nothing red gets merged.pushwithbranches: [main]: it also runs after the merge. As we saw in 02-01,mainmay have received other commits while the PR was open, so verifying it again is not redundant.jobs:opens the list of units of work. Each one will run on its own machine.test:is the job's identifier — the name other jobs will use to depend on it withneeds:and the one we will configure as a required check in 02-07.name: Testsis only what gets shown in the interface.runs-on: ubuntu-22.04picks the machine. Note that we are not usingubuntu-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 aspostgres:16.3versuspostgres:latestin lesson 01-04.timeout-minutes: 15kills 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.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.uses: actions/checkout@v4downloads the repository code onto the runner. Without this step the disk is empty: the runner knows nothing about your project. The@v4pins the action's major version.uses: actions/setup-node@v4installs Node.js on the runner.node-version-file: .nvmrcis the most important detail in the file. Instead of writingnode-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.cache: npmstores the npm download cache between runs, keyed by the hash ofpackage-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.run: npm ciruns a command in the runner's shell.npm ci(and notnpm install) is the reproducible installation; the why is the subject of 02-03.run: npm testruns 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.
checkout and setup-node: the two actions you will always use
checkout and setup-node: the two actions you will always useIt 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.jsonBy 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-nodeforsetup-python,setup-javaorsetup-go, andnpm ciforpip install -r requirements.txt,mvn -B verifyorgo build ./....
- 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.
- 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 onlyThe 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 scriptechos 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.
- 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 # 6postgres:is a label you choose yourself; it will also be the container's network name.image: postgres:16.3is exactly the same version used by the localdocker-compose.ymland the same family as RDS in production. Having all three match is half of repeatability.env:configures the container. We use thereservalia_testdatabase, notreservalia: naming the test database differently avoids accidents the day somebody copies a connection string.ports: - 5432:5432publishes the container's port on the runner, so the Node process can connect tolocalhost:5432.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 intermittentECONNREFUSED— the worst kind of failure, because sometimes it happens and sometimes it does not. With--health-cmd, the platform waits forpg_isreadyto respond before running the first step.DATABASE_URLpoints atlocalhost, not atpostgres. The steps run directly on the runner, not inside a container, so they see the published port. (If the job usedcontainer:, the correct address would bepostgres: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
- 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 testIf 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 secretsNote 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 testact 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 main — and 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/postgresExercise 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:
actions/checkout@v4is missing as the first step: the runner has no code, sonpm installcannot findpackage.json. It is the root cause of nothing working.- The service has no healthcheck and the port is not published. Without
ports: - 5432:5432the port is not reachable from the runner, and without--health-cmdthe steps start before the database is ready. Both produceECONNREFUSED. - Floating versions:
ubuntu-latest,postgres:latestandnode-version: 20. They should beubuntu-22.04,postgres:16.3andnode-version-file: .nvmrc.
As a bonus, two improvements: npm install should be npm ci, and timeout-minutes is missing.
Solution 2. Hypotheses in order:
- 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 && dateon the runner; fix:TZ: Europe/Madridat workflow level, or better still, make the test independent of the clock by injecting the date. - 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.
- 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 towardsmainand every push tomain. - The
testjob runs on a pinnedubuntu-22.04runner, withtimeout-minutes, and executes four steps:checkout,setup-nodereading the version from the.nvmrcwith npm caching,npm ciandnpm 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.NAMEwithout ever writing them in the YAML.TZ: Europe/Madridprevents an entire family of time-related failures. - A
postgres:16.3service 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_DEBUGas 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
- 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
