DevOps

PHP y Docker en CI/CD: construcción de un entorno de desarrollo y pruebas reproducible desde cero

Ruslan Ismailov Publicado 12 min de lectura
P

Introducción: el problema de «works on my machine» en 2026

«En mi máquina funciona» — una frase que sigue costándole a los equipos semanas enteras de depuración. Las diferencias en versiones de PHP, extensiones, configuración de nginx y variables de entorno entre el portátil del desarrollador, el staging y el entorno de producción generan bugs casi imposibles de reproducir. En 2026, cuando los ciclos de release se han reducido a horas en lugar de días, la reproducibilidad del entorno no es una opción: es una condición imprescindible para la supervivencia del equipo.

Docker resuelve este problema a nivel de infraestructura: la misma imagen se ejecuta en el MacBook del desarrollador, en GitHub Actions y en el servidor de producción. Combinado con un pipeline CI/CD, esto ofrece un camino completamente automatizado desde el commit hasta el despliegue, con un entorno garantizadamente idéntico en cada etapa. Este artículo es una guía práctica: sin abstracciones, solo archivos y comandos concretos.

Estructura base del entorno Docker para un proyecto PHP

Un proyecto PHP típico requiere al menos tres contenedores: la aplicación (php-fpm), el servidor web (nginx) y la base de datos. La estructura de directorios marca la pauta de todo el proyecto:

project/\n├── docker/\n│   ├── php/\n│   │   ├── Dockerfile\n│   │   └── php.ini\n│   └── nginx/\n│       └── default.conf\n├── src/          # código fuente PHP\n├── docker-compose.yml\n├── docker-compose.override.yml  # sobrescrituras locales\n└── .env.example

Principios clave de organización:

  • Un proceso, un contenedor. php-fpm no gestiona el enrutamiento; nginx no ejecuta PHP.
  • Volúmenes solo para desarrollo. En la imagen de producción, el código debe estar integrado en su interior.
  • Red compartida. Todos los servicios se encuentran en la misma red Docker para una comunicación aislada.

Cómo escribir un Dockerfile correcto para PHP

Elección de la imagen base

En 2026, la base recomendada es php:8.3-fpm-alpine. Alpine proporciona un tamaño de imagen mínimo (~30 MB frente a los ~400 MB de las variantes Debian) y una superficie de ataque reducida. Para producción, evita las etiquetas latest — fija la versión menor.

# docker/php/Dockerfile\nFROM php:8.3-fpm-alpine3.19 AS base\n\n# Dependencias del sistema\nRUN apk add --no-cache \\\n    git \\\n    curl \\\n    libpng-dev \\\n    libzip-dev \\\n    icu-dev \\\n    oniguruma-dev \\\n    && docker-php-ext-install \\\n        pdo_mysql \\\n        mbstring \\\n        zip \\\n        gd \\\n        intl \\\n        opcache\n\n# Instalamos Composer\nCOPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer\n\n# Configuración de PHP\nCOPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini\n\n# Seguridad: no ejecutar como root\nRUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -s /bin/sh -D appuser\n\n# Etapa para producción\nFROM base AS production\nWORKDIR /var/www/html\nCOPY --chown=appuser:appgroup src/ .\nRUN composer install --no-dev --optimize-autoloader --no-interaction\nUSER appuser\n\n# Etapa para desarrollo\nFROM base AS development\nRUN apk add --no-cache $PHPIZE_DEPS \\\n    && pecl install xdebug \\\n    && docker-php-ext-enable xdebug\nWORKDIR /var/www/html\nUSER appuser

Consideraciones de seguridad

  • Nunca ejecutes el contenedor como root en producción — usa USER appuser.
  • No copies el archivo .env en la imagen — pasa las variables a través del entorno de ejecución.
  • Añade un .dockerignore para excluir node_modules, .git y los tests de la imagen de producción.
  • Analiza las imágenes periódicamente con docker scout cves o Trivy.

Docker Compose para desarrollo local

# docker-compose.yml\nservices:\n  php:\n    build:\n      context: .\n      dockerfile: docker/php/Dockerfile\n      target: development\n    volumes:\n      - ./src:/var/www/html\n      - composer_cache:/root/.composer\n    environment:\n      APP_ENV: local\n      DB_HOST: db\n      DB_PORT: 3306\n      DB_DATABASE: ${DB_DATABASE}\n      DB_USERNAME: ${DB_USERNAME}\n      DB_PASSWORD: ${DB_PASSWORD}\n    depends_on:\n      db:\n        condition: service_healthy\n    networks:\n      - app_network\n\n  nginx:\n    image: nginx:1.27-alpine\n    ports:\n      - \"8080:80\"\n    volumes:\n      - ./src:/var/www/html:ro\n      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro\n    depends_on:\n      - php\n    networks:\n      - app_network\n\n  db:\n    image: mysql:8.4\n    environment:\n      MYSQL_DATABASE: ${DB_DATABASE}\n      MYSQL_USER: ${DB_USERNAME}\n      MYSQL_PASSWORD: ${DB_PASSWORD}\n      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}\n    volumes:\n      - db_data:/var/lib/mysql\n    healthcheck:\n      test: [\"CMD\", \"mysqladmin\", \"ping\", \"-h\", \"localhost\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n    networks:\n      - app_network\n\nvolumes:\n  db_data:\n  composer_cache:\n\nnetworks:\n  app_network:\n    driver: bridge

Presta atención al healthcheck de la base de datos. Sin él, el contenedor PHP arranca antes de que MySQL acepte conexiones y la aplicación falla al iniciarse. La opción condition: service_healthy en depends_on resuelve este problema a nivel de Docker Compose.

Integración con CI/CD: pipeline en GitHub Actions

GitHub Actions es el estándar de facto para PHP Docker CI/CD en proyectos open-source y en la mayoría de los proyectos comerciales. El pipeline consta de cuatro etapas: linting, tests, build de la imagen y publicación en el registry.

# .github/workflows/ci.yml\nname: PHP CI/CD Pipeline\n\non:\n  push:\n    branches: [main, develop]\n  pull_request:\n    branches: [main]\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  lint:\n    name: Code Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: PHP CS Fixer\n        run: |\n          docker run --rm \\\n            -v ${{ github.workspace }}/src:/app \\\n            cytopia/php-cs-fixer:3 fix --dry-run --diff /app\n\n  test:\n    name: Unit & Integration Tests\n    runs-on: ubuntu-latest\n    needs: lint\n    services:\n      db:\n        image: mysql:8.4\n        env:\n          MYSQL_DATABASE: test_db\n          MYSQL_USER: test_user\n          MYSQL_PASSWORD: test_pass\n          MYSQL_ROOT_PASSWORD: root_pass\n        ports:\n          - 3306:3306\n        options: --health-cmd=\"mysqladmin ping\" --health-interval=10s --health-timeout=5s --health-retries=5\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build test image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: docker/php/Dockerfile\n          target: development\n          load: true\n          tags: php-app:test\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n      - name: Run PHPUnit\n        run: |\n          docker run --rm \\\n            --network host \\\n            -e APP_ENV=testing \\\n            -e DB_HOST=127.0.0.1 \\\n            -e DB_DATABASE=test_db \\\n            -e DB_USERNAME=test_user \\\n            -e DB_PASSWORD=test_pass \\\n            php-app:test \\\n            ./vendor/bin/phpunit --coverage-text\n\n  build-and-push:\n    name: Build & Push Image\n    runs-on: ubuntu-latest\n    needs: test\n    if: github.ref == 'refs/heads/main'\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}}\n            type=sha,prefix=sha-\n            type=raw,value=latest,enable={{is_default_branch}}\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: docker/php/Dockerfile\n          target: production\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max

Caché de capas Docker en CI

Sin caché, cada build en CI descarga todas las dependencias de nuevo — esto supone 3–5 minutos perdidos en cada push. En GitHub Actions hay dos enfoques principales disponibles:

  • GitHub Actions Cache (type=gha) — mecanismo integrado que no requiere servicios de terceros. Recomendado para la mayoría de proyectos.
  • Registry Cache (type=registry) — almacena la caché directamente en el container registry. Ideal para self-hosted runners sin caché compartida.

La estrategia clave para PHP es el orden correcto de las capas en el Dockerfile. Copia primero composer.json y composer.lock, instala las dependencias y solo entonces copia el código fuente. Esto garantiza que la capa con vendor/ solo se invalide cuando cambien las dependencias, no con cada modificación del código.

# Parte optimizada del Dockerfile para caché\nWORKDIR /var/www/html\n\n# Primero solo los archivos de dependencias\nCOPY src/composer.json src/composer.lock ./\nRUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction\n\n# Luego todo el código (esta capa se invalida con más frecuencia)\nCOPY src/ .\nRUN composer run-script post-install-cmd

Ejecución paralela de tests en Docker dentro del pipeline

Para suites de tests grandes (más de 1000 tests), la ejecución secuencial es un cuello de botella. GitHub Actions permite dividir los tests en grupos mediante la estrategia matrix:

  test-parallel:\n    name: Tests (shard ${{ matrix.shard }}/${{ matrix.total }})\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        shard: [1, 2, 3, 4]\n        total: [4]\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Run PHPUnit shard\n        run: |\n          docker run --rm \\\n            -e APP_ENV=testing \\\n            php-app:test \\\n            ./vendor/bin/phpunit \\\n              --testsuite=Unit \\\n              --group=shard${{ matrix.shard }}

Otra alternativa es usar paratest dentro de un único contenedor para paralelizar mediante procesos. En PHP Docker CI/CD esto suele ser más rápido que varios jobs matriciales, debido al overhead de arrancar múltiples contenedores.

Gestión de variables de entorno y secretos

Una de las principales fuentes de incidentes es la filtración de secretos a través de imágenes Docker o logs de CI. Reglas para trabajar con secretos en PHP Docker CI/CD:

  • Nunca añadas el archivo .env a la imagen Docker. Agrégalo al .dockerignore.
  • En GitHub Actions, almacena los secretos en Repository Secrets o en Environment Secrets (para gestión separada de staging y producción).
  • Pasa los secretos al contenedor mediante -e KEY=${{ secrets.KEY }}, no a través de build arguments.
  • Para proyectos complejos, usa HashiCorp Vault o AWS Secrets Manager con emisión dinámica de tokens.
      - name: Run migrations\n        run: |\n          docker run --rm \\\n            -e APP_KEY=${{ secrets.APP_KEY }} \\\n            -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \\\n            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \\\n            php artisan migrate --force

Los build secrets (mediante --secret id=... en Buildkit) permiten pasar datos sensibles durante el build de la imagen sin que queden registrados en las capas finales. Utiliza este mecanismo para Composer con repositorios privados.

Artefactos y versionado de imágenes

Un versionado correcto de las imágenes es la base de un rollback fiable. Estrategia de etiquetado recomendada:

  • latest — apunta siempre a la última imagen estable de la rama main.
  • sha-<commit-hash> — etiqueta inmutable para reproducir exactamente cualquier despliegue.
  • v1.2.3 — versión semántica a partir de un tag de Git para los releases.
  • develop-<date> — imágenes temporales para probar feature branches.

Para hacer rollback en producción basta con cambiar la etiqueta de la imagen al SHA anterior y reiniciar el servicio. Nunca dependas únicamente de latest para el rollback — es un antipatrón.

Errores habituales y cómo evitarlos

  • Xdebug en la imagen de producción. Añade Xdebug solo en la etapa development del Dockerfile. Reduce el rendimiento entre 3 y 5 veces.
  • Montar vendor/ mediante un volumen en CI. Esto rompe la caché de capas y ralentiza el pipeline. En CI, las dependencias deben estar dentro de la imagen.
  • Usar docker-compose up en CI sin comandos explícitos. Utiliza siempre servicios y comandos específicos; no arranques todo el stack.
  • Ausencia de .dockerignore. Sin él, el contexto de build incluye node_modules (cientos de MB), .git y datos de prueba.
  • Almacenar secretos en variables de entorno de la imagen (ENV). Son visibles mediante docker inspect. Pasa los secretos solo en tiempo de ejecución.
  • Versiones de PHP distintas entre el entorno local y CI. Fija la versión exacta de la imagen en ambos lugares y usa una única variable PHP_VERSION.

Checklist para un PHP CI/CD con Docker listo para producción

  1. El Dockerfile usa multi-stage build con etapas separadas development y production.
  2. La imagen base está fijada hasta la versión menor (por ejemplo, php:8.3.10-fpm-alpine3.19).
  3. El contenedor no se ejecuta como root; se ha creado un usuario sin privilegios.
  4. El archivo .dockerignore excluye .git, node_modules, .env y archivos de test.
  5. Docker Compose está configurado con healthcheck para todos los servicios con estado (BD, Redis).
  6. El pipeline de CI incluye las etapas: lint → test → build → push.
  7. La caché de capas está configurada mediante type=gha en docker/build-push-action.
  8. Las dependencias (composer.json) se copian en una capa separada antes que el código fuente.
  9. Los secretos se pasan en tiempo de ejecución mediante variables de entorno, no a través de ENV en el Dockerfile ni mediante build args.
  10. Las imágenes se etiquetan con el SHA del commit y la versión semántica; latest no se usa para desplegar en producción.
  11. El rollback está documentado y probado: cambio de etiqueta + reinicio del servicio.
  12. La ejecución paralela de tests está configurada para suites con más de 500 tests.

Un entorno de desarrollo reproducible con PHP Docker CI/CD no es una configuración puntual, sino un sistema vivo. Actualiza las imágenes base con regularidad, monitoriza los CVE con escáneres automáticos y revisa el pipeline cuando cambie la arquitectura del proyecto. Un equipo que configura correctamente esta infraestructura una sola vez ahorra horas en cada sprint.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →