El pipeline de Reservalia ja executa les proves a cada pull request, però encara no construeix res: treballa sobre el codi font i s'acaba aquí. Falta la peça que converteix un repositori en una cosa executable. En aquesta lliçó veurem què significa exactament "construir", per què ha de ser una única ordre reproduïble i no una seqüència de passos que algú recorda, i com s'orquestra la construcció d'un monorepo on un paquet depèn d'un altre. Després atacarem el concepte central de tot el mòdul —la reproduïbilitat: per què npm ci no és el mateix que npm install, quin paper hi juga el lockfile i quines coses fan que una build deixi de ser reproduïble—. Acabarem empaquetant apps/api en un Dockerfile multietapa explicat etapa a etapa, i afegint el job build al ci.yml. El que no farem aquí és publicar ni versionar aquest artefacte: això és la lliçó 02-06.

Contingut

  1. Què és realment "construir"
  2. La build de Reservalia, paquet a paquet
  3. Reproduïbilitat: el lockfile i npm ci
  4. Què fa que una build no sigui reproduïble
  5. Empaquetar amb Docker: el Dockerfile multietapa
  6. .dockerignore: el que no hi ha d'entrar
  7. Memòries cau de construcció i el perill d'invalidar-les malament
  8. El job build al ci.yml de Reservalia
  9. Errors Comuns i Consells
  10. Exercicis
  11. Conclusió

  1. Què és realment "construir"

Construir és transformar el codi font en l'artefacte que s'executarà, sense intervenció humana. Segons la tecnologia, aquesta transformació inclou compilar, transpilar, empaquetar, minimitzar, generar codi o crear una imatge de contenidor.

El que defineix una bona build no és el que fa, sinó quatre propietats:

Propietat Què significa Com es comprova
Una sola ordre npm run build i res més Algú ha d'executar alguna cosa "abans"?
Reproduïble El mateix codi produeix el mateix resultat Construir dues vegades i comparar
Sense estat No depèn de restes de builds anteriors rm -rf node_modules dist && npm ci && npm run build
Autoverificable Falla sorollosament, amb codi de sortida ≠ 0 Trencar alguna cosa a propòsit i veure si es posa en vermell

La primera és la que més s'incompleix. Reservalia parteix d'un cas real: en Diego construeix l'API executant npm install && npm run build, però abans copia a mà un fitxer .env que només és al seu portàtil, i si ha tocat packages/tipus-compartits ha de construir aquest paquet primer i recordar-se de fer-ho. Això no és una ordre: és un procediment oral. I un procediment oral no es pot automatitzar fins que algú l'escriu.

La regla pràctica. Si necessites explicar a algú com construir el projecte amb més d'una frase, el pas següent no és escriure el pipeline: és arreglar l'ordre de build.

  1. La build de Reservalia, paquet a paquet

El monorepo té tres paquets i una dependència real entre ells: tant l'API com la web importen tipus de @reservalia/tipus-compartits.

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

Això significa que l'ordre importa: si apps/api compila abans que tipus-compartits, TypeScript no trobarà les declaracions i fallarà. Les tres ordres, una per paquet:

npm run build --workspace packages/tipus-compartits  # genera JS + fitxers .d.ts
npm run build --workspace apps/api                   # tsc → apps/api/dist/
npm run build --workspace apps/web                   # vite build → apps/web/dist/

Cadascuna fa una cosa diferent:

  • tsc (paquet compartit i API) transpila TypeScript a JavaScript executable per Node i genera els .d.ts. No agrupa ni minimitza: el resultat conserva l'estructura de carpetes de src/.
  • vite build (web) fa molt més: resol tots els import, agrupa el codi en uns pocs fitxers, elimina el que ningú no fa servir, minimitza, processa el CSS i afegeix un hash al nom de cada fitxer (index-4a7f2b.js) perquè el navegador els pugui posar en memòria cau indefinidament.

A l'arrel del monorepo, la lliçó 01-04 ja va deixar definida l'ordre única: "build": "npm run build --workspaces --if-present". L'opció --workspaces recorre tots els paquets i npm resol l'ordre topològic: com que apps/api declara "@reservalia/tipus-compartits": "*" a les seves dependències, npm construeix primer el paquet del qual es depèn. Aquest és un motiu excel·lent per declarar bé les dependències internes: el gestor de paquets pot deduir l'ordre i tu no l'has d'escriure.

En eines d'altres ecosistemes la idea és idèntica: Maven, Gradle, Nx o Turborepo resolen el mateix graf. El que no has de fer mai és escriure l'ordre a mà al YAML del pipeline, perquè el dia que algú afegeixi un paquet, el pipeline no se n'assabentarà.

  1. Reproduïbilitat: el lockfile i npm ci

Aquest apartat és el cor de la lliçó. Reproduïble significa: el mateix commit, construït avui al teu portàtil i d'aquí a sis mesos en un runner net, produeix el mateix artefacte.

3.1. El paper del lockfile

A package.json declares intencions ("express": "4.19.2" o, pitjor, "^4.19.2"). A package-lock.json queda registrat el resultat exacte de resoldre aquestes intencions: la versió concreta de cadascuna dels centenars de dependències transitives, la seva URL i el seu hash d'integritat.

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

El camp integrity és el que fa que el lockfile sigui també un control de seguretat: si el paquet descarregat no coincideix amb aquell hash, la instal·lació falla. Per això el lockfile es versiona al repositori, sempre, i a Reservalia n'hi ha un de sol per a tot el monorepo.

3.2. npm ci davant de npm install

npm install npm ci
Què fa servir package.json, i el lockfile com a suggeriment Només el lockfile
Si hi ha discrepància Modifica el lockfile silenciosament Falla amb un error explícit
node_modules previ L'actualitza de manera incremental L'esborra i l'instal·la de zero
Determinisme No garantit Garantit
Velocitat en CI Menor Major (no resol l'arbre)
Ús correcte Al teu portàtil, en afegir una dependència Al pipeline, sempre

El cas que il·lustra la diferència: algú afegeix "lodash": "^4.17.20" al package.json i no actualitza el lockfile. Amb npm install, el pipeline instal·la el que hi hagi i reescriu el lockfile dins del runner, així que la build passa; i com que el runner és efímer, aquell lockfile modificat desapareix sense que ningú se n'assabenti. Cada execució podria instal·lar una versió diferent. Amb npm ci, el pipeline s'atura amb un missatge clar —npm ci can only install packages when your package.json and package-lock.json are in sync—, i aquest error no és una molèstia: és el pipeline fent exactament la seva feina.

  1. Què fa que una build no sigui reproduïble

Els enemics habituals, i com es neutralitzen:

Enemic Símptoma típic Antídot
Rangs de versió (^, ~) sense lockfile aplicat "Ahir funcionava i avui no, sense tocar res" Versions exactes + npm ci
Runtime no fixat Funciona amb Node 20 i falla amb Node 22 .nvmrc + engines + node-version-file
Imatge base flotant (node:20, postgres:latest) La build canvia quan el proveïdor publica Fixar la versió completa
Fitxers no versionats (.env local, certificats) "A la meva màquina funciona" Tot al repositori o injectat per variables
Descàrregues des d'internet durant la build Falla quan l'URL canvia o cau Dependències declarades, no descarregades a mà
Restes de builds anteriors Passa al segon intent però no al primer Netejar dist/ abans de construir
Dependència de la data o la màquina L'artefacte binari canvia entre execucions Segells de temps fixos si necessites bit a bit

La prova definitiva de reproduïbilitat cap en quatre línies i val la pena executar-la de tant en tant:

git clone https://github.com/reservalia/reservalia.git /tmp/neta && cd /tmp/neta
git checkout a3f9c21           # el commit exacte
npm ci                         # installacio des del lockfile
npm run build                  # construeix sense cap pas manual?

Si això falla, el pipeline fallarà. Si funciona, el pipeline serà avorrit, que és justament el que es busca.

  1. Empaquetar amb Docker: el Dockerfile multietapa

apps/api/dist/ és JavaScript solt: per executar-lo cal un Node de la versió correcta i les dependències de producció. Una imatge de contenidor empaqueta les tres coses juntes i elimina d'una revolada la diferència entre entorns.

El problema és que per construir cal TypeScript, i per executar no. Ficar-ho tot a la imatge final dona una imatge enorme i amb més superfície d'atac de la necessària. La solució és una construcció multietapa.

# apps/api/Dockerfile

# ───────── ETAPA 1: dependencies completes ─────────
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/tipus-compartits/package.json packages/tipus-compartits/
RUN npm ci

# ───────── ETAPA 2: construccio ─────────
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/tipus-compartits \
 && npm run build --workspace apps/api

# ───────── ETAPA 3: dependencies de produccio ─────────
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/tipus-compartits/package.json packages/tipus-compartits/
RUN npm ci --omit=dev

# ───────── ETAPA 4: imatge final ─────────
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/tipus-compartits/dist ./node_modules/@reservalia/tipus-compartits/dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Etapa a etapa:

Etapa 1 (deps). Copia només els package.json i el lockfile, no el codi, i executa npm ci. Aquest ordre és deliberat i és el truc de memòria cau més rendible de Docker: mentre no canviïn les dependències, aquesta capa es reutilitza i t'estalvies un npm ci complet a cada build. Si copiessis el codi abans, qualsevol canvi en un .ts invalidaria la instal·lació.

Etapa 2 (build). Reutilitza els node_modules de l'etapa anterior amb COPY --from=deps, copia ara sí tot el codi i construeix en l'ordre correcte: primer el paquet compartit, després l'API.

Etapa 3 (prod-deps). Torna a instal·lar, però amb --omit=dev: sense TypeScript, sense Vitest, sense ESLint. Redueix molt la mida final i, sobretot, treu del contenidor de producció desenes de paquets que mai no haurien de ser-hi. Etapa 4 (runtime). L'única que acaba al registre. Només conté Node, les dependències de producció i el dist/ compilat. Quatre decisions importants:

  • node:20.11.0-bookworm-slim: versió completament fixada —igual que el .nvmrc— i variant slim, molt més lleugera que la imatge per defecte. Mai node:20 ni node:latest.
  • USER node: el procés no corre com a root. Les imatges oficials de Node ja porten aquell usuari creat. És una línia que redueix de manera real l'impacte d'una vulnerabilitat: si algú aconsegueix executar codi dins del contenidor, ho fa sense privilegis.
  • --chown=node:node a cada COPY: els fitxers pertanyen a l'usuari que els farà servir, no a root.
  • ENV NODE_ENV=production: moltes llibreries, Express entre elles, canvien el seu comportament amb aquesta variable (menys registres, més memòria cau, missatges d'error sense traça interna).

  1. .dockerignore: el que no hi ha d'entrar

El COPY . . de l'etapa 2 copia tot el context de construcció. Sense filtre, això inclou node_modules locals (d'una altra arquitectura), l'historial de git i, en el pitjor cas, un .env amb credencials. El fitxer .dockerignore va a l'arrel del repositori:

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

Tres motius, per ordre d'importància. Seguretat: un .env copiat a una capa queda dins de la imatge per sempre encara que una instrucció posterior l'esborri, perquè les capes són immutables i qualsevol amb la imatge les pot llegir. Correcció: copiar node_modules des d'un macOS a una imatge Linux hi fica binaris natius incompatibles. Velocitat: un context més petit es transfereix abans i evita que un fitxer irrellevant invalidi capes.

  1. Memòries cau de construcció i el perill d'invalidar-les malament

Hi ha dues memòries cau diferents en joc. La memòria cau de dependències és la que vam activar a la 02-02 amb cache: npm: desa les descàrregues d'npm entre execucions, amb una clau derivada del hash del package-lock.json; si el lockfile no canvia, la clau coincideix i no cal descarregar res de la xarxa. La memòria cau de capes de Docker funciona per instrucció: cada línia del Dockerfile produeix una capa identificada pel seu contingut, i si la instrucció i les seves entrades no han canviat, Docker la reutilitza. D'aquí la regla d'or d'ordenar el Dockerfile del que canvia menys al que canvia més, que és exactament el que fa la nostra etapa 1.

Ara la part important: una memòria cau mal invalidada és pitjor que no tenir-ne.

Imagina't una clau de memòria cau fixa, del tipus cache-key: dependencies-api, que no inclou el hash del lockfile. Algú actualitza express de 4.19.2 a 4.19.3 i el lockfile canvia; però com que la clau és la mateixa, el pipeline restaura els node_modules vells. Resultat: el pipeline verifica una versió del codi que no existeix. Les proves passen, l'artefacte es publica i en producció corre una altra cosa. Aquesta fallada pot trigar setmanes a aparèixer i és endimoniadament difícil de diagnosticar, perquè el pipeline diu que tot va bé.

Quatre regles per no caure-hi: la clau s'ha de derivar del contingut del que posa en memòria cau (hash del package-lock.json, mai un nom fix); no posis mai en memòria cau el resultat de la construcció, només les entrades, perquè un dist/ en memòria cau és un artefacte potencialment obsolet; davant del dubte, invalida —reconstruir costa minuts, publicar l'artefacte equivocat costa un incident—; i una memòria cau absent no ha de trencar la build: si falta, es reconstrueix i prou.

L'optimització avançada de temps —memòries cau remotes compartides, construcció només d'allò afectat— és matèria de la lliçó 04-04.

  1. El job build al ci.yml de Reservalia

Afegim el segon job al flux de treball de la lliçó anterior:

  build:
    name: Construccio
    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: Construir tots els paquets
        run: npm run build            # 1

      - name: Comprovar que l'artefacte existeix
        run: |                        # 2
          test -f apps/api/dist/index.js
          test -d apps/web/dist
          echo "Mida de la web: $(du -sh apps/web/dist | cut -f1)"

      - name: Construir la imatge de l'API
        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 a l'arrel: una sola ordre construeix els tres paquets en ordre. El pipeline no sap res del graf de dependències, i així ha de ser.
  2. La comprovació explícita sembla redundant, però atrapa una fallada real: una configuració mal posada pot fer que tsc acabi amb èxit sense escriure res. Verificar que l'artefacte existeix converteix un fals verd en un vermell honest.
  3. docker/build-push-action construeix la imatge fent servir BuildKit, amb suport de memòria cau entre execucions. context: . és l'arrel del monorepo, perquè el Dockerfile necessita el lockfile de l'arrel.
  4. push: false: en un pull request construïm per comprovar que el Dockerfile continua funcionant, però no publiquem res. La publicació és la lliçó 02-06.
  5. cache-from/cache-to de tipus gha desen les capes de Docker a la memòria cau d'Actions. mode=max desa també les capes intermèdies, cosa que accelera molt les builds multietapa a canvi de més espai.

Errors Comuns i Consells

Error 1: npm install al pipeline. És l'error més estès i el més silenciós: cada execució pot instal·lar una cosa diferent i el lockfile modificat desapareix amb el runner. En CI, sempre npm ci.

Error 2: copiar el codi abans que el package.json al Dockerfile. Invalida la capa de dependències a cada canvi d'una línia de codi i converteix una build de 40 segons en una de 4 minuts.

Error 3: no tenir .dockerignore. El context es dispara de mida, s'hi colen node_modules incompatibles i, en el pitjor cas, un fitxer amb credencials queda dins de la imatge per sempre. Error 4: executar el contenidor com a root, que és el valor per defecte: sense USER node, el teu procés de producció té privilegis que no necessita.

Error 5: claus de memòria cau que no depenen del contingut. La fallada que fa que el pipeline verifiqui una versió del codi que no existeix. Si algun cop sospites de la memòria cau, esborra-la abans de continuar investigant.

Consell 1: construeix una vegada, prova moltes. Encara que en aquest mòdul el job build construeixi per verificar, la meta —lliçó 02-06— és que existeixi un sol artefacte per commit i que tota la resta hi operi a sobre.

Consell 2: mesura la mida de la imatge i posa-li un sostre. Una imatge que creix de 180 MB a 900 MB gairebé sempre significa que s'hi han colat dependències de desenvolupament; un docker images al registre n'hi ha prou per detectar-ho. I prova el Dockerfile en local abans de pujar-lo: docker build -f apps/api/Dockerfile -t prova . costa un minut i evita cinc execucions vermelles.

Exercicis

Exercici 1

Ordena les instruccions següents d'un Dockerfile d'una sola etapa per maximitzar l'aprofitament de la memòria cau, i explica el criteri:

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

Exercici 2

El pipeline d'un equip fa servir aquesta configuració de memòria cau:

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

Descriu la fallada concreta que es produirà i per què és especialment difícil de diagnosticar. Proposa la correcció.

Exercici 3

La imatge final d'apps/api pesa 1,1 GB. Enumera quatre causes probables ordenades per impacte i la correcció de cadascuna.

Solucions

Solució 1. Ordre correcte:

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

El criteri és del que canvia menys al que canvia més. Els package.json canvien poques vegades per setmana; el codi, diverses vegades al dia. En copiar primer només els manifestos i executar npm ci immediatament després, la capa de dependències només s'invalida quan canvien les dependències de debò. Amb COPY . . abans d'npm ci, qualsevol canvi en qualsevol fitxer obligaria a reinstal·lar-ho tot.

Solució 2. La clau moduls-node és fixa: no depèn del contingut del lockfile. La primera execució desa els node_modules d'aquell moment; a partir d'aquí, totes les execucions restauren aquesta mateixa còpia encara que el lockfile canviï. El pipeline provarà i construirà amb dependències antigues.

És difícil de diagnosticar perquè el pipeline és verd: no hi ha cap error, simplement es verifica un codi que no és el que es desplegarà. El símptoma apareix molt més tard i en un altre lloc —una funció que no existeix en producció, una fallada que en local no es reprodueix—, sense cap pista que apunti a la memòria cau.

La correcció és posar en memòria cau ~/.npm (la memòria cau de descàrregues, no node_modules) amb una clau derivada del contingut: key: npm-${{ hashFiles('package-lock.json') }}. I a Reservalia, encara millor: cache: npm a actions/setup-node, que ja fa exactament això.

Solució 3. Per impacte:

  1. Dependències de desenvolupament a la imatge final: TypeScript, Vitest i ESLint pesen centenars de megues. Correcció: npm ci --omit=dev en una etapa a part i copiar només aquests node_modules, com fa l'etapa 3 de l'exemple.
  2. No fer servir construcció multietapa: tot el codi font, les eines de compilació i les capes intermèdies queden a la imatge. Correcció: separar build de runtime i copiar únicament dist/.
  3. Imatge base pesant: node:20.11.0 completa ronda el gigabyte davant dels ~200 MB de la variant slim. Correcció: fer servir -slim.
  4. Manca de .dockerignore: s'hi colen node_modules locals, .git complet i fitxers de cobertura. Correcció: afegir-lo amb les entrades de l'apartat 6.

Conclusió

Reservalia ja construeix de manera automàtica i reproduïble:

  • Construir és transformar el codi en l'artefacte executable amb una sola ordre, sense estat previ i fallant sorollosament quan alguna cosa va malament. Si cal explicar el procediment de paraula, encara no està automatitzat.
  • Al monorepo, npm resol l'ordre topològic a partir de les dependències declarades: tipus-compartits abans que api i web. El pipeline no ha de conèixer aquest ordre.
  • La reproduïbilitat es recolza en tres pilars: el lockfile versionat, npm ci en comptes de npm install i el runtime fixat pel .nvmrc. Els enemics són sempre els mateixos: versions flotants, fitxers no versionats, descàrregues durant la build i restes d'execucions anteriors.
  • El Dockerfile multietapa d'apps/api separa instal·lació, construcció, dependències de producció i execució; la imatge final només porta Node fixat a 20.11.0, les dependències de producció i dist/, corre amb USER node i va acompanyada d'un .dockerignore.
  • Hi ha dues memòries cau diferents —dependències i capes de Docker— i una regla que no es negocia: la clau s'ha de derivar del contingut. Una memòria cau mal invalidada produeix un pipeline verd que verifica codi que no existeix.
  • El job build ja forma part del ci.yml: construeix els tres paquets, comprova que els artefactes existeixen i construeix la imatge sense publicar-la.

Tenim, doncs, una cosa que es construeix. Falta respondre a la pregunta de si funciona, i respondre-la amb rigor. A la lliçó següent, Proves Automatitzades, veurem la piràmide de proves aplicada al pipeline —què s'executa a cada PR i què no—, escriurem amb Vitest una prova unitària de la lògica de disponibilitat de cites i una d'integració contra el PostgreSQL del services:, parlarem de cobertura sense convertir-la en un objectiu, i posarem nom i política al destructor més gran de confiança en un pipeline: les proves inestables.

Curs de CI/CD: Integració i Desplegament Continu

Mòdul 1: Introducció al CI/CD

Mòdul 2: Integració Contínua (CI)

Mòdul 3: Desplegament Continu (CD)

Mòdul 4: Pràctiques Avançades de CI/CD

Mòdul 5: Implementació de CI/CD en Projectes Reals

Mòdul 6: Eines i Tecnologies

Mòdul 7: Exercicis Pràctics

Mòdul 8: Recursos Addicionals

© Copyright 2026. Tots els drets reservats