Docker

Com desplegar Laravel amb Docker: Dockerfile multi-stage, Docker Compose per producció, Nginx, PHP-FPM, workers i orquestració.

Per què Docker per a producció?#

Quantes vegades has escoltat "a mi em funciona"? Aquesta frase és el símptoma d'un problema fonamental en el desplegament de programari: la diferència entre l'entorn de desenvolupament i l'entorn de producció. Docker resol aquest problema d'arrel. Un contenidor Docker empaqueta la teva aplicació amb totes les seves dependències (versió de PHP, extensions, configuració del servidor web, versions de les llibreries) en una unitat portable que funciona exactament igual a tot arreu: al teu portàtil, al servidor de staging i als servidors de producció.

Però Docker no és només consistència. És també reproductibilitat: qualsevol membre de l'equip pot reconstruir la imatge exacta que s'executa en producció amb una sola comanda. És aïllament: cada servei (PHP, Nginx, Redis, MySQL) s'executa en el seu propi contenidor amb els seus propis recursos, sense interferir amb els altres. I és escalabilitat: pots arrencar deu contenidors de la teva aplicació en segons per absorbir un pic de tràfic i tornar a un sol contenidor quan el pic passi.

Docker vs Laravel Sail#

És important entendre la diferència entre Docker per a producció i Laravel Sail. Sail és una eina de desenvolupament local que Laravel ofereix per simplificar l'arrencada d'un entorn amb Docker. Utilitza Docker Compose internament, però les seves imatges estan pensades per al desenvolupament: inclouen Xdebug, múltiples versions de Node, eines de compilació i moltes altres utilitats que no vols en producció. Les imatges de Sail són grans (sovint superen 1 GB) i no estan optimitzades per al rendiment.

El que veurem en aquest capítol és completament diferent: construirem imatges Docker lleugeres, segures i optimitzades per a producció. Imatges que pesen menys de 100 MB, que arrenquen en mil·lisegons, que no contenen cap eina de desenvolupament i que estan configurades específicament per servir la teva aplicació Laravel amb el màxim rendiment.

No facis servir les imatges de Laravel Sail en producció. Estan dissenyades per al desenvolupament i contenen eines, dependències i configuracions que no són adequades per a un entorn de producció. Crea les teves pròpies imatges optimitzades seguint les pràctiques d'aquest capítol.

Dockerfile multi-stage#

Un Dockerfile multi-stage és una tècnica que permet utilitzar múltiples imatges base dins d'un sol Dockerfile. Cada stage (etapa) parteix d'una imatge diferent i només el resultat final es converteix en la imatge que desplegarem. Això és fonamental perquè les eines necessàries per compilar l'aplicació (Composer, Node.js, npm) no han d'existir a la imatge final de producció. El resultat és una imatge molt més petita, més segura i més ràpida d'arrencar.

L'ordre de les instruccions dins de cada stage és crític per aprofitar la cache de Docker. Docker construeix les imatges per capes: cada instrucció (RUN, COPY, ADD) crea una capa nova. Si una capa no ha canviat respecte a la construcció anterior, Docker la reutilitza de la cache. Per això, hem de posar primer les instruccions que canvien menys sovint (instal·lar extensions de PHP) i al final les que canvien més sovint (copiar el codi de l'aplicació).

Stage 1: Dependències de Composer#

La primera etapa instal·la les dependències de PHP. Utilitzem la imatge oficial de Composer, que ja conté tot el necessari. Fixem-nos que primer copiem només els fitxers composer.json i composer.lock, i després executem composer install. Això és intencionat: si el codi de l'aplicació canvia però les dependències no, Docker reutilitzarà la capa de la cache i no tornarà a executar composer install, estalviant minuts en cada construcció.

# Stage 1: Dependències de Composer
FROM composer:2 AS composer-deps
 
WORKDIR /app
 
# Copiar només els fitxers de dependències primer (cache-friendly)
COPY composer.json composer.lock ./
 
# Instal·lar dependències sense scripts ni autoloader
# (els scripts poden necessitar fitxers que encara no hem copiat)
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-autoloader \
    --prefer-dist \
    --no-interaction
 
# Ara copiar tot el codi font
COPY . .
 
# Generar l'autoloader optimitzat amb tot el codi disponible
RUN composer dump-autoload --optimize --no-dev

El flag --no-scripts és important en aquesta primera fase. Alguns paquets defineixen scripts post-install que poden necessitar fitxers de l'aplicació que encara no hem copiat. L'autoloader optimitzat el generem al final, quan tot el codi ja està disponible. El flag --no-dev exclou les dependències de desenvolupament (PHPUnit, Faker, Telescope, etc.) que no necessitem en producció.

Stage 2: Compilació d'assets#

La segona etapa s'encarrega de compilar els assets del frontend (CSS, JavaScript). Utilitzem una imatge de Node.js lleugera basada en Alpine Linux. Igual que amb Composer, primer copiem els fitxers de dependències (package.json, package-lock.json) per aprofitar la cache, i després copiem el codi font i compilem.

# Stage 2: Compilar assets amb Node.js
FROM node:20-alpine AS node-build
 
WORKDIR /app
 
# Copiar fitxers de dependències npm (cache-friendly)
COPY package.json package-lock.json ./
 
# Instal·lar dependències de Node
RUN npm ci --no-audit --no-fund
 
# Copiar els fitxers necessaris per a la compilació
COPY vite.config.js ./
COPY resources ./resources
COPY tailwind.config.js postcss.config.js ./
 
# Compilar assets per a producció
RUN npm run build

El flag npm ci (en lloc de npm install) és preferible per a entorns de CI/CD i producció perquè instal·la exactament les versions definides a package-lock.json, sense modificar-lo. Això garanteix construccions deterministes: la mateixa entrada sempre produeix la mateixa sortida.

Stage 3: Imatge de producció#

La tercera i última etapa és la imatge que realment es desplegarà. Partim d'una imatge PHP-FPM basada en Alpine Linux, que és extremadament lleugera (menys de 30 MB la imatge base). Instal·lem només les extensions de PHP que la nostra aplicació necessita, copiem el codi i les dependències des dels stages anteriors, i configurem OPcache per a màxim rendiment.

# Stage 3: Imatge de producció
FROM php:8.3-fpm-alpine AS production
 
# Instal·lar dependències del sistema
RUN apk add --no-cache \
    nginx \
    supervisor \
    curl \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    libzip-dev \
    icu-dev \
    oniguruma-dev \
    libpq-dev \
    linux-headers
 
# Instal·lar extensions de PHP necessàries
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) \
        pdo_mysql \
        pdo_pgsql \
        opcache \
        gd \
        intl \
        zip \
        bcmath \
        mbstring \
        pcntl \
        exif
 
# Instal·lar l'extensió Redis via PECL
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps
 
# Configurar OPcache per a producció
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.interned_strings_buffer=64" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=30000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.save_comments=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.jit=1255" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.jit_buffer_size=128M" >> /usr/local/etc/php/conf.d/opcache.ini
 
# Configurar PHP per a producció
RUN echo "expose_php=Off" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "display_errors=Off" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "display_startup_errors=Off" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "log_errors=On" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "error_log=/dev/stderr" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "memory_limit=256M" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "upload_max_filesize=64M" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "post_max_size=64M" >> /usr/local/etc/php/conf.d/production.ini \
    && echo "max_execution_time=60" >> /usr/local/etc/php/conf.d/production.ini
 
WORKDIR /var/www/html
 
# Copiar les dependències de Composer des del stage 1
COPY --from=composer-deps /app/vendor ./vendor
 
# Copiar els assets compilats des del stage 2
COPY --from=node-build /app/public/build ./public/build
 
# Copiar el codi de l'aplicació
COPY . .
 
# Crear l'usuari www-data si no existeix i establir permisos
RUN mkdir -p storage/framework/{sessions,views,cache} \
    && mkdir -p storage/logs \
    && mkdir -p bootstrap/cache \
    && chown -R www-data:www-data storage bootstrap/cache \
    && chmod -R 775 storage bootstrap/cache
 
# Copiar configuracions de Nginx i Supervisor
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisord.conf
 
# Cachear configuració, rutes i vistes
RUN php artisan config:cache \
    && php artisan route:cache \
    && php artisan view:cache \
    && php artisan event:cache
 
EXPOSE 80
 
# Crear un script d'arrencada
RUN echo '#!/bin/sh' > /usr/local/bin/start.sh \
    && echo 'php artisan migrate --force' >> /usr/local/bin/start.sh \
    && echo 'exec supervisord -c /etc/supervisord.conf' >> /usr/local/bin/start.sh \
    && chmod +x /usr/local/bin/start.sh
 
CMD ["/usr/local/bin/start.sh"]

Cada extensió de PHP que instal·lem té un propòsit concret. pdo_mysql i pdo_pgsql són els drivers de base de dades. opcache accelera l'execució cachejant el bytecode PHP compilat. gd permet manipular imatges (thumbnails, avatars). intl proporciona funcions d'internacionalització. zip és necessari per a Composer i per gestionar fitxers comprimits. bcmath s'utilitza per a càlculs financers amb precisió arbitrària. pcntl és necessari per a Horizon i per a la gestió de senyals dels workers. redis permet la connexió amb Redis per a cache, sessions i cues.

La configuració d'OPcache mereix atenció especial. El paràmetre validate_timestamps=0 és clau per a producció: indica a OPcache que no comprovi si els fitxers PHP han canviat al disc. Això millora el rendiment significativament perquè OPcache no ha de fer cap operació d'E/S per verificar timestamps. L'inconvenient és que quan despleguis codi nou, hauràs de reiniciar PHP-FPM perquè els canvis tinguin efecte. El JIT (Just-In-Time compiler), activat amb jit=1255, compila el bytecode PHP a codi màquina natiu, oferint millores de rendiment addicionals.

El paràmetre opcache.validate_timestamps=0 significa que OPcache no detectarà canvis als fitxers PHP automàticament. Després de cada desplegament, hauràs de reiniciar el contenidor o enviar un senyal a PHP-FPM per netejar la cache. Això és exactament el que volem en producció: màxim rendiment sense comprovacions innecessàries.

Configuració de Nginx#

Nginx actua com a servidor web davant de PHP-FPM. Rep les peticions HTTP, serveix els fitxers estàtics directament (CSS, JavaScript, imatges) i passa les peticions dinàmiques a PHP-FPM per processar-les. Aquesta separació de responsabilitats és molt més eficient que utilitzar Apache amb mod_php, perquè Nginx és extremadament ràpid servint fitxers estàtics i consumeix molta menys memòria.

Crea el fitxer docker/nginx.conf amb la configuració completa per a producció:

# docker/nginx.conf
 
server {
    listen 80;
    server_name _;
    root /var/www/html/public;
    index index.php;
 
    # Mida màxima de pujada
    client_max_body_size 64M;
 
    # Charset
    charset utf-8;
 
    # Logs
    access_log /dev/stdout;
    error_log /dev/stderr;
 
    # Compressió Gzip
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_proxied any;
    gzip_vary on;
    gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;
 
    # Headers de seguretat
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
 
    # Cache per a fitxers estàtics
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
 
    # Rutes de Laravel
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
 
    # PHP-FPM
    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
 
        # Timeouts
        fastcgi_connect_timeout 60s;
        fastcgi_send_timeout 60s;
        fastcgi_read_timeout 60s;
 
        # Buffers
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
        fastcgi_busy_buffers_size 32k;
    }
 
    # Denegar accés a fitxers ocults
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
 
    # Denegar accés a fitxers sensibles
    location ~* \.(env|log|git|gitignore|composer\.(json|lock))$ {
        deny all;
        access_log off;
        log_not_found off;
    }
 
    # Health check endpoint
    location /health {
        access_log off;
        return 200 "OK";
        add_header Content-Type text/plain;
    }
}

La compressió Gzip redueix significativament la mida de les respostes HTTP, especialment per a fitxers de text com HTML, CSS i JavaScript. El nivell de compressió 5 és un bon equilibri entre reducció de mida i ús de CPU. Els headers de seguretat (X-Frame-Options, X-Content-Type-Options, etc.) protegeixen contra atacs comuns com clickjacking i MIME type sniffing. La cache de fitxers estàtics amb expires 30d evita que el navegador torni a descarregar fitxers que no han canviat.

El bloc que denega accés a fitxers ocults i sensibles és crític per a la seguretat. Sense aquesta configuració, algú podria accedir a .env (que conté credencials) o a .git (que conté tot l'historial del repositori) directament des del navegador.

Configuració de Supervisor#

Supervisor és un gestor de processos que manté múltiples serveis en execució dins d'un sol contenidor. En el nostre cas, necessitem que Nginx i PHP-FPM s'executin simultàniament. Supervisor els arrenca, els monitoritza i els reinicia automàticament si algun d'ells mor inesperadament.

Crea el fitxer docker/supervisord.conf:

; docker/supervisord.conf
 
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
 
[program:php-fpm]
command=php-fpm --nodaemonize
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
 
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

L'opció nodaemon=true és essencial per a Docker: fa que Supervisor s'executi en primer pla en lloc de com a dimoni. Si Supervisor s'executés com a dimoni, el procés principal del contenidor acabaria immediatament i Docker el consideraria com a mort. Les opcions --nodaemonize per a PHP-FPM i daemon off per a Nginx serveixen el mateix propòsit: mantenir els processos en primer pla perquè Supervisor els pugui monitoritzar.

Els logs es redirigeixen a /dev/stdout i /dev/stderr, que és la pràctica recomanada per a contenidors Docker. D'aquesta manera, els logs són accessibles amb docker logs i poden ser recollits per sistemes de monitorització centralitzats com ELK Stack, Datadog o CloudWatch.

Docker Compose per a producció#

Docker Compose permet definir i executar aplicacions multi-contenidor. En lloc d'arrencar cada servei manualment amb docker run, defineixes tot l'stack en un fitxer YAML i l'arrenques amb una sola comanda. Per a una aplicació Laravel en producció, necessitem almenys cinc serveis: l'aplicació (PHP-FPM + Nginx), la base de dades, Redis, un worker per a les cues i un servei per al scheduler.

# docker-compose.yml
 
services:
  # Aplicació principal (PHP-FPM + Nginx)
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    ports:
      - "80:80"
      - "443:443"
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      APP_KEY: ${APP_KEY}
      APP_URL: ${APP_URL}
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      CACHE_STORE: redis
      SESSION_DRIVER: redis
      QUEUE_CONNECTION: redis
      LOG_CHANNEL: stderr
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - app-storage:/var/www/html/storage/app
    networks:
      - laravel
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s
 
  # Base de dades MySQL
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db-data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/custom.cnf:ro
    networks:
      - laravel
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
 
  # Redis per a cache, sessions i cues
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - laravel
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
 
  # Worker per processar les cues
  worker:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      APP_KEY: ${APP_KEY}
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      QUEUE_CONNECTION: redis
      LOG_CHANNEL: stderr
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - laravel
    restart: unless-stopped
    deploy:
      replicas: 2
 
  # Scheduler per a les tasques programades
  scheduler:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    command: php artisan schedule:work
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      APP_KEY: ${APP_KEY}
      DB_CONNECTION: mysql
      DB_HOST: db
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_PORT: 6379
      QUEUE_CONNECTION: redis
      LOG_CHANNEL: stderr
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - laravel
    restart: unless-stopped
 
volumes:
  db-data:
    driver: local
  redis-data:
    driver: local
  app-storage:
    driver: local
 
networks:
  laravel:
    driver: bridge

Aquesta configuració mereix una explicació detallada de diversos aspectes clau.

Dependències amb health checks#

En lloc de simplement dir depends_on: db, utilitzem condition: service_healthy. Això significa que el servei app no arrencarà fins que MySQL hagi passat el seu health check (és a dir, fins que realment accepti connexions). Sense aquesta condició, l'aplicació podria arrencar abans que MySQL estigui llest i les migracions fallarien. Cada servei defineix el seu propi health check: MySQL verifica amb mysqladmin ping, Redis amb redis-cli ping, i l'aplicació amb un endpoint HTTP /health.

Workers i scheduler#

El servei worker utilitza la mateixa imatge que l'aplicació, però amb una comanda diferent (queue:work en lloc de Supervisor). L'opció deploy.replicas: 2 arrenca dos workers en paral·lel per processar més jobs simultàniament. Pots ajustar aquest nombre segons la càrrega de l'aplicació. El paràmetre --max-time=3600 fa que el worker es reiniciï cada hora, evitant fuites de memòria. El paràmetre --max-jobs=1000 el reinicia després de processar 1000 jobs, una altra mesura de seguretat contra l'acumulació de memòria.

El servei scheduler executa schedule:work, que és la versió de llarga durada del scheduler. En lloc de necessitar un cron job que executi schedule:run cada minut, schedule:work s'executa contínuament i gestiona les tasques programades automàticament. Això és ideal per a contenidors perquè no cal configurar cron.

Restart policies#

La política restart: unless-stopped reinicia el contenidor automàticament si es mor per un error, però no el reinicia si l'has aturat manualment amb docker compose stop. Altres opcions són always (reinicia sempre, fins i tot després d'un stop manual) i on-failure (reinicia només si el procés surt amb un codi d'error diferent de zero).

Variables d'entorn#

Les variables com ${APP_KEY} i ${DB_PASSWORD} es llegeixen d'un fitxer .env a l'arrel del projecte. Això permet mantenir les credencials fora del codi font. Crea un fitxer .env al costat del docker-compose.yml amb els valors reals:

# .env per a Docker Compose (NO el .env de Laravel)
APP_KEY=base64:la-teva-clau-generada
APP_URL=https://laravelandorra.com
DB_DATABASE=laravel
DB_USERNAME=laravel
DB_PASSWORD=una-contrasenya-segura
DB_ROOT_PASSWORD=una-altra-contrasenya-segura

Mai pugis el fitxer .env al repositori de codi. Afegeix-lo al .gitignore. Per a producció, utilitza secrets del teu orquestrador (Docker Swarm secrets, Kubernetes secrets) o un servei de gestió de secrets com HashiCorp Vault o AWS Secrets Manager.

Fitxer .dockerignore#

El fitxer .dockerignore funciona com el .gitignore, però per a Docker. Quan executes docker build, Docker envia tot el contingut del directori actual (el "build context") al dimoni de Docker. Si no excloem els fitxers innecessaris, estem enviant gigabytes de dades que mai s'utilitzaran a la imatge. Això alenteix la construcció i pot fer que la imatge final sigui innecessàriament gran si algun COPY inclou fitxers no desitjats.

# .dockerignore
 
# Dependències (s'instal·len dins el contenidor)
/vendor
/node_modules
 
# Git
.git
.gitignore
.gitattributes
 
# Entorns
.env
.env.*
!.env.example
 
# IDE
.idea
.vscode
*.swp
*.swo
 
# Docker
docker-compose*.yml
Dockerfile
 
# Testing i desenvolupament
/tests
phpunit.xml
phpstan.neon
.php-cs-fixer.php
/coverage
 
# Documentació
*.md
LICENSE
 
# Cache i logs locals
/storage/logs/*
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/bootstrap/cache/*
 
# macOS
.DS_Store

Excloure .git és especialment important: el directori .git pot ser molt gran en projectes amb un historial llarg i conté informació sensible sobre tot l'historial del codi. Excloure vendor i node_modules és essencial perquè les dependències s'instal·len dins el contenidor amb les versions correctes per a l'arquitectura de la imatge.

Construir i executar#

Un cop tenim tots els fitxers configurats, construir i arrencar l'stack és senzill. La primera construcció trigarà més perquè ha de descarregar les imatges base i instal·lar totes les dependències. Les construccions posteriors seran molt més ràpides gràcies a la cache de Docker.

# Construir les imatges
docker compose build
 
# Construir sense cache (útil si alguna cosa ha fallat)
docker compose build --no-cache
 
# Arrencar tots els serveis en segon pla
docker compose up -d
 
# Veure l'estat dels serveis
docker compose ps
 
# Veure els logs de tots els serveis
docker compose logs -f
 
# Veure els logs d'un servei específic
docker compose logs -f app
 
# Executar migracions (si no les executa l'script d'arrencada)
docker compose exec app php artisan migrate --force
 
# Executar un comando dins del contenidor
docker compose exec app php artisan tinker
 
# Aturar tots els serveis
docker compose down
 
# Aturar i eliminar volums (PERD LES DADES)
docker compose down -v

Actualitzar l'aplicació#

Per desplegar una nova versió de l'aplicació, reconstrueix la imatge i reinicia els serveis. Docker Compose és prou intel·ligent per reiniciar només els serveis que han canviat:

# Obtenir el codi nou
git pull origin main
 
# Reconstruir i reiniciar
docker compose build app worker scheduler
docker compose up -d --no-deps app worker scheduler

El flag --no-deps indica a Docker Compose que no reiniciï els serveis dels quals depèn l'aplicació (MySQL, Redis). Només volem reiniciar els contenidors que contenen el nostre codi.

Container registry#

Una vegada construïda la imatge, la pots pujar a un registre de contenidors perquè els servidors de producció la puguin descarregar sense haver de construir-la des de zero. Això és especialment important en entorns amb múltiples servidors o amb un pipeline de CI/CD.

Docker Hub#

Docker Hub és el registre públic per defecte. Per a projectes privats, necessites un compte de pagament:

# Iniciar sessió
docker login
 
# Etiquetar la imatge
docker tag laravel-app:latest el-teu-usuari/laravel-app:latest
docker tag laravel-app:latest el-teu-usuari/laravel-app:1.0.0
 
# Pujar la imatge
docker push el-teu-usuari/laravel-app:latest
docker push el-teu-usuari/laravel-app:1.0.0

GitHub Container Registry (GHCR)#

Si el teu codi està a GitHub, GHCR és una opció convenient perquè integra directament amb els teus repositoris:

# Iniciar sessió amb un token de GitHub
echo $GITHUB_TOKEN | docker login ghcr.io -u el-teu-usuari --password-stdin
 
# Etiquetar i pujar
docker tag laravel-app:latest ghcr.io/el-teu-usuari/laravel-app:latest
docker push ghcr.io/el-teu-usuari/laravel-app:latest

Amazon Elastic Container Registry (ECR)#

Si desplegueu a AWS, ECR és l'opció natural. Ofereix integració directa amb ECS, EKS i altres serveis d'AWS:

# Autenticar-se amb ECR
aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin 123456789.dkr.ecr.eu-west-1.amazonaws.com
 
# Crear el repositori (només la primera vegada)
aws ecr create-repository --repository-name laravel-app
 
# Etiquetar i pujar
docker tag laravel-app:latest 123456789.dkr.ecr.eu-west-1.amazonaws.com/laravel-app:latest
docker push 123456789.dkr.ecr.eu-west-1.amazonaws.com/laravel-app:latest

Utilitza sempre etiquetes versionades (com 1.0.0 o el hash del commit de Git) a més de latest. Això permet fer rollback a versions anteriors ràpidament i saber exactament quina versió s'executa en producció.

Orquestració de contenidors#

Docker Compose és ideal per a desplegaments en un sol servidor, però quan la teva aplicació necessita escalar a múltiples servidors, necessites un orquestrador de contenidors. Un orquestrador gestiona la distribució dels contenidors entre servidors, el balanceig de càrrega, l'escalat automàtic, les actualitzacions sense downtime i la recuperació automàtica davant errors.

Docker Swarm#

Docker Swarm és l'orquestrador integrat a Docker. És la opció més senzilla perquè no requereix instal·lar res addicional: qualsevol instal·lació de Docker ja inclou Swarm. Utilitza el mateix format docker-compose.yml amb algunes extensions per a la secció deploy. Per a aplicacions petites i mitjanes que necessiten alta disponibilitat en uns pocs servidors, Swarm és una opció pragmàtica i poc complexa.

Amazon ECS#

Amazon ECS (Elastic Container Service) és el servei d'orquestració de contenidors d'AWS. Es pot utilitzar amb instàncies EC2 o amb Fargate (serverless). ECS s'integra nativament amb altres serveis d'AWS com ALB (balancejador de càrrega), CloudWatch (monitorització), IAM (permisos) i ECR (registre d'imatges). Si ja esteu a l'ecosistema AWS, ECS és sovint la millor opció pel nivell d'integració que ofereix.

Kubernetes#

Kubernetes (K8s) és l'orquestrador de contenidors més popular i potent. Ofereix funcionalitats avançades com auto-scaling, rolling updates, service discovery, gestió de secrets i molt més. La contrapartida és la complexitat: Kubernetes té una corba d'aprenentatge pronunciada i requereix una infraestructura significativa per gestionar-lo. Per a la majoria d'aplicacions Laravel, Kubernetes és excessiu, però si treballeu en una empresa que ja utilitza Kubernetes o necessiteu escalar a desenes de servidors, val la pena conèixer-ne els fonaments.

Kubernetes bàsic per a Laravel#

Si decideu utilitzar Kubernetes, necessitareu definir diversos recursos: un Deployment per gestionar les rèpliques de l'aplicació, un Service per exposar-la a la xarxa, ConfigMaps i Secrets per a la configuració, i opcionalment un HorizontalPodAutoscaler per a l'escalat automàtic.

Deployment#

El Deployment defineix quantes rèpliques de l'aplicació volem executar i com actualitzar-les. Kubernetes s'encarrega de distribuir les rèpliques entre els nodes del clúster i de reiniciar-les si alguna falla:

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-app
  labels:
    app: laravel
spec:
  replicas: 3
  selector:
    matchLabels:
      app: laravel
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: laravel
    spec:
      containers:
        - name: laravel
          image: ghcr.io/el-teu-usuari/laravel-app:latest
          ports:
            - containerPort: 80
          envFrom:
            - configMapRef:
                name: laravel-config
            - secretRef:
                name: laravel-secrets
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          readinessProbe:
            httpGet:
              path: /health
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              port: 80
            initialDelaySeconds: 30
            periodSeconds: 10

L'estratègia RollingUpdate amb maxUnavailable: 0 garanteix zero downtime durant les actualitzacions: Kubernetes arrencarà una nova rèplica amb el codi nou, esperarà que passi els health checks, i només llavors aturarà una rèplica antiga. Les probes readiness i liveness permeten a Kubernetes saber si un pod està llest per rebre tràfic i si està viu, respectivament.

Service#

El Service exposa el Deployment a la xarxa del clúster i distribueix el tràfic entre les rèpliques disponibles:

# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: laravel-service
spec:
  selector:
    app: laravel
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  type: ClusterIP

ConfigMap i Secrets#

Els ConfigMaps contenen la configuració no sensible i els Secrets les dades sensibles com contrasenyes i claus API. Kubernetes codifica els Secrets en base64 i permet controlar l'accés amb RBAC:

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: laravel-config
data:
  APP_ENV: "production"
  APP_DEBUG: "false"
  APP_URL: "https://laravelandorra.com"
  DB_CONNECTION: "mysql"
  DB_HOST: "mysql-service"
  DB_PORT: "3306"
  CACHE_STORE: "redis"
  SESSION_DRIVER: "redis"
  QUEUE_CONNECTION: "redis"
  LOG_CHANNEL: "stderr"
 
---
# k8s/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: laravel-secrets
type: Opaque
stringData:
  APP_KEY: "base64:la-teva-clau"
  DB_DATABASE: "laravel"
  DB_USERNAME: "laravel"
  DB_PASSWORD: "la-teva-contrasenya-segura"

HorizontalPodAutoscaler#

L'HPA escala automàticament el nombre de rèpliques basant-se en mètriques com l'ús de CPU o memòria. Quan la càrrega augmenta, Kubernetes arrenca noves rèpliques. Quan disminueix, les elimina:

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: laravel-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: laravel-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80

Aquesta configuració manté entre 2 i 10 rèpliques de l'aplicació. Quan l'ús de CPU supera el 70% o l'ús de memòria supera el 80%, Kubernetes arrenca rèpliques addicionals. Quan la càrrega baixa, elimina les rèpliques sobrants, mantenint sempre un mínim de 2 per a alta disponibilitat.

Kubernetes és una eina potent però complexa. Per a la majoria d'aplicacions Laravel, Docker Compose en un sol servidor o un servei gestionat com AWS ECS és suficient. Considera Kubernetes només si la teva organització ja el fa servir o si realment necessites les funcionalitats avançades que ofereix.

Bones pràctiques de seguretat#

La seguretat dels contenidors és un aspecte que sovint es descuida però que pot tenir conseqüències greus. Un contenidor mal configurat pot exposar credencials, permetre l'execució de codi arbitrari o donar accés al sistema host.

Usuari no-root#

Per defecte, els processos dins d'un contenidor Docker s'executen com a root. Si un atacant aconsegueix executar codi dins del contenidor (per exemple, a través d'una vulnerabilitat de l'aplicació), tindrà permisos de root. Per mitigar-ho, crea i utilitza un usuari sense privilegis:

# Al final del Dockerfile, abans del CMD
RUN addgroup -g 1000 -S appgroup \
    && adduser -u 1000 -S appuser -G appgroup
 
# Canviar la propietat dels fitxers
RUN chown -R appuser:appgroup /var/www/html/storage /var/www/html/bootstrap/cache
 
# Nginx i PHP-FPM ja s'executen com www-data internament,
# però Supervisor necessita root per gestionar-los.
# Alternativa: executar cada servei en el seu propi contenidor
# i utilitzar USER per canviar l'usuari.

Imatge base mínima#

Utilitzar imatges basades en Alpine Linux redueix la superfície d'atac perquè Alpine conté molts menys paquets que Debian o Ubuntu. Menys paquets significa menys vulnerabilitats potencials. A més, les imatges Alpine són molt més petites (5 MB vs 120 MB), cosa que accelera les descàrregues i redueix l'espai de disc.

Escaneig de vulnerabilitats#

Utilitza eines d'escaneig per detectar vulnerabilitats conegudes a les teves imatges. Serveis com Docker Scout, Trivy o Snyk analitzen les dependències de la imatge i t'alerten si alguna conté vulnerabilitats publicades:

# Escanejar amb Docker Scout
docker scout cves laravel-app:latest
 
# Escanejar amb Trivy
trivy image laravel-app:latest

Gestió de secrets#

Mai incloguis secrets (contrasenyes, claus API, tokens) directament al Dockerfile o al codi font. Utilitza variables d'entorn, fitxers .env exclosos del repositori o serveis de gestió de secrets:

# MAL: secret hardcoded al Dockerfile
ENV DB_PASSWORD=la-meva-contrasenya
 
# BÉ: passat com a variable d'entorn en execució
docker run -e DB_PASSWORD=la-meva-contrasenya laravel-app
 
# MILLOR: llegit d'un fitxer de secrets
docker run --env-file .env.production laravel-app

Consells pràctics#

Logs a stdout/stderr#

En un entorn de contenidors, els logs no s'han d'escriure a fitxers dins del contenidor. El contenidor pot ser destruït en qualsevol moment i els logs es perdrien. En lloc d'això, configura Laravel per enviar els logs a stderr, que Docker captura automàticament:

LOG_CHANNEL=stderr

O crea un canal personalitzat a config/logging.php:

'stderr' => [
    'driver' => 'monolog',
    'handler' => StreamHandler::class,
    'with' => [
        'stream' => 'php://stderr',
    ],
    'level' => env('LOG_LEVEL', 'warning'),
],

Aturada elegant (graceful shutdown)#

Quan Docker atura un contenidor, envia un senyal SIGTERM al procés principal. Si el procés no respon en 10 segons (per defecte), Docker envia SIGKILL, que mata el procés immediatament. Per evitar que els workers morin a meitat d'un job, assegura't que l'aplicació gestiona el SIGTERM correctament. La comanda queue:work de Laravel ja ho fa per defecte: quan rep SIGTERM, acaba el job actual i surt netament. Si els teus jobs poden ser llargs, augmenta el stop_grace_period al docker-compose.yml:

worker:
  # ...
  stop_grace_period: 300s  # 5 minuts per acabar el job actual

Endpoint de health check#

Crea un endpoint dedicat per als health checks que verifiqui que l'aplicació funciona correctament, incloent la connexió a la base de dades i Redis:

// routes/web.php
Route::get('/health', function () {
    try {
        DB::connection()->getPdo();
        Cache::store('redis')->get('health-check');
 
        return response()->json([
            'status' => 'healthy',
            'timestamp' => now()->toISOString(),
        ]);
    } catch (\Exception $e) {
        return response()->json([
            'status' => 'unhealthy',
            'error' => $e->getMessage(),
        ], 503);
    }
});

Aquest endpoint retorna un 200 si tot funciona i un 503 si alguna cosa falla. Docker, Kubernetes i els balancejadors de càrrega utilitzen aquest endpoint per saber si el contenidor està sa i pot rebre tràfic.

Compilació per a múltiples arquitectures#

Si necessites executar la imatge en servidors amb arquitectures diferents (per exemple, x86 per a producció i ARM per a desenvolupament en un Mac amb Apple Silicon), pots construir imatges multi-arquitectura:

# Crear un builder multi-arquitectura
docker buildx create --name multi-arch --use
 
# Construir per a x86 i ARM64
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    --tag el-teu-usuari/laravel-app:latest \
    --push .

Docker seleccionarà automàticament la imatge correcta per a l'arquitectura del servidor quan faci docker pull.