Veinticinco contenedores en un solo VPS: un stack por aplicacion
Cinco plataformas Laravel, un microservicio en Java, tres bots y almacenamiento S3 en una sola maquina. Por que un docker-compose por aplicacion y no uno gigante, y por que todo escucha en loopback.
Tengo un solo VPS. Dentro corren veinticinco contenedores: cinco plataformas Laravel, un microservicio en Java, tres bots de n8n, almacenamiento S3 y monitoreo. Cada uno con su base de datos.
La pregunta que me hacen cuando lo cuento es siempre la misma: por qué no todo en una base y un solo docker-compose.yml. La respuesta corta es que lo intenté y lo desarmé a los dos meses.
Un stack por aplicación, no un compose gigante
Cada aplicación vive en su propia carpeta con su propio compose:
~/apps/
pos/ docker-compose.yml .env
electoral/ docker-compose.yml .env
territorio/ docker-compose.yml .env
n8n-pipe/ docker-compose.yml .env
minio/ docker-compose.yml .env
Lo que se gana no es elegancia, es poder equivocarse en pequeño. Cuando reconstruyo la imagen de electoral, el POS no se entera. Cuando una migración deja la base de territorio a medias, restauro esa base. Y cuando quiero apagar algo, es docker compose down en una carpeta, no editar un archivo de cuatrocientas líneas rezando por no borrar el servicio equivocado.
El costo es real: hay cinco Postgres corriendo en vez de uno. En una máquina con memoria contada eso importa. En la mía, cada Postgres en reposo ocupa bastante menos de lo que cuesta un domingo restaurando la base equivocada.
Todo escucha en loopback
Ninguna aplicación publica un puerto al mundo. En el compose se ve así:
ports:
- "127.0.0.1:8082:80" # y no "8082:80"
La diferencia entre esas dos líneas es si tu Postgres está en internet o no. Con 8082:80, Docker escribe una regla en iptables que pasa por encima de tu firewall: puedes tener firewalld impecable y el puerto abierto de todas formas. Es el error que más he visto en servidores ajenos, y no da ninguna señal — todo funciona, solo que también funciona para los demás.
Con el prefijo 127.0.0.1, al puerto solo llega quien ya está dentro de la máquina. Y quien está dentro es nginx.
Un nginx de verdad al frente
El reverse proxy del host es el único que habla con internet. Tiene un server por subdominio, cada uno con su certificado de Let's Encrypt, y todos redirigen HTTP a HTTPS:
server {
server_name pos.laravelconmanuel.dev;
location / {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Agregar una aplicación nueva es: carpeta, compose, registro DNS, archivo de nginx, certbot. Cinco pasos, siempre los mismos. Esa repetición aburrida es la señal de que la arquitectura está bien: lo interesante debería pasar dentro de la aplicación, no en el despliegue.
Lo interno no sale a internet
El monitoreo y la consola de administración del almacenamiento no tienen subdominio. No están detrás de una contraseña: están detrás de no existirpara el exterior. Se llega a ellos por una red privada Tailscale o por un túnel SSH.
Es la regla que más tranquilidad me ha dado. Un panel de administración publicado con login es una superficie de ataque permanente que hay que parchar. Un panel que no responde desde internet no la tiene.
Versiones fijas, no "latest"
Todas las imágenes están pinneadas:
image: n8nio/n8n:2.34.6 # y no :latest
image: postgres:17-alpine
Con latest, un docker compose up -d rutinario puede traerte una versión mayor sin avisar. Lo descubres cuando algo dejó de funcionar y no sabes qué cambiaste, porque no cambiaste nada. Fijar la versión convierte una actualización en una decisión, que es lo que debería ser.
Lo que no haría otra vez
Reusar la misma contraseña de base de datos en dos stacks. La puse igual "mientras" y ese "mientras" duró tres semanas, hasta que una auditoría me la señaló. Si se filtra una, caen dos. Generar una aleatoria por stack cuesta un openssl rand -hex 24.