docker-compose de referencia para una aplicacion Laravel en produccion
Cinco servicios, cada rol en su contenedor, todo en loopback y con healthcheck. El archivo completo y el porque de cada decision.
Este es el docker-compose.yml del que parto para cada aplicación Laravel que despliego. Cinco servicios, cada rol en su contenedor, y todo publicado en loopback.
No es un compose de desarrollo: es el de producción, y las decisiones están explicadas abajo.
El archivo
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
container_name: miapp-app
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- appcode:/var/www/html
web:
image: nginx:alpine
container_name: miapp-web
restart: unless-stopped
depends_on: [app]
ports:
- "127.0.0.1:8082:80"
volumes:
- appcode:/var/www/html:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
queue:
build:
context: .
dockerfile: docker/Dockerfile
container_name: miapp-queue
restart: unless-stopped
env_file: .env
depends_on: [db]
command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
volumes:
- appcode:/var/www/html
scheduler:
build:
context: .
dockerfile: docker/Dockerfile
container_name: miapp-scheduler
restart: unless-stopped
env_file: .env
depends_on: [db]
command: php artisan schedule:work
volumes:
- appcode:/var/www/html
db:
image: postgres:17-alpine
container_name: miapp-db
restart: unless-stopped
env_file: .env
environment:
POSTGRES_DB: ${DB_DATABASE}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "127.0.0.1:5433:5432"
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
appcode:
dbdata:
Por qué cada decisión
Cuatro contenedores para la misma aplicación. PHP-FPM, nginx, el worker de colas y el scheduler. Comparten imagen y código, pero se reinician por separado: un worker atorado no tira el sitio, y reiniciar el sitio no mata un trabajo a la mitad.
Todos los puertos con 127.0.0.1. Es la línea más importante del archivo. Sin ese prefijo, Docker escribe reglas de iptables que se saltan el firewall y publicas tu Postgres a internet. Nadie te avisa: funciona, y también funciona para los demás.
Postgres en el 5433 del host. Para no chocar con un Postgres local en el 5432. Cuando tienes varias apps, cada una toma el suyo.
Healthcheck y service_healthy. Sin esto, la aplicación arranca antes que la base y las migraciones fallan en el primer despliegue. Con esto, espera.
Imágenes con versión fija. postgres:17-alpine, no postgres. Un up -d rutinario no debe poder traerte una versión mayor: eso se decide, no se sufre.
El código en un volumen con nombre, no en un bind mount. En producción el código sale de la imagen construida, no de la carpeta del host. nginx lo monta en solo lectura: no tiene por qué escribir en tu aplicación.
--max-time=3600 en el worker. Lo obliga a reciclarse cada hora. PHP en procesos largos acumula memoria, y un worker eterno es un fuga de memoria esperando.
El nginx que lo acompaña
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
client_max_body_size 32M;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Sin esto, las pantallas autenticadas dan 502:
# "upstream sent too big header".
fastcgi_buffer_size 32k;
fastcgi_buffers 16 32k;
fastcgi_busy_buffers_size 64k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
El último bloque niega el acceso a todo lo que empiece con punto —.env, .git— salvo .well-known, que certbot necesita.
Lo que falta y es tuyo
chmod 600 .env. Nace en 644 y ahí van todas tus contraseñas.- Contraseña distinta por stack:
openssl rand -hex 24. - El reverse proxy del host, con el certificado por subdominio.
- Respaldos de
dbdata. Un volumen no es un respaldo.