La taula amb què vam tancar 05-04 —què s'executa en desar, al pull request, en desplegar i en producció— és una llista de bones intencions mentre algú s'hagi de recordar d'executar-la. I ningú no se'n recorda un divendres a les set de la tarda amb una correcció urgent entre mans.

Aquesta lliçó converteix aquella llista en maquinària. Empaquetarem l'API de la Botiga Aroma en una imatge Docker que arrenca a qualsevol lloc, aixecarem l'entorn complet amb Redis en una ordre, i escriurem la canalització de GitHub Actions que executa en ordre el linting, Spectral, les proves amb cobertura, npm audit, oasdiff, la construcció de la imatge i Newman contra preproducció. Després veurem el que de debò distingeix un equip que desplega amb tranquil·litat d'un que desplega amb por: migracions retrocompatibles, estratègies de desplegament sense talls, i un pla de retorn enrere que funcioni.

Tot el que vam construir al mòdul 4 reapareix aquí amb un paper operatiu: /salut i /salut/preparat decideixen quan entra el trànsit, les mètriques i els SLO de 04-07 decideixen si el desplegament continua o es reverteix, i l'aturada ordenada de 03-07 és el que permet desplegar sense tallar peticions a mitges.

Contingut

  1. Integració contínua, lliurament continu i desplegament continu
  2. Per què la branca principal sempre ha d'estar desplegable
  3. Empaquetar amb Docker: el Dockerfile multietapa
  4. .dockerignore, capes i mida de la imatge
  5. L'entorn complet amb docker-compose.yml
  6. Configuració per entorn i secrets fora de la imatge
  7. La canalització de CI: .github/workflows/ci.yml
  8. Matriu de versions i memòria cau de dependències
  9. Què fa fallar la canalització i per què les portes són inflexibles
  10. Construir i publicar la imatge
  11. Migracions: expandir, migrar, contraure
  12. Estratègies de desplegament
  13. El paper de liveness i readiness
  14. Retorn enrere i feature flags
  15. On desplegar
  16. Versionat d'artefactes i traçabilitat
  17. Després del desplegament: proves de fum i vigilància
  18. Secrets a CI i mínim privilegi

  1. Integració contínua, lliurament continu i desplegament continu

Tres termes que es fan servir com a sinònims i no ho són:

Integració contínua (CI) Lliurament continu (CD) Desplegament continu
Què automatitza Construir i provar cada canvi Produir un artefacte desplegable sempre Desplegar a producció sense intervenció
Freqüència Cada push Cada fusió a main Cada fusió a main
Hi ha un botó humà? No s'aplica : algú decideix quan No
Requisit previ Proves automàtiques fiables CI sòlida + entorns reproduïbles CD + observabilitat + retorn enrere automàtic
Risc si falta Branques que divergeixen setmanes Desplegaments manuals i fràgils Cap: és una elecció legítima

On és la Botiga Aroma. Integració contínua completa, lliurament continu a preproducció de manera automàtica, i desplegament a producció amb un botó. És la configuració adequada per al punt on som, i mereix justificació: el desplegament continu a producció exigeix que el retorn enrere sigui automàtic i que l'observabilitat detecti una degradació en minuts. Tenim el segon des de 04-07; el primer arribarà quan el canary de l'apartat 12 estigui muntat. Adoptar desplegament continu abans de tenir aquestes dues peces no és maduresa, és imprudència.

La paraula que importa als tres termes és contínua: petit i freqüent. Un desplegament de deu canvis petits té deu oportunitats de fallar per separat i cada fallada és trivial de localitzar. Un desplegament trimestral amb dos-cents canvis falla una vegada i ningú no sap quin dels dos-cents va ser.

  1. Per què la branca principal sempre ha d'estar desplegable

La regla és senzilla d'enunciar i difícil de sostenir: qualsevol commit de main ha de poder desplegar-se a producció en aquest moment. D'ella se'n deriven diverses pràctiques:

  • Branques curtes. Una branca de tres setmanes garanteix un conflicte dolorós i una revisió impossible de fer bé. Un o dos dies és el raonable.
  • Portes abans de fusionar, no després. Si la canalització s'executa després del merge, main està trencat mentre algú l'arregla, i tot l'equip queda bloquejat.
  • Funcionalitat incompleta sota bandera. Quan una cosa no està llesta, es fusiona desactivada amb un feature flag en lloc de viure en una branca a part. És l'única alternativa real a les branques llargues.
  • Arreglar la canalització és prioritat absoluta. Una canalització vermella que ningú no arregla en una hora deixa de ser un senyal i es converteix en soroll; a partir d'aquí, la gent fusiona en vermell i tot el sistema perd el seu valor.

I una conseqüència menys òbvia: la protecció de la branca ha de ser tècnica, no cultural. A GitHub, això són regles de protecció de branca amb les comprovacions marcades com a obligatòries. Un acord verbal es trenca el dia d'una urgència; una regla configurada, no.

  1. Empaquetar amb Docker: el Dockerfile multietapa

Un contenidor resol el problema més antic de l'ofici: que l'aplicació es comporti igual al teu portàtil, a CI i en producció. Empaqueta el codi, les seves dependències i el runtime en una imatge immutable.

Fitxer nou Dockerfile a l'arrel del projecte:

# syntax=docker/dockerfile:1.7

# ============================================================================
# ETAPA 1 — dependències de construcció
# Instal·la TOTES les dependències (incloses les de desenvolupament) perquè
# better-sqlite3 és un mòdul natiu i s'ha de compilar.
# ============================================================================
FROM node:20-bookworm-slim AS dependencies

# Eines de compilació per als mòduls natius. Només viuen en aquesta
# etapa: no arriben a la imatge final, que és tot el sentit del multietapa.
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 make g++ \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copiem NOMÉS els manifestos abans que el codi font.
# Docker desa a la memòria cau per capes: mentre package*.json no canviï, el npm ci
# de sota es reutilitza encara que hagis tocat cent fitxers de src/.
COPY package.json package-lock.json ./

# npm ci (no npm install): instal·la EXACTAMENT el del lock, és reproduïble
# i falla si el lock i el package.json no concorden. És el que es vol a CI.
RUN npm ci

# ============================================================================
# ETAPA 2 — dependències de producció
# Reinstal·la només el necessari per executar. Redueix molt la imatge final
# i, sobretot, la superfície d'atac: menys codi, menys CVE.
# ============================================================================
FROM dependencies AS dependencies-produccio
RUN npm ci --omit=dev

# ============================================================================
# ETAPA 3 — proves (opcional, s'invoca amb --target proves)
# Permet executar el conjunt de proves dins de la mateixa imatge que es desplegarà,
# eliminant el "a la meva màquina passava".
# ============================================================================
FROM dependencies AS proves
COPY . .
RUN npm run lint && npm test

# ============================================================================
# ETAPA 4 — imatge final d'execució
# ============================================================================
FROM node:20-bookworm-slim AS produccio

# NODE_ENV=production canvia el comportament d'Express (vistes a la memòria cau,
# stack traces fora de les respostes) i de moltes biblioteques. És obligatori.
ENV NODE_ENV=production \
    PORT=3000 \
    NPM_CONFIG_UPDATE_NOTIFIER=false

# tini és un init mínim: reemet els senyals al procés fill i recull els
# processos zombis. Sense ell, un SIGTERM pot no arribar mai a Node i
# l'aturada ordenada de 03-07 no s'executa.
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Usuari no root: la imatge de Node ja porta l'usuari "node" (uid 1000).
# Executar com a root dins del contenidor és un risc innecessari: si algú
# aconsegueix execució de codi, comença amb tots els privilegis del contenidor.
# --chown evita un "chown -R" posterior, que duplicaria la capa sencera.
COPY --chown=node:node --from=dependencies-produccio /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src/ ./src/
COPY --chown=node:node migracions/ ./migracions/
COPY --chown=node:node openapi.yaml ./

USER node

EXPOSE 3000

# HEALTHCHECK fa servir l'endpoint de LIVENESS de 04-07, no el de readiness:
# aquí preguntem "el procés està viu?", no "pot atendre trànsit?".
# Si féssim servir /salut/preparat, una caiguda de Redis reiniciaria el contenidor
# en bucle en lloc de simplement treure'l del balancejador.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

# tini com a PID 1; el procés Node és el seu fill i rep els senyals netament.
ENTRYPOINT ["/usr/bin/tini", "--"]

# Forma "exec" (array), MAI forma shell. Amb `CMD node src/servidor.js` en
# forma shell, el PID 1 seria /bin/sh i SIGTERM no arribaria a Node.
CMD ["node", "src/servidor.js"]

Les cinc decisions que més importen d'aquell fitxer:

  1. Multietapa. Les eines de compilació (python3, make, g++, uns 300 MB) es queden a l'etapa 1. La imatge final només porta runtime i dependències de producció.
  2. npm ci --omit=dev. Fora supertest, eslint, prettier, Spectral, autocannon, Prism i Newman. Menys mida i, sobretot, menys superfície d'atac.
  3. Usuari node, no root. Combinat amb readOnlyRootFilesystem a l'orquestrador, tanca bona part dels camins d'escalada.
  4. tini + forma exec. És el que fa que SIGTERM arribi a Node i s'executi el tancarOrdenadament() de 03-07. Sense això, cada desplegament talla peticions a mitges i els clients veuen errors de xarxa que no apareixen en cap log.
  5. HEALTHCHECK sobre liveness. La distinció de 04-07 té aquí la seva conseqüència pràctica: confondre els dos endpoints provoca reinicis en cascada quan falla una dependència.

Comprovació local:

docker build -t botiga-aroma-api:local .
docker run --rm -p 3000:3000 --env-file .env.local botiga-aroma-api:local

# Verificar l'aturada ordenada: ha de sortir en menys d'un segon,
# no als 10 s del timeout forçat de Docker.
docker stop $(docker ps -q --filter ancestor=botiga-aroma-api:local)

Si docker stop triga deu segons, el SIGTERM no està arribant. És la comprovació més útil i la que gairebé ningú no fa.

  1. .dockerignore, capes i mida de la imatge

Fitxer nou .dockerignore:

# Mai no entren a la imatge
node_modules
npm-debug.log*
.git
.github
.env
.env.*
*.local.json

# Artefactes de desenvolupament i proves
proves/
cobertura/
informes/
postman/
docs/
*.md
!README.md

# Base de dades local i temporals
dades/
*.sqlite
*.sqlite-journal
.DS_Store

Tres motius, per ordre d'importància:

  • Seguretat. Sense .dockerignore, un COPY . . fica el teu .env amb les claus reals dins d'una imatge que potser acaba en un registre compartit. És una de les fuites de credencials més freqüents que existeixen.
  • Correcció. Copiar el teu node_modules local amb binaris compilats per a macOS a una imatge Linux produeix fallades incomprensibles.
  • Velocitat i mida. .git pot pesar centenars de megues i s'envia sencer al dimoni de Docker a cada construcció.

Sobre les capes: cada instrucció crea una capa, i Docker les desa a la memòria cau. L'ordre del Dockerfile no és estètic, és una estratègia de memòria cau: el que canvia poc a dalt, el que canvia molt a baix. Copiar package*.json abans que src/ significa que un canvi de codi reutilitza el npm ci, que és la instrucció cara. A l'inrevés, cada construcció reinstal·laria tot.

Referència de mides aproximades per triar base:

Base Mida de la imatge final Notes
node:20 ~1,1 GB Debian complet. Només si necessites moltes eines.
node:20-bookworm-slim ~250 MB La nostra. Bon equilibri; glibc, sense sorpreses amb mòduls natius.
node:20-alpine ~180 MB musl en lloc de glibc: pot donar problemes amb mòduls natius com better-sqlite3.
gcr.io/distroless/nodejs20 ~170 MB Sense shell ni gestor de paquets: màxima seguretat, depuració incòmoda.

La recomanació per a la Botiga Aroma és bookworm-slim: Alpine estalvia 70 MB i et pot costar una tarda compilant better-sqlite3 contra musl. Distroless és una excel·lent elecció quan l'equip té maduresa operativa, però entrar al contenidor a depurar deixa de ser una opció.

I un consell de seguretat que encaixa amb 04-02: escaneja la imatge.

# Vulnerabilitats conegudes a la imatge construïda
docker scout cves botiga-aroma-api:local
# o
trivy image botiga-aroma-api:local --severity HIGH,CRITICAL

  1. L'entorn complet amb docker-compose.yml

Fitxer nou docker-compose.yml:

# docker-compose.yml — entorn complet de la Botiga Aroma per a desenvolupament i proves
services:
  api:
    build:
      context: .
      target: produccio
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      PORT: 3000
      # A Compose els serveis es resolen pel seu nom: "redis" és un host.
      REDIS_URL: redis://redis:6379
      RUTA_BASE_DADES: /dades/aroma.sqlite
      # Els secrets NO van aquí en text: arriben del fitxer .env local,
      # que és al .gitignore i al .dockerignore.
      JWT_SECRET: ${JWT_SECRET:?falta JWT_SECRET al .env}
      ORIGENS_PERMESOS: http://localhost:5173,http://localhost:4173
      NIVELL_LOG: debug
    volumes:
      # Volum amb nom perquè la base sobrevisqui a docker compose down
      - dades-api:/dades
    depends_on:
      redis:
        condition: service_healthy      # no arrenca fins que Redis respon
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/salut').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 15s
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    # appendonly sí: el rate limiting de 04-04 i la memòria cau de 04-06 toleren
    # perdre dades, però en desenvolupament és còmode que sobrevisquin al reinici.
    command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
    ports:
      - "6379:6379"
    volumes:
      - dades-redis:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  # Mock del contracte (05-04): permet a la SPA treballar sense dependre de l'API.
  mock:
    image: stoplight/prism:5
    command: ["mock", "-h", "0.0.0.0", "-p", "4010", "--errors", "/tmp/openapi.yaml"]
    ports:
      - "4010:4010"
    volumes:
      - ./openapi.yaml:/tmp/openapi.yaml:ro
    profiles: ["desenvolupament"]     # només arrenca amb --profile desenvolupament

  # PostgreSQL: esmentat aquí perquè és el destí natural quan SQLite
  # es queda curt. Migrar exigeix canviar només src/repositoris/*, gràcies al
  # patró repositori de 03-05; la resta del projecte no se n'assabenta.
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: aroma
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clau-local-ficticia}
      POSTGRES_DB: aroma
    ports:
      - "5432:5432"
    volumes:
      - dades-postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aroma"]
      interval: 5s
      retries: 5
    profiles: ["postgres"]

volumes:
  dades-api:
  dades-redis:
  dades-postgres:

Ús quotidià:

docker compose up -d --wait                     # aixeca API i Redis, espera que estiguin sans
docker compose --profile desenvolupament up -d  # afegeix el mock de Prism
docker compose logs -f api                      # segueix els logs de pino
docker compose exec api npm run migrar          # executa una migració dins del contenidor
docker compose down -v                          # destrueix tot, volums inclosos

Dos detalls que eviten hores de depuració:

  • condition: service_healthy a depends_on. Sense ell, depends_on només espera que el contenidor arrenqui, no que el servei funcioni, i l'API intenta connectar a un Redis que encara no accepta connexions.
  • ${JWT_SECRET:?falta ...}. Aquella sintaxi fa que Compose falli amb un missatge clar si la variable no està definida, en lloc d'arrencar amb un secret buit. Arrencar amb un secret buit és pitjor que no arrencar.

I un fitxer addicional docker-compose.proves.yml, que és el que fan servir les proves d'extrem a extrem de 05-04: igual però amb NODE_ENV=test, base de dades efímera (tmpfs, sense volum persistent) i sense ports exposats tret del de l'API.

  1. Configuració per entorn i secrets fora de la imatge

El principi, pres dels Twelve-Factor App: una imatge, molts entorns. La mateixa imatge que va passar les proves és la que va a preproducció i després a producció, sense reconstruir. Si reconstrueixes per a cada entorn, no estàs desplegant el que vas provar.

Tot el que varia entre entorns són variables d'entorn:

Variable desenvolupament proves preproducció producció
NODE_ENV development test production production
NIVELL_LOG debug silent info info
RUTA_BASE_DADES fitxer local :memory: volum gestionada
REDIS_URL redis://redis:6379 redis://redis:6379 interna gestionada amb TLS
JWT_SECRET fictici al .env fictici fix del gestor de secrets del gestor de secrets
ORIGENS_PERMESOS localhost:5173 localhost api-proves.botigaaroma.example botigaaroma.example, panel.…
LIMIT_GLOBAL_PER_MINUT 10000 10000 600 600
DOCS_PUBLIQUES true true true false
MOSTREIG_TRACES 1.0 0 1.0 0.05

Quatre regles sobre secrets que no admeten excepció:

  1. Cap secret dins de la imatge. Ni al Dockerfile, ni amb ARG —els ARG queden a l'historial de capes i es veuen amb docker history—, ni en un fitxer copiat.
  2. Cap secret al repositori. .env al .gitignore; .env.example amb les claus i valors ficticis, versionat, per documentar què cal.
  3. Els secrets s'injecten en temps d'execució per l'orquestrador, des del seu gestor: GitHub Secrets, AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, Kubernetes Secrets.
  4. src/config/entorn.js valida en arrencar. Ja el vam escriure a 03-01, i aquí es veu per què importa: si falta JWT_SECRET, el procés ha de morir immediatament amb un missatge clar, no arrencar i fallar a la primera petició autenticada. Fallar ràpid i sorollosament a l'arrencada és el que fa que un desplegament mal configurat no arribi a rebre trànsit.

  1. La canalització de CI: .github/workflows/ci.yml

Fitxer nou .github/workflows/ci.yml:

name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

# Cancel·la execucions anteriors de la mateixa branca: si fas tres push seguits,
# només s'executa l'últim. Estalvia minuts de CI i dona retroalimentació abans.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

# Permisos mínims per defecte (apartat 18). Cada treball demana el que necessita.
permissions:
  contents: read

env:
  NODE_VERSION_PRINCIPAL: '20'

jobs:
  # --------------------------------------------------------------------------
  # 1. Qualitat estàtica: ràpida i sense dependències externes. Falla en 30 s.
  # --------------------------------------------------------------------------
  qualitat:
    name: Lint i format
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
          cache: npm            # desa ~/.npm fent servir package-lock.json com a clau

      - name: Instal·lar dependències
        run: npm ci

      - name: ESLint
        run: npm run lint

      - name: Prettier (comprovació, no escriptura)
        run: npx prettier --check .

  # --------------------------------------------------------------------------
  # 2. El contracte: validesa estructural + guia d'estil + canvis trencadors.
  #    S'executa en paral·lel amb les proves perquè no en depèn.
  # --------------------------------------------------------------------------
  contracte:
    name: Validació del contracte
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # necessitem l'historial per comparar amb main

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
          cache: npm

      - run: npm ci

      - name: És un document OpenAPI vàlid? (05-02)
        run: npx swagger-cli validate openapi.yaml

      - name: Compleix la guia d'estil? (Spectral, 04-01)
        run: npx spectral lint openapi.yaml --fail-severity=error

      - name: Instal·lar oasdiff
        run: |
          curl -fsSL https://raw.githubusercontent.com/oasdiff/oasdiff/main/install.sh | sh

      - name: Introdueix canvis trencadors? (05-04)
        if: github.event_name == 'pull_request'
        run: bash eines/comprovar-contracte.sh origin/main

  # --------------------------------------------------------------------------
  # 3. Proves: unitàries + integració + contracte, en diverses versions de Node.
  # --------------------------------------------------------------------------
  proves:
    name: Proves (Node ${{ matrix.node }})
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false          # que fallin totes les que hagin de fallar
      matrix:
        node: ['20', '22']      # LTS actual i la següent: detecta trencades aviat

    services:
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm

      - run: npm ci

      - name: Migrar i sembrar la base de proves
        run: npm run bd:reiniciar
        env:
          RUTA_BASE_DADES: ':memory:'

      - name: Proves amb cobertura
        run: node --test --experimental-test-coverage proves/
        env:
          NODE_ENV: test
          REDIS_URL: redis://localhost:6379
          JWT_SECRET: secret-fictici-nomes-per-a-ci
          NIVELL_LOG: silent

      - name: Publicar l'informe de cobertura
        if: matrix.node == '20'
        uses: actions/upload-artifact@v4
        with:
          name: cobertura
          path: cobertura/
          retention-days: 7

  # --------------------------------------------------------------------------
  # 4. Seguretat de dependències (04-02).
  # --------------------------------------------------------------------------
  seguretat:
    name: Auditoria de dependències
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
          cache: npm

      - run: npm ci

      # Falla amb vulnerabilitats altes o crítiques. Les moderades es revisen
      # però no bloquegen: si bloquegessin, la canalització estaria trencada
      # permanentment per dependències transitives i l'equip aprendria a ignorar-la.
      - name: npm audit
        run: npm audit --audit-level=high

      - name: Comprovar que el lock està sincronitzat
        run: |
          npm ci --dry-run 2>&1 | tee /tmp/sortida
          ! grep -q "npm warn" /tmp/sortida || echo "Revisar avisos d'npm"

  # --------------------------------------------------------------------------
  # 5. Imatge: només si tot l'anterior ha passat. Al PR es construeix però no es
  #    publica; a main es publica etiquetada amb el SHA del commit.
  # --------------------------------------------------------------------------
  imatge:
    name: Construir i publicar la imatge
    needs: [qualitat, contracte, proves, seguretat]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write           # necessari per empènyer al registre
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Autenticar-se al registre
        if: github.ref == 'refs/heads/main'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Etiquetes i metadades
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}/api
          tags: |
            type=sha,format=long          # l'etiqueta = el commit: traçabilitat
            type=ref,event=branch
            type=semver,pattern={{version}}

      - name: Construir (i publicar només a main)
        uses: docker/build-push-action@v6
        with:
          context: .
          target: produccio
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Escanejar la imatge
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}/api:sha-${{ github.sha }}
          severity: 'HIGH,CRITICAL'
          exit-code: '1'
          ignore-unfixed: true    # sense pedaç disponible, no hi ha res a fer

  # --------------------------------------------------------------------------
  # 6. Desplegament a preproducció i verificació (només a main).
  # --------------------------------------------------------------------------
  preproduccio:
    name: Desplegar a preproducció i verificar
    needs: [imatge]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: preproduccio
      url: https://api-proves.botigaaroma.example
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION_PRINCIPAL }}
          cache: npm
      - run: npm ci

      - name: Aplicar migracions (abans del desplegament, apartat 11)
        run: npm run migrar
        env:
          URL_BASE_DADES: ${{ secrets.URL_BASE_DADES_PREPRODUCCIO }}

      - name: Desplegar la imatge
        run: ./eines/desplegar.sh preproduccio "sha-${{ github.sha }}"
        env:
          TOKEN_DESPLEGAMENT: ${{ secrets.TOKEN_DESPLEGAMENT_PREPRODUCCIO }}

      - name: Esperar que el readiness estigui verd (04-07)
        run: |
          for intent in $(seq 1 30); do
            if curl -fsS https://api-proves.botigaaroma.example/salut/preparat; then
              echo "Llest després de ${intent} intents."; exit 0
            fi
            sleep 5
          done
          echo "El servei no ha arribat a estar preparat en 150 s."; exit 1

      - name: Proves d'extrem a extrem (05-04)
        run: node --test proves/e2e/
        env:
          URL_API: https://api-proves.botigaaroma.example/v1
          EMAIL_PROVA: ${{ secrets.EMAIL_PROVA }}
          CLAU_PROVA: ${{ secrets.CLAU_PROVA }}

      - name: Col·lecció de Postman amb Newman (05-01)
        run: |
          npx newman run postman/botiga-aroma-v1.postman_collection.json \
            -e postman/proves.postman_environment.json \
            --env-var "clauClient=${{ secrets.CLAU_PROVA }}" \
            --delay-request 100 \
            --reporters cli,junit \
            --reporter-junit-export informes/newman.xml

      - name: Publicar l'informe de Newman
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: informe-newman
          path: informes/newman.xml

  1. Matriu de versions i memòria cau de dependències

La matriu. matrix: node: ['20', '22'] executa les proves dues vegades. El cost és el doble de temps de CI; el benefici, descobrir amb mesos d'antelació que alguna cosa es trenca a la LTS següent, quan encara hi ha temps d'arreglar-ho amb calma en lloc de sota la pressió d'un final de suport.

fail-fast: false és important: per defecte, GitHub cancel·la la resta de la matriu a la primera fallada, i aleshores no saps si el problema és de Node 22 o del teu codi. Amb false veus tots dos resultats.

Quan val la pena la matriu: a les biblioteques, gairebé sempre; en una aplicació desplegada, quan la migració de versió major és previsible i t'hi vols anticipar. A la Botiga Aroma sí, perquè desplegem en Node 20 i la 22 serà la LTS següent.

La memòria cau. cache: npm a setup-node desa el directori ~/.npm fent servir el hash de package-lock.json com a clau. Efecte típic: npm ci baixa de 60-90 segons a 10-15.

Un matís que confon: desa la descàrrega, no node_modules. És deliberat. Desar node_modules és una font clàssica de fallades fantasma —mòduls natius compilats per a una altra versió, restes d'instal·lacions anteriors— i no és reproduïble. npm ci esborra node_modules i reinstal·la des de zero; l'única cosa que s'estalvia és la xarxa.

Per a la imatge, la memòria cau és diferent: cache-from: type=gha desa les capes de Docker a la memòria cau de GitHub Actions, de manera que l'etapa cara —npm ci amb compilació de better-sqlite3— es reutilitza mentre package-lock.json no canviï.

  1. Què fa fallar la canalització i per què les portes són inflexibles

Porta Falla si Bloqueja el merge? Per què
ESLint Qualsevol error Els avisos ja estan filtrats; un error és un error
Prettier Un fitxer sense formatar Format automàtic: no hi ha excusa ni discussió
swagger-cli validate El contracte no és OpenAPI vàlid Trenca la documentació i tots els generadors
Spectral Alguna regla error És la guia d'estil acordada (04-01)
oasdiff breaking Hi ha canvis trencadors , tret d'etiqueta explícita Trenca l'Aroma Mòbil, i és permanent
Proves Una sola falla Evident
Cobertura Baixa del llindar acordat Sí, però amb criteri Vegeu més avall
npm audit Vulnerabilitat alta o crítica Amb --audit-level=high: les moderades no bloquegen
Trivy CVE alta o crítica amb pedaç ignore-unfixed: sense pedaç no hi ha acció possible
E2E a preproducció Una falla , no promociona a producció És l'última xarxa abans dels clients

Per què la inflexibilitat importa. Una porta que es pot saltar «només aquesta vegada» deixa de ser una porta al tercer «només aquesta vegada». La regla que funciona és: si una porta molesta, es canvia la regla mitjançant un pull request que la modifiqui —discutit i visible— no es salta en una execució concreta. Aquell és exactament el procediment de Spectral de 04-01: una regla entra com a warn, es netegen les infraccions i després puja a error.

L'únic mecanisme d'escapament legítim és explícit, deixa rastre i exigeix justificació. Per a oasdiff:

      - name: Introdueix canvis trencadors?
        if: >
          github.event_name == 'pull_request' &&
          !contains(github.event.pull_request.labels.*.name, 'canvi-trencador')
        run: bash eines/comprovar-contracte.sh origin/main

Posar l'etiqueta canvi-trencador és un acte deliberat, visible al pull request, que obliga a explicar-se i que a més pot exigir l'aprovació d'algú concret mitjançant CODEOWNERS.

Sobre la cobertura. Un llindar és útil com a xarxa contra el descuit —«no baixem del 80 %»— i pervers com a objectiu: perseguir el 100 % produeix proves que executen codi sense comprovar res. La configuració sensata és exigir que la cobertura no baixi respecte de main, en lloc d'un número absolut.

  1. Construir i publicar la imatge

El treball imatge construeix sempre i publica només des de main. Dues conseqüències bones: els pull requests verifiquen que el Dockerfile continua funcionant —una fallada de construcció es detecta a la revisió, no en fusionar— i el registre no s'omple d'imatges de branques efímeres.

Les etiquetes que produeix docker/metadata-action:

ghcr.io/botigaaroma/api:sha-3f9a2c1e8b474d2a9e0177c6b5d3a8129e4c1b2d
ghcr.io/botigaaroma/api:main
ghcr.io/botigaaroma/api:1.7.0        (si el commit porta una etiqueta de versió)

L'etiqueta que es desplega és sempre la del SHA. Mai latest, mai main. Motiu: són etiquetes mòbils. Si desplegues main i demà cal investigar què hi havia en producció el dimarts, la resposta és «no se sap». Amb sha-3f9a2c… la resposta és un commit exacte, amb el seu diff, el seu autor i el seu pull request. És el principi de traçabilitat de l'apartat 16.

cache-from/cache-to: type=gha reutilitza les capes entre execucions. Sense això, cada construcció recompila better-sqlite3 des de zero: dos o tres minuts per execució que es converteixen en vint segons.

  1. Migracions: expandir, migrar, contraure

Aquí hi ha el problema més subestimat del desplegament continu. Durant un desplegament sense talls conviuen dues versions del codi sobre una sola base de dades. Si la migració no és compatible amb totes dues, hi ha errors garantits.

Escenari concret. Volem reanomenar notes_tast a notes a la taula cafes.

La forma ingènua, que trenca:

-- migracions/013-reanomenar-notes.sql  ← NO FACIS AIXÒ
ALTER TABLE cafes RENAME COLUMN notes_tast TO notes;

Seqüència dels fets: la migració s'aplica; durant els dos minuts següents, les instàncies amb el codi antic continuen atenent trànsit i executen SELECT notes_tast FROM cafes; aquella columna ja no existeix; cada petició al catàleg retorna 500 fins que acaba el desplegament. I si cal revertir, el codi antic tampoc no funciona: has perdut el retorn enrere.

La forma correcta: expandir → migrar → contraure. Tres desplegaments separats.

-- PAS 1 — EXPANDIR (desplegament 1). Només afegeix. Compatible amb tot.
ALTER TABLE cafes ADD COLUMN notes TEXT;
UPDATE cafes SET notes = notes_tast WHERE notes IS NULL;
// Codi del desplegament 1: escriu a TOTES DUES, llegeix de l'antiga.
export function desarCafe(cafe) {
  bd.prepare(`
    UPDATE cafes SET notes_tast = ?, notes = ? WHERE id = ?
  `).run(JSON.stringify(cafe.notesTast), JSON.stringify(cafe.notesTast), cafe.id);
}

export function llegirCafe(id) {
  const fila = bd.prepare('SELECT * FROM cafes WHERE id = ?').get(id);
  return { ...fila, notesTast: JSON.parse(fila.notes_tast) };  // encara l'antiga
}
// PAS 2 — MIGRAR (desplegament 2). Escriu a totes dues, LLEGEIX DE LA NOVA.
// Si alguna cosa va malament, es reverteix al desplegament 1 sense perdre dades,
// perquè totes dues columnes estan poblades i sincronitzades.
export function llegirCafe(id) {
  const fila = bd.prepare('SELECT * FROM cafes WHERE id = ?').get(id);
  return { ...fila, notesTast: JSON.parse(fila.notes ?? fila.notes_tast) };
}
-- PAS 3 — CONTRAURE (desplegament 3, dies o setmanes després).
-- Només quan CAP instància amb codi antic no pugui estar viva
-- i el retorn enrere a aquella versió ja no sigui una opció realista.
ALTER TABLE cafes DROP COLUMN notes_tast;

Regles pràctiques de migració:

Operació És segura durant un desplegament? Nota
ADD COLUMN amb valor per defecte o nul La forma segura d'afegir
ADD COLUMN NOT NULL sense defecte No Les escriptures del codi antic fallen
DROP COLUMN No Només a la fase de contracció
RENAME COLUMN No És un DROP disfressat. Expandir/contraure
CREATE INDEX Depèn A PostgreSQL, CONCURRENTLY; sense això bloqueja la taula
ALTER TYPE que estreny No Dades existents poden no cabre-hi
Afegir taula Ningú no la fa servir encara
Afegir restricció NOT NULL No Expandir: omplir, validar i després restringir

Quan s'apliquen. Abans de desplegar el codi nou, en un pas propi de la canalització, com al treball preproduccio de l'apartat 7. Mai en arrencar l'aplicació: amb tres instàncies arrencant alhora tindries tres processos migrant en paral·lel sobre la mateixa base. Si el teu sistema de migracions no pren un bloqueig, el resultat és impredictible.

I les migracions s'han de provar. Una prova que aplica totes les migracions sobre una base buida i comprova l'esquema resultant costa poc i evita el pitjor tipus d'incident: el que passa al pas previ al desplegament, quan encara no hi ha res desplegat a revertir.

  1. Estratègies de desplegament

Estratègia Com funciona Tall Cost Risc Retorn enrere Quan
Recreate Atura tot, arrenca el nou , segons o minuts Mínim Alt Redesplegar Desenvolupament; sistemes que toleren aturada
Rolling Substitueix instàncies per tandes No Mínim Mitjà Rolling invers, lent El cas per defecte
Blue-green Dos entorns complets; es commuta el trànsit No Doble infraestructura Baix Instantani Desplegaments crítics
Canary Un 1-5 % del trànsit a la versió nova; es puja si les mètriques aguanten No Mitjà Molt baix Automàtic per mètriques Alt volum; canvis arriscats
graph TD
    subgraph Rolling
    R1[3 instàncies v1] --> R2[2 v1 + 1 v2] --> R3[1 v1 + 2 v2] --> R4[3 instàncies v2]
    end
    subgraph BlueGreen
    B1[Blau v1 rep trànsit<br/>Verd v2 es desplega i s'escalfa] --> B2[Commutar el balancejador] --> B3[Verd v2 rep trànsit<br/>Blau v1 en espera per revertir]
    end
    subgraph Canary
    C1[100% a v1] --> C2[95% v1 i 5% v2<br/>vigilar errors i latència] --> C3{SLO en verd?}
    C3 -->|Sí| C4[50% i 50%, després 100% v2]
    C3 -->|No| C5[Tornada a 100% v1<br/>automàtica]
    end

Rolling és el mode per defecte de Kubernetes i de gairebé tots els orquestradors, i és una elecció sensata. La seva condició imprescindible és la de l'apartat anterior: durant la substitució conviuen totes dues versions, així que la base de dades i el contracte han de ser compatibles amb les dues.

Blue-green és molt tranquil·litzador —revertir és tornar a commutar el balancejador, segons— i car: durant el desplegament pagues el doble d'infraestructura. I compte: la base de dades no és blue-green. És compartida, així que les migracions continuen havent de ser retrocompatibles.

Canary és l'estratègia que fa viable el desplegament continu real, perquè uneix el desplegament amb l'observabilitat de 04-07: s'envia un percentatge petit de trànsit a la versió nova i es comparen automàticament la seva taxa d'error i la seva latència p99 contra les de la versió estable. Si es degraden, es reverteix sol. Requereix volum suficient perquè les mètriques siguin significatives —amb deu peticions per minut, un 5 % no diu res— i un gateway o malla que sàpiga repartir el trànsit, que és justament el que veurem a 05-06.

La connexió amb /v1 i /v2 de 02-07. Convé no confondre dues coses que s'assemblen:

  • Desplegar v1.7.0 sobre v1.6.0 és un desplegament: mateix contracte, codi nou. Aquí s'apliquen rolling, blue-green o canary.
  • Publicar /v2 al costat de /v1 és convivència de versions d'API: dos contractes diferents vius durant mesos, amb Deprecation i Sunset a /v1. No és una estratègia de desplegament, és una decisió de producte.

Es combinen: /v2 es desplega amb rolling com qualsevol altra versió, i totes dues rutes conviuen al mateix servei (o en serveis separats darrere del gateway, que és més net per poder retirar /v1 apagant alguna cosa).

  1. El paper de liveness i readiness

Els dos endpoints de 04-07 deixen de ser teoria tan bon punt hi ha un orquestrador al davant.

// src/app.js — recordatori de la distinció, posicions 10 i 11
// LIVENESS: el procés està viu? Res de dependències.
// Si respon malament, l'orquestrador MATA i REINICIA el contenidor.
app.get('/salut', (req, res) => res.json({ estat: 'viu' }));

// READINESS: puc atendre trànsit ARA? Comprova dependències.
// Si respon malament, l'orquestrador TREU la instància del balancejador,
// però NO la reinicia: potser Redis torna en deu segons.
app.get('/salut/preparat', async (req, res) => {
  const comprovacions = {
    baseDades: await comprovarBaseDades(),
    redis: await comprovarRedis(),
    migracionsAlDia: await comprovarMigracions(),
  };
  const llest = Object.values(comprovacions).every(Boolean);
  res.status(llest ? 200 : 503).json({ estat: llest ? 'preparat' : 'no_preparat', comprovacions });
});

Configuració equivalent a Kubernetes, que mostra com es fan servir:

        livenessProbe:
          httpGet: { path: /salut, port: 3000 }
          initialDelaySeconds: 10
          periodSeconds: 30
          failureThreshold: 3          # tres fallades seguides → reiniciar

        readinessProbe:
          httpGet: { path: /salut/preparat, port: 3000 }
          initialDelaySeconds: 5
          periodSeconds: 5             # més freqüent: reacciona ràpid
          failureThreshold: 2

        # startupProbe: dona marge a l'arrencada sense relaxar el liveness.
        # Fins que no passa, liveness i readiness no s'avaluen.
        startupProbe:
          httpGet: { path: /salut, port: 3000 }
          periodSeconds: 5
          failureThreshold: 30         # fins a 150 s per arrencar

        lifecycle:
          preStop:
            # Espera abans del SIGTERM: dona temps que el balancejador s'assabenti
            # que aquesta instància surt, evitant peticions encaminades a un
            # procés que ja s'està tancant. És la causa núm. 1 d'errors 502
            # durant desplegaments "sense talls".
            exec: { command: ["sleep", "5"] }
        terminationGracePeriodSeconds: 30

Els tres errors clàssics, que produeixen incidents molt difícils de diagnosticar:

  1. Fer servir readiness com a liveness. Redis cau, readiness respon 503, l'orquestrador reinicia tots els contenidors en bucle, i ara tens dos problemes.
  2. Un liveness que consulta la base de dades. Una consulta lenta fa fallar el liveness i reinicia una aplicació perfectament sana, agreujant la càrrega sobre la base.
  3. Oblidar el preStop. Sense ell, el balancejador pot continuar enviant peticions durant un o dos segons a un procés que ja ha rebut SIGTERM, i apareixen 502 esporàdics a cada desplegament que ningú no aconsegueix reproduir.

La cadena completa d'una aturada neta, unint 03-07 amb aquesta lliçó: preStop (5 s de marge) → SIGTERMtini el reemet a Node → tancarOrdenadament()servidor.close() deixa d'acceptar connexions noves i acaba les que hi ha en curs → es tanquen base de dades i Redis → process.exit(0). Si alguna cosa s'encalla, terminationGracePeriodSeconds acaba amb un SIGKILL als 30 segons.

  1. Retorn enrere i feature flags

Revertir el codi és fàcil. És tornar a desplegar l'etiqueta anterior:

./eines/desplegar.sh produccio "sha-<commit-anterior>"

Que sigui trivial depèn de tres coses que ja tenim: imatges immutables etiquetades per commit, cap configuració dins de la imatge, i migracions retrocompatibles. La tercera és la que sol faltar.

Revertir les dades és el problema difícil, i convé tenir-ho clar abans de necessitar-ho:

  • Si la versió nova va escriure dades amb un format que l'antiga no entén, revertir el codi no arregla res.
  • Si la migració va esborrar una columna, revertir el codi el deixa apuntant a una cosa que ja no existeix.
  • Restaurar una còpia de seguretat significa perdre tot el que ha passat des de la còpia: comandes, pagaments, ressenyes. Gairebé mai no és acceptable.

D'aquí que la regla d'or sigui: el rollback d'una migració destructiva no existeix; es preveu. Expandir → migrar → contraure no és una cerimònia burocràtica, és precisament el que fa possible revertir.

Feature flags com a alternativa. En lloc de revertir el desplegament, s'apaga la funcionalitat:

// src/config/banderes.js
// Banderes llegides de l'entorn o d'un servei de configuració.
// Canviar-les NO requereix desplegar: és la seva raó de ser.
export const banderes = {
  recomanacionsAlCataleg: entorn.llegirBooleana('BANDERA_RECOMANACIONS', false),
  nouCalculEnviament: entorn.llegirBooleana('BANDERA_NOU_CALCUL_ENVIAMENT', false),
};
// src/serveis/comandes.js
const despesesEnviament = banderes.nouCalculEnviament
  ? calcularEnviamentPerZones(comanda)   // lògica nova, apagable en un segon
  : calcularEnviamentPla(comanda);       // lògica antiga, intacta

Avantatges: separen desplegar d'activar, permeten activar només per a un grup (empleats primer, després el 5 % de clients), i apagar és instantani sense desplegar res. Inconvenients que cal gestionar: cada bandera duplica camins de codi i per tant casos de prova, i les banderes caduquen: una bandera que fa un any que està encesa és deute tècnic. La disciplina que funciona és apuntar la data de retirada al mateix commit que la crea.

  1. On desplegar

Opció Esforç Control Cost Escalat Encaix amb la Botiga Aroma
PaaS (Render, Railway, Fly.io) Molt baix Baix Mitjà Automàtic, limitat Excel·lent per començar: git push i llest
Contenidors gestionats (Cloud Run, ECS Fargate) Baix Mitjà Baix-mitjà Automàtic, a zero L'opció sensata: Docker sense operar servidors
Kubernetes gestionat (GKE, EKS, AKS) Alt Alt Mitjà-alt Total Només amb diversos serveis i algú que l'operi
Serverless (Lambda, Cloud Functions) Baix Baix Molt baix sense trànsit Automàtic Problemàtic: arrencades en fred i connexions (05-03)
VPS propi Mitjà Total Baix Manual Vàlid si l'equip sap administrar sistemes

Recomanació per a la Botiga Aroma: Cloud Run o ECS Fargate. El raonament: ja tenim la imatge Docker, que és l'única cosa que demanen; l'escalat és automàtic; no hi ha servidors per apedaçar; i /salut/preparat s'integra directament com a comprovació. Kubernetes donaria més control del que necessitem avui i exigeix dedicació operativa a temps parcial que un equip petit no té.

Un avís sobre Kubernetes, perquè és la decisió sobredimensionada més comuna del sector: és una eina excel·lent per al seu problema, que és orquestrar molts serveis amb equips que els puguin operar. Adoptar-lo per a una API és canviar el problema «desplegar una aplicació» pel problema «operar Kubernetes», que és molt més gran. La pregunta correcta no és «és bo?», sinó «tenim el seu problema?».

  1. Versionat d'artefactes i traçabilitat

Davant d'un incident, hi ha tres preguntes que s'han de respondre en menys d'un minut: quina versió està desplegada, què conté i quan va arribar.

Les pràctiques que ho garanteixen:

  • Etiqueta d'imatge = commit. sha-3f9a2c1e…. Sense ambigüitat possible.
  • L'aplicació exposa la seva versió, a l'arrel o a /salut:
// src/rutes/salut.js
app.get('/salut', (req, res) => {
  res.json({
    estat: 'viu',
    versio: entorn.versioApi,          // 1.7.0, d'info.version del contracte
    commit: entorn.commitSha,          // injectat com a variable d'entorn
    desplegatEl: entorn.dataDesplegament,
  });
});
  • La versió com a etiqueta a les mètriques de 04-07. aroma_http_peticions_total{versio="1.7.0"} permet veure en un gràfic el moment exacte del desplegament i correlacionar-lo amb un canvi a la taxa d'error. És el senyal que més ràpid resol incidents: «ha començat just en desplegar».
  • La versió a cada línia de log, que ja surt gratis amb el registrador base de pino.
  • Un registre de desplegaments: quina versió, qui, quan, a quin entorn. GitHub Deployments ho fa sol amb environment: al workflow.

Una nota sobre versions semàntiques: info.version d'openapi.yaml descriu el contracte; el SHA descriu el codi. No coincideixen: vint commits poden compartir contracte 1.7.0. Tots dos són necessaris i responen preguntes diferents.

  1. Després del desplegament: proves de fum i vigilància

El desplegament no acaba quan la canalització es posa verda. Acaba quan algú ha comprovat que el sistema està bé.

Proves de fum. Un subconjunt mínim, ràpid i no destructiu, executat contra producció just després de desplegar:

#!/usr/bin/env bash
# eines/fum.sh — comprovacions mínimes després del desplegament.
# NO crea dades: en producció, una prova destructiva és inacceptable.
set -euo pipefail
URL="${1:?ús: fum.sh https://api.botigaaroma.example}"

echo "1/5 liveness"
curl -fsS "${URL}/salut" | grep -q '"estat":"viu"'

echo "2/5 readiness (dependències)"
curl -fsS "${URL}/salut/preparat" | grep -q '"estat":"preparat"'

echo "3/5 la versió desplegada és l'esperada"
DESPLEGADA=$(curl -fsS "${URL}/salut" | sed -n 's/.*"commit":"\([^"]*\)".*/\1/p')
[ "${DESPLEGADA}" = "${COMMIT_ESPERAT}" ] || {
  echo "Versió desplegada ${DESPLEGADA}, esperada ${COMMIT_ESPERAT}"; exit 1; }

echo "4/5 el catàleg respon i exigeix autenticació"
curl -fsS -o /dev/null -w '%{http_code}' "${URL}/v1/cafes" | grep -q 401

echo "5/5 el contracte està publicat"
curl -fsS "${URL}/docs/openapi.json" | grep -q '"openapi"'

echo "Proves de fum correctes."

Cinc comprovacions en dos segons que detecten les fallades de desplegament més freqüents: l'aplicació no arrenca, una dependència no és abastable des de producció, s'ha desplegat la imatge equivocada, o l'autenticació s'ha desconfigurat.

Vigilància durant el desplegament. És on 04-07 dona el seu valor. Els primers quinze minuts, mirant:

Senyal Llindar d'alarma Acció
Taxa d'error 5xx Qualsevol pujada sobre la línia base Revertir i després investigar
Latència p99 +20 % sobre la referència Investigar; revertir si empitjora
Taxa de peticions Caiguda brusca Algú no hi arriba: DNS, balancejador, CORS
Pressupost d'error de l'SLO Consum accelerat Revertir
Memòria i event loop Creixement sostingut Possible fuita introduïda en aquesta versió
Mètriques de negoci (aroma_comandes_creades_total) Caiguda El senyal més important: l'impacte real

L'última fila mereix èmfasi. Un desplegament pot estar tècnicament perfecte —zero errors, latència esplèndida— i haver trencat el negoci, perquè un canvi a la validació fa que cap comanda no es completi. Els 5xx no ho detecten; les comandes per minut, sí. Vigila sempre almenys una mètrica de negoci durant un desplegament.

I la regla cultural més important: primer restaurar el servei, després entendre què ha passat. La temptació d'investigar amb producció trencada és enorme i sempre és un error. Es reverteix, es respira i s'investiga amb el sistema estable.

  1. Secrets a CI i mínim privilegi

La canalització de CI és un objectiu d'atac de primer ordre: té credencials de desplegament i capacitat d'executar codi arbitrari. Els controls imprescindibles:

1. Permisos mínims per defecte. Ja és al workflow de l'apartat 7:

permissions:
  contents: read      # per defecte per a tots els treballs

i cada treball eleva només el seu (packages: write únicament a imatge). Sense aquesta declaració, el GITHUB_TOKEN té permisos amplis d'escriptura sobre el repositori, i qualsevol acció de tercers compromesa els hereta.

2. Mai imprimir secrets. GitHub emmascara els valors coneguts als logs, però és fàcil burlar-ho sense voler: un env | sort o un secret codificat en base64 es veu en clar. Regla simple: res de bolcar l'entorn en un log.

3. Fixar les accions de tercers. uses: algu/accio@v3 segueix una etiqueta mòbil: si aquella etiqueta es mou a codi maliciós, s'executa a la teva canalització amb accés als teus secrets. Ha passat. El segur és fixar el SHA:

      - uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75  # v6.9.0

4. Entorns protegits. L'environment: produccio de GitHub permet exigir aprovació humana, limitar quines branques poden desplegar i desar secrets accessibles només des d'aquell entorn. Així, el token de producció no està a l'abast d'un workflow de pull request.

5. Res de secrets a workflows de pull requests de forks. Un pull request des d'un fork executa codi que no controles. GitHub no exposa secrets a pull_request de forks per defecte; no ho canviïs amb pull_request_target sense entendre exactament el que implica. És una de les vies d'exfiltració de credencials més explotades.

6. Credencials efímeres en lloc de claus llargues. OIDC entre GitHub Actions i el proveïdor de núvol emet un token de vida curta per execució, en lloc de desar una clau d'accés permanent. És la mateixa idea de 04-03 —tokens de vida curta, abast limitat— aplicada a la infraestructura.

7. Rotació i auditoria. Els secrets caduquen i es roten; els accessos es registren. I si un secret ha aparegut alguna vegada en un log o en un commit, es considera compromès: es rota, no s'esborra el commit i s'hi fa com si res.

Errors Comuns i Consells

  • COPY . . sense .dockerignore. Fica el teu .env amb les claus reals dins de la imatge. És una de les fuites de credencials més freqüents que existeixen.
  • CMD node src/servidor.js en forma shell. El PID 1 passa a ser /bin/sh, SIGTERM no arriba a Node i cada desplegament talla peticions a mitges. Forma exec i tini.
  • Fer servir readiness com a liveness. Redis cau i l'orquestrador reinicia tots els contenidors en bucle. Liveness sense dependències; readiness amb elles.
  • DROP COLUMN en un desplegament sense talls. La versió antiga continua viva i executant SELECT sobre aquella columna. Expandir → migrar → contraure, sempre.
  • Migrar en arrencar l'aplicació. Amb tres instàncies, tres migracions en paral·lel sobre la mateixa base. Pas propi a la canalització, amb bloqueig.
  • Desplegar l'etiqueta latest. És mòbil: no saps què hi ha en producció ni pots revertir amb precisió. L'etiqueta és el SHA del commit.
  • Reconstruir la imatge per a cada entorn. Aleshores no desplegues el que vas provar. Una imatge, moltes configuracions.
  • Portes de CI que es poden saltar. Al tercer «només aquesta vegada» deixen d'existir. Es canvien amb un pull request, no es salten en una execució.
  • Bloquejar amb npm audit a nivell moderat. La canalització estarà trencada permanentment per dependències transitives i l'equip aprendrà a ignorar-la. --audit-level=high.
  • Un workflow amb permissions: write-all. Qualsevol acció de tercers compromesa hereta el poder d'escriure al teu repositori.
  • Consell: comprova docker stop en local. Si triga deu segons, el SIGTERM no arriba i la teva aturada ordenada tampoc no s'executa en producció.
  • Consell: exporta la versió desplegada com a etiqueta de mètrica. Veure l'esglaó exacte al gràfic d'errors en el moment del desplegament resol incidents en segons.
  • Consell: vigila una mètrica de negoci durant cada desplegament. Un desplegament tècnicament perfecte pot haver trencat la creació de comandes, i els 5xx no t'ho diran.
  • Consell: escriu un ADR (04-01) amb l'estratègia de desplegament triada. D'aquí a un any algú preguntarà per què no feu canary, i la resposta ha d'estar escrita.

Exercicis

Exercici 1: completar la canalització amb un treball de desplegament a producció

Amplia .github/workflows/ci.yml amb un treball produccio que s'executi després de preproduccio, exigeixi aprovació humana, apliqui migracions, desplegui la mateixa imatge que es va verificar a preproducció, executi les proves de fum i reverteixi automàticament si fallen. Explica per què s'ha de desplegar exactament la mateixa etiqueta d'imatge i no reconstruir-se.

Exercici 2: dissenyar una migració segura

La Botiga Aroma necessita separar el camp origen (avui "Etiòpia") en dos: pais i regio ("Etiòpia" / "Yirgacheffe"), perquè el cercador ha de filtrar per regió. Hi ha 2.400 cafès en producció i el sistema desplega amb rolling sobre tres instàncies.

Dissenya la seqüència completa expandir → migrar → contraure: quin SQL a cada pas, què fa el codi a cada desplegament, quant temps deixaries entre passos i per què, com tractaries el contracte de l'API a cada fase (recorda 02-07), i en quin punt exacte es perd la possibilitat de revertir.

Exercici 3: triar estratègia de desplegament per a tres canvis

Per a cada canvi, tria entre recreate, rolling, blue-green i canary, justifica l'elecció, indica què vigilaries durant el desplegament i descriu el pla de retorn enrere:

A) Correcció d'una fallada al càlcul de l'IVA de les factures. Afecta totes les comandes i hi ha clients pagant ara mateix.

B) Canvi de l'algorisme d'ordenació per defecte del catàleg, que ara prioritza els cafès més ben valorats. No canvia el contracte, però canvia el que veuen tots els usuaris.

C) Migració de la persistència de SQLite a PostgreSQL, amb el mateix contracte d'API i el mateix codi tret de src/repositoris/.

Solucions

Solució 1

  # --------------------------------------------------------------------------
  # 7. Producció: aprovació humana, desplegament, proves de fum i retorn enrere.
  # --------------------------------------------------------------------------
  produccio:
    name: Desplegar a producció
    needs: [preproduccio]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: produccio            # amb "required reviewers" configurat a GitHub:
      url: https://api.botigaaroma.example   # el treball espera aprovació humana
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4

      - name: Anotar la versió que estava desplegada (per al retorn enrere)
        id: anterior
        run: |
          ACTUAL=$(curl -fsS https://api.botigaaroma.example/salut \
            | sed -n 's/.*"commit":"\([^"]*\)".*/\1/p')
          echo "sha=${ACTUAL}" >> "$GITHUB_OUTPUT"
          echo "Versió actual en producció: ${ACTUAL}"

      - name: Aplicar migracions (retrocompatibles, apartat 11)
        run: npm ci && npm run migrar
        env:
          URL_BASE_DADES: ${{ secrets.URL_BASE_DADES_PRODUCCIO }}

      - name: Desplegar EXACTAMENT la imatge verificada a preproducció
        run: ./eines/desplegar.sh produccio "sha-${{ github.sha }}"
        env:
          TOKEN_DESPLEGAMENT: ${{ secrets.TOKEN_DESPLEGAMENT_PRODUCCIO }}

      - name: Esperar readiness
        run: |
          for i in $(seq 1 40); do
            curl -fsS https://api.botigaaroma.example/salut/preparat && exit 0
            sleep 5
          done
          exit 1

      - name: Proves de fum
        id: fum
        run: ./eines/fum.sh https://api.botigaaroma.example
        env:
          COMMIT_ESPERAT: ${{ github.sha }}

      - name: Vigilar mètriques durant 3 minuts
        run: ./eines/vigilar-desplegament.sh --minuts 3 --llindar-error 0.5

      - name: RETORN ENRERE automàtic si alguna cosa ha fallat
        if: failure()
        run: |
          echo "::error::Desplegament fallit. Revertint a ${{ steps.anterior.outputs.sha }}"
          ./eines/desplegar.sh produccio "${{ steps.anterior.outputs.sha }}"
          ./eines/fum.sh https://api.botigaaroma.example
        env:
          TOKEN_DESPLEGAMENT: ${{ secrets.TOKEN_DESPLEGAMENT_PRODUCCIO }}
          COMMIT_ESPERAT: ${{ steps.anterior.outputs.sha }}

      - name: Avisar l'equip
        if: always()
        run: ./eines/notificar.sh "${{ job.status }}" "sha-${{ github.sha }}"

Per què la mateixa imatge i no reconstruir. Cinc raons, de més a menys evident:

  1. És l'única cosa que s'ha verificat. Les proves d'extrem a extrem i Newman es van executar contra aquella imatge concreta a preproducció. Reconstruir produeix un artefacte que ningú no ha provat, per molt idèntic que sembli el codi.
  2. Les construccions no són bit a bit reproduïbles. npm ci respecta el lock per a les teves dependències directes i transitives, però la imatge base node:20-bookworm-slim és una etiqueta mòbil que s'actualitza; els paquets del sistema instal·lats amb apt-get canvien de versió; i els mòduls natius es compilen contra el que hi hagi en aquell moment. Dues construccions del mateix commit amb un dia de diferència poden diferir.
  3. Traçabilitat. Una etiqueta, un artefacte, un commit. Si en producció i a preproducció hi ha imatges diferents amb el mateix nom, la depuració es torna un exercici de fe.
  4. Temps. Reconstruir afegeix minuts al camí crític del desplegament, justament quan pot haver-hi una correcció urgent esperant.
  5. Superfície d'atac. Cada construcció és una oportunitat perquè entri alguna cosa indeguda a la cadena de subministrament. Menys construccions, menys oportunitats.

Detalls del treball que mereixen atenció: environment: produccio amb revisors obligatoris és el que converteix això en lliurament continu i no en desplegament continu; el pas anterior consulta la versió desplegada abans de tocar res, perquè després ja no es pot saber; i el pas de retorn enrere porta if: failure(), que es dispara si falla qualsevol pas previ del treball, incloses les proves de fum i la vigilància de mètriques.

Una limitació honesta d'aquesta solució: el retorn enrere reverteix el codi, no les dades. Si la migració va ser destructiva, això no salva res, i per això l'apartat 11 no és opcional.

Solució 2

Fase 0 — Preparació (abans de tocar res).

Afegir al contracte els camps nous com a opcionals, sense eliminar origen. Publicar openapi.yaml amb pais i regio documentats i origen encara vigent. oasdiff ho aprova: afegir camps a una resposta no és trencador.

Fase 1 — EXPANDIR (desplegament 1).

-- migracions/014-expandir-origen.sql
ALTER TABLE cafes ADD COLUMN pais TEXT;
ALTER TABLE cafes ADD COLUMN regio TEXT;

-- Ompliment inicial: tot el que hi ha avui a origen es copia a pais.
-- La regió queda nul·la: es completarà manualment o amb un procés a part.
UPDATE cafes SET pais = origen WHERE pais IS NULL;

-- L'índex per al cercador per regió es crea aquí, no després.
CREATE INDEX IF NOT EXISTS idx_cafes_pais_regio ON cafes(pais, regio);
// Codi del desplegament 1: escriu a les TRES columnes, llegeix d'`origen`.
export function desarCafe(cafe) {
  bd.prepare(`
    UPDATE cafes SET origen = ?, pais = ?, regio = ? WHERE id = ?
  `).run(
    cafe.regio ? `${cafe.pais}, ${cafe.regio}` : cafe.pais,  // compon el llegat
    cafe.pais,
    cafe.regio ?? null,
    cafe.id,
  );
}

// L'API continua retornant `origen` i ja retorna `pais` i `regio` quan existeixen.
export function cafeARepresentacio(fila) {
  return {
    ...camps,
    origen: fila.origen,          // continua sent la font per als consumidors
    pais: fila.pais,
    regio: fila.regio ?? undefined,
  };
}

Estat del contracte: origen vigent, pais i regio disponibles. Cap consumidor no se n'assabenta. Es pot revertir sense problema: les columnes noves sobren però no molesten.

Fase 1b — Omplir la regió (procés a part, dies).

2.400 cafès la regió dels quals cal deduir o introduir a mà. Un script per lots que actualitza en tandes de 200 amb pauses, per no bloquejar la base ni disparar la latència:

// eines/omplir-regio.js — executable diverses vegades, idempotent
const lot = bd.prepare('SELECT id, origen FROM cafes WHERE regio IS NULL LIMIT 200').all();
for (const fila of lot) {
  const [pais, regio] = separarOrigen(fila.origen);   // heurística + taula manual
  bd.prepare('UPDATE cafes SET pais = ?, regio = ? WHERE id = ?').run(pais, regio, fila.id);
}

Aquest pas és el que marca el ritme real, i per això la migració d'esquema i l'ompliment de dades han d'anar separats: barrejar-los produeix migracions que triguen minuts i bloquegen el desplegament.

Fase 2 — MIGRAR (desplegament 2, una o dues setmanes després).

// El codi llegeix de les columnes NOVES i continua escrivint a les tres.
export function cafeARepresentacio(fila) {
  return {
    ...camps,
    // `origen` es continua retornant, però ara es COMPON de pais i regio.
    // Els consumidors antics veuen exactament el mateix que abans.
    origen: fila.regio ? `${fila.pais}, ${fila.regio}` : fila.pais,
    pais: fila.pais,
    regio: fila.regio ?? undefined,
  };
}

El filtre ?regio=Yirgacheffe s'activa en aquesta fase. Encara es pot revertir: origen continua poblat i sincronitzat, així que el codi del desplegament 1 funciona perfectament.

Espera d'una o dues setmanes abans de continuar. El motiu: donar temps que apareguin casos rars —cafès amb noms d'origen que l'heurística ha separat malament— amb el retorn enrere encara disponible.

Fase 3 — Deprecar origen al contracte (no toca la base de dades).

        origen:
          type: string
          deprecated: true
          description: |
            **Obsolet des de la 1.8.0. Es retirarà el 30 de juny de 2027.**
            Fes servir `pais` i `regio`. Aquest camp es compon com
            `"{pais}, {regio}"` per compatibilitat.

Amb Deprecation i Sunset a les respostes, i avís a la SPA, a l'Aroma Mòbil i a CataBox. Com que l'Aroma Mòbil té versions antigues vives, aquest termini ha de ser generós: sis mesos com a mínim.

Fase 4 — CONTRAURE (mesos després, amb /v2 o després del Sunset).

-- migracions/018-contraure-origen.sql
-- Només quan CAP versió del codi que llegeix `origen` no pugui estar viva
-- i el termini de Sunset anunciat hagi vençut.
DROP INDEX IF EXISTS idx_cafes_origen;
ALTER TABLE cafes DROP COLUMN origen;

On es perd la possibilitat de revertir: exactament a la fase 4, en executar el DROP COLUMN. A partir d'aquell instant, desplegar qualsevol versió anterior a la fase 2 produeix errors SQL a cada lectura del catàleg, i recuperar la columna significa restaurar una còpia de seguretat i perdre tot el que s'ha escrit des d'aleshores. És la raó per la qual la contracció es fa mesos després, quan revertir a aquella versió ja no és un escenari realista, i preferiblement en un desplegament propi sense cap altre canvi, perquè si alguna cosa falla se sàpiga exactament què ha estat.

Amb tres instàncies en rolling, cada fase individual és segura perquè en cap moment no coexisteixen un esquema i un codi incompatibles: aquella és la propietat que garanteix el patró, i l'única raó per la qual existeix.

Solució 3

A) Correcció de l'IVA a les factures → blue-green (o rolling amb retorn enrere preparat).

Aspecte Decisió
Estratègia Blue-green, o rolling si no hi ha infraestructura duplicada
Per què no canary El canary implica que un percentatge de clients rep factures amb el càlcul antic durant la finestra. En un afer fiscal, la incoherència entre factures emeses el mateix dia és pitjor que l'error original: complica la comptabilitat i la correcció posterior. Aquí es vol un tall net amb un instant exacte de canvi.
Per què blue-green Dona un moment de commutació precís —anotable al registre comptable— i retorn enrere en segons.
Què vigilar Taxa d'error de POST /comandes/{id}/pagament i de la generació de factures; imports de les primeres factures emeses, comparats a mà amb el càlcul esperat; aroma_comandes_creades_total per confirmar que es continuen completant compres.
Retorn enrere Recommutar el balancejador a blau. I un pla per a les factures emeses a la finestra: identificar-les per marca de temps i reemetre-les si escau. Això és el veritablement difícil, i cal tenir-ho escrit abans de desplegar.
Extra Desplegar a la franja de menys activitat. Un canvi amb implicacions fiscals no es desplega un divendres a les 18:00.

B) Nova ordenació per defecte del catàleg → canary.

Aspecte Decisió
Estratègia Canary, començant amb un 5 %
Per què És un canvi de producte, no tècnic: no hi ha una resposta «correcta» a verificar, sinó una hipòtesi a mesurar. El canary permet comparar el comportament real dels usuaris entre totes dues versions, que és exactament la pregunta. A més és reversible sense conseqüències: ningú no perd dades si l'ordenació canvia.
Què vigilar Mètriques de negoci per damunt de les tècniques: taxa de conversió de visita a comanda, valor mitjà de la comanda, profunditat de paginació (si la gent pagina menys, l'ordenació encerta més). En allò tècnic, la latència p99 de GET /v1/cafes, perquè ordenar per valoració mitjana pot exigir un JOIN o una agregació que degradi la consulta; convé comprovar que hi ha índex (04-06).
Retorn enrere Feature flag, no desplegament. BANDERA_ORDRE_PER_VALORACIO apagada retorna l'ordre anterior en un segon, sense desplegar res. És el cas de llibre per a una bandera: canvi de comportament visible, reversible i subjecte a mesura.
Extra Si la comparació ha de durar setmanes, això deixa de ser un canary i passa a ser una prova A/B; convé un repartiment estable per client perquè un mateix usuari no vegi el catàleg reordenat a cada visita.

C) Migració de SQLite a PostgreSQL → blue-green, amb una fase prèvia de doble escriptura.

Aspecte Decisió
Estratègia Blue-green, precedida d'una migració de dades per fases. No és un desplegament: és un projecte.
Per què no rolling En rolling coexistirien instàncies escrivint a SQLite i a PostgreSQL simultàniament. Les escriptures es repartirien entre dues bases que divergeixen, i reconciliar-les després és pràcticament impossible.
Fases 1) Desplegar el codi nou escrivint a totes dues i llegint de SQLite. 2) Còpia inicial massiva i verificació que totes dues bases coincideixen, registre a registre. 3) Finestra breu de només lectura o de manteniment per copiar el delta final. 4) Commutar a lectura des de PostgreSQL (blue-green). 5) Vigilar dies. 6) Deixar d'escriure a SQLite.
Què vigilar Latència p99 de tots els endpoints (el rendiment de les consultes canvia del tot amb un altre motor i un altre planificador); errors de restriccions d'integritat que SQLite tolerava i PostgreSQL no; saturació del pool de connexions, que a SQLite no existia i aquí és un límit real; i la coherència de les dades comparant recomptes i sumes entre totes dues bases.
Retorn enrere Recommutar a SQLite, sempre que s'hi hagi continuat escrivint. Aquell és el motiu de la fase de doble escriptura, i el seu cost està justificat: sense ella, la migració és un viatge d'anada.
Extra Gràcies al patró repositori de 03-05, el canvi de codi es limita a src/repositoris/ i a src/config/base-dades.js; la resta del projecte no se n'assabenta. És la millor demostració del valor d'aquella separació, i convé comprovar que les proves d'integració passen contra tots dos motors abans de començar.

Conclusió

La taula de 05-04 és ara maquinària. L'API de la Botiga Aroma s'empaqueta en un Dockerfile multietapa on les eines de compilació es queden a la primera etapa, la imatge final porta només dependències de producció i s'executa com a usuari node, amb tini i forma exec perquè el SIGTERM arribi de debò a Node i s'executi l'aturada ordenada de 03-07, i amb un HEALTHCHECK sobre liveness —no readiness— per no reiniciar contenidors sans quan falli Redis. El .dockerignore manté fora el teu .env i el teu node_modules, i docker-compose.yml aixeca API, Redis, el mock de Prism i, després d'un perfil, PostgreSQL, amb condition: service_healthy perquè res no arrenqui abans d'hora.

.github/workflows/ci.yml executa les portes en l'ordre correcte: qualitat estàtica primer perquè falla en trenta segons, després el contracte amb swagger-cli validate, Spectral i oasdiff breaking en paral·lel amb les proves sobre Node 20 i 22 amb Redis com a servei i cobertura, npm audit --audit-level=high, i només aleshores la construcció i publicació d'una imatge etiquetada amb el SHA del commit i escanejada amb Trivy; després, migracions, desplegament a preproducció, espera activa a /salut/preparat, proves d'extrem a extrem i la col·lecció de Newman de 05-01. Les portes són inflexibles a propòsit, amb un únic escapament explícit i visible: l'etiqueta canvi-trencador al pull request.

I per damunt de les eines, tres idees que separen un equip que desplega tranquil d'un que desplega amb por. Les migracions retrocompatibles: durant un desplegament sense talls conviuen dues versions sobre una sola base de dades, així que un RENAME COLUMN és un DROP disfressat i el patró expandir → migrar → contraure no és burocràcia, és l'única cosa que preserva la possibilitat de revertir. Les estratègies de desplegament —recreate, rolling, blue-green i canary— amb /salut i /salut/preparat de 04-07 decidint quan entra el trànsit i un preStop que evita els 502 fantasma de cada desplegament. I el retorn enrere: revertir codi és trivial quan les imatges són immutables i la configuració viu fora; revertir dades no existeix, es preveu; i els feature flags separen desplegar d'activar, amb data de caducitat escrita el mateix dia que es creen. Els artefactes nous del projecte són Dockerfile, .dockerignore, docker-compose.yml, docker-compose.proves.yml, .github/workflows/ci.yml, eines/desplegar.sh, eines/fum.sh i src/config/banderes.js.

Queda una última peça del mòdul, i és la que reordena bona part del que hem fet a mà. Molts dels mecanismes del mòdul 4 —el rate limiting amb Redis, la validació de tokens JWT i OAuth, la terminació TLS, CORS, la memòria cau, la compressió, l'encaminament per versió— existeixen resolts una capa per damunt de l'aplicació. A 05-06, API gateways i portals de desenvolupador, tanquem el mòdul amb aquella perspectiva: què és un gateway i quines funcions assumeix, comparades una a una amb les nostres implementacions; el criteri per decidir què es delega i què mai no es delega —l'autorització a nivell de recurs, la lògica de negoci i la validació semàntica es queden sempre a l'aplicació—; una configuració declarativa real de Kong per a la Botiga Aroma amb els límits de 04-04 i la llista blanca de 04-05, i quins middlewares de src/app.js podríem retirar aleshores i quins no; els riscos del gateway com a punt únic de fallada; i els portals de desenvolupador, on l'openapi.yaml de 05-02 es converteix en la porta d'entrada de tercers com CataBox, amb el «temps fins a la primera crida amb èxit» com a mètrica de la qualitat de la teva API.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats