PHP y Docker en CI/CD: construcción de un entorno de desarrollo y pruebas reproducible desde cero
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.examplePrincipios 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 appuserConsideraciones de seguridad
- Nunca ejecutes el contenedor como
rooten producción — usaUSER appuser. - No copies el archivo
.enven la imagen — pasa las variables a través del entorno de ejecución. - Añade un
.dockerignorepara excluirnode_modules,.gity los tests de la imagen de producción. - Analiza las imágenes periódicamente con
docker scout cveso 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: bridgePresta 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=maxCaché 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-cmdEjecució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
.enva 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 --forceLos 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 ramamain.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
developmentdel 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 upen 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 incluyenode_modules(cientos de MB),.gity datos de prueba. - Almacenar secretos en variables de entorno de la imagen (
ENV). Son visibles mediantedocker 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
- El Dockerfile usa multi-stage build con etapas separadas
developmentyproduction. - La imagen base está fijada hasta la versión menor (por ejemplo,
php:8.3.10-fpm-alpine3.19). - El contenedor no se ejecuta como
root; se ha creado un usuario sin privilegios. - El archivo
.dockerignoreexcluye.git,node_modules,.envy archivos de test. - Docker Compose está configurado con
healthcheckpara todos los servicios con estado (BD, Redis). - El pipeline de CI incluye las etapas: lint → test → build → push.
- La caché de capas está configurada mediante
type=ghaendocker/build-push-action. - Las dependencias (composer.json) se copian en una capa separada antes que el código fuente.
- Los secretos se pasan en tiempo de ejecución mediante variables de entorno, no a través de
ENVen el Dockerfile ni mediante build args. - Las imágenes se etiquetan con el SHA del commit y la versión semántica;
latestno se usa para desplegar en producción. - El rollback está documentado y probado: cambio de etiqueta + reinicio del servicio.
- 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í →