Tutoriales de VPS
Cómo configurar Docker Compose para producción en un VPS (2026): healthcheck, límites de recursos, logs, secretos y lista de rollback
Que Docker Compose levante los servicios no significa que ya estén listos para producción. Los problemas más comunes en un VPS son contenedores sin healthcheck, mal uso de las políticas de restart, CPU/memoria sin límites, logs que llenan el disco, secretos filtrados en .env, falta de copia de seguridad de los Volume antes de actualizar y ausencia de plan de rollback. Este artículo explica cómo reforzar Docker Compose para producción siguiendo el orden real de una puesta en marcha.

Docker Compose encaja muy bien en un VPS: una máquina, unos pocos servicios, un compose.yml, y es mucho más limpio que instalar Nginx, base de datos y Redis a mano.
Pero el archivo Compose de mucha gente solo logra "que arranque":
services:
app:
image: myapp:latest
restart: always
ports:
- "3000:3000"
Eso basta en un entorno de pruebas, pero en producción es peligroso. Un contenedor se cae y nadie se entera, los logs llenan el disco del sistema, la base de datos no tiene healthcheck, al actualizar se hace pull de latest directamente, .env está lleno de secretos y, cuando algo falla, nadie sabe cómo hacer rollback.
Este artículo no explica cómo instalar Docker. Para la instalación básica puedes ver:
- Despliegue con Docker en la práctica: la guía definitiva para aplicaciones en contenedores en un VPS
Este artículo resuelve un solo problema: tu proyecto Docker Compose ya funciona, ¿cómo lo conviertes en una configuración más adecuada para un entorno de producción en un VPS?
Una configuración de producción de Docker Compose en VPS bastante fiable debería considerar al menos:
- Fijar las versiones de las imágenes, no usar
latesta ciegas; - Que cada servicio crítico tenga un
healthcheck; - Una política de
restartrazonable; - Límites de CPU, memoria, número de procesos o ulimit;
- Rotación de logs para evitar llenar el disco;
- No escribir los secretos directamente en el archivo compose;
- Copias de seguridad de volúmenes de datos, bases de datos y directorios de subida;
- Un plan de rollback antes de actualizar.
Si solo quieres recordar una frase: Compose no es el problema; el problema es llevar el Compose del entorno de desarrollo a producción tal cual.
A muchos tutoriales les gusta escribir:
image: nginx:latest
image: postgres:latest
image: redis:latest
Es cómodo para pruebas locales, pero en un entorno de producción en VPS provoca incidentes con facilidad. Hoy haces docker compose pull, mañana la imagen upstream se actualiza, cambia la versión mayor, cambia el comportamiento de la configuración, cambia la compatibilidad de la base de datos y el sitio puede simplemente no arrancar.
Una forma más estable es fijar la versión:
services:
web:
image: nginx:1.26-alpine
db:
image: postgres:16-alpine
redis:
image: redis:7.2-alpine
Para las imágenes de aplicación también se recomienda usar tags explícitos:
image: ghcr.io/example/myapp:2026-06-14-1
No conviertas producción en "una caja sorpresa en cada actualización".
Si debes usar latest, al menos haz lo siguiente:
- Copia de seguridad de los volúmenes de datos antes de actualizar;
- Levántalo primero en una máquina de pruebas;
- Confirma los scripts de migración;
- Conserva la imagen de la versión anterior;
- Ten comandos de rollback claros.
depends_on en Compose solo resuelve el "orden de arranque"; no demuestra que el servicio esté realmente disponible.
Por ejemplo, el proceso del contenedor de base de datos arranca pero aún no acepta conexiones; el contenedor de la aplicación arranca pero la migración de la base de datos falla; Nginx arranca pero el backend del proxy inverso no responde. Sin healthcheck, solo ves el estado del contenedor como running, pero los usuarios ya están viendo el sitio caído.
Una aplicación web puede escribirse así:
services:
app:
image: ghcr.io/example/myapp:2026-06-14-1
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
PostgreSQL:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
MySQL / MariaDB:
services:
db:
image: mariadb:11
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root -p$${MARIADB_ROOT_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Redis:
services:
redis:
image: redis:7.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
Luego haz que la aplicación espere a que la base de datos esté healthy:
services:
app:
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
Nota: condition: service_healthy depende de Compose V2. Los tutoriales antiguos pueden no aplicarse.
Muchas plantillas escriben uniformemente:
restart: always
No está mal, pero no sirve para todos los servicios.
Opciones habituales:
| Política | Escenarios adecuados | Riesgos |
|---|---|---|
no | Tareas puntuales, scripts de migración | No se recupera automáticamente si falla |
on-failure | Servicios de tipo tarea, workers | No se reinicia si la salida no es anómala |
unless-stopped | La mayoría de servicios web | No se inicia automáticamente con el reinicio de Docker tras detenerlo manualmente |
always | Servicios base que deben estar siempre activos | Puede reiniciarse incluso tras detenerlo manualmente, fácil de malinterpretar |
Para proyectos pequeños en un VPS, normalmente se recomienda:
restart: unless-stopped
Para tareas de migración y tareas de inicialización, no pongas reinicio automático. De lo contrario, una migración fallida puede ejecutarse repetidamente y empeorar cuanto más la arreglas.
Si tu problema es que los servicios no se recuperan tras reiniciar el VPS, mira este artículo:
En un VPS lo que más miedo da es que un contenedor se descontrole, consuma toda la memoria de la máquina y al final el OOM Killer mate procesos al azar.
Compose puede limitar recursos. El soporte de deploy.resources ha variado históricamente según el escenario de Docker/Compose; hoy Compose V2 es más común, pero aun así debes verificarlo en tu propio entorno.
Ejemplo:
services:
app:
image: ghcr.io/example/myapp:2026-06-14-1
deploy:
resources:
limits:
cpus: "1.0"
memory: 768M
reservations:
memory: 256M
Si en tu entorno no surte efecto, puedes usar la forma compatible:
services:
app:
mem_limit: 768m
mem_reservation: 256m
cpus: 1.0
También puedes limitar el número de procesos:
services:
app:
pids_limit: 256
Y el número de archivos abiertos:
services:
nginx:
ulimits:
nofile:
soft: 65535
hard: 65535
¿Cómo configurarlo de forma razonable? Primero mira el tipo de servicio:
| Servicio | Enfoque de limitación sugerido |
|---|---|
| Nginx/Caddy | La CPU no necesita ser alta; nofile puede ampliarse |
| Aplicaciones Node/Python | Prioriza limitar la memoria para evitar que una fuga tumbe la máquina |
| MySQL/PostgreSQL | No se recomienda demasiado pequeño; la base de datos necesita memoria estable |
| Redis | Hay que configurar la política maxmemory propia de Redis |
| Worker/colas | Limita CPU/memoria para evitar que tareas por lotes saturen el VPS |
Si tu VPS ya está sufriendo OOM, puedes ver:
Los logs json-file por defecto de Docker, si no se limitan, llenan el disco con el funcionamiento prolongado.
Este tipo de fallo es muy común:
- Bucles de errores de la aplicación;
- Crecimiento desmedido del access log de Nginx;
- Crawlers o ataques CC machacando APIs;
- Contenedores que imprimen SQL o logs de debug en stdout;
- Un mes después, el disco del sistema de repente al 100%.
Añade límites de logs a los servicios:
services:
app:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
Se recomienda para Nginx, aplicaciones y workers. Con los logs de base de datos hay que ser más cuidadoso para evitar que información de diagnóstico clave se sobrescriba demasiado pronto, pero tampoco pueden crecer sin límite.
Consulta el uso de logs de Docker:
docker system df
sudo du -h /var/lib/docker/containers | sort -h | tail -20
Si Docker ya ha llenado el disco, puedes ver:
No hagas esto:
environment:
DATABASE_PASSWORD: my-secret-password
API_KEY: sk_live_xxx
compose.yml acaba fácilmente en Git, e incluso se copia en tickets, chats de grupo y documentos.
Una mejor práctica es:
services:
app:
env_file:
- .env.production
No hagas commit de .env.production en Git:
.env
.env.*
!.env.example
Proporciona un .env.example:
DATABASE_URL=postgres://user:password@db:5432/app
REDIS_URL=redis://redis:6379/0
APP_SECRET=change-me
Si usas Docker Swarm o un entorno más complejo, puedes considerar Docker secrets. Pero para un VPS único, con gestionar bien .env, configurar permisos y cifrar las copias de seguridad ya evitas la mayoría de incidentes.
Configura permisos:
chmod 600 .env.production
No escribas secretos en la imagen durante la fase de build. Distingue los argumentos de build de las variables de entorno en tiempo de ejecución.
A muchos archivos Compose les gusta hacer esto:
ports:
- "5432:5432"
- "6379:6379"
- "3000:3000"
Esto expone la base de datos, Redis y los puertos de la aplicación directamente a internet. En producción normalmente no hace falta.
Una forma más segura es:
- Exponer solo Nginx/Caddy en 80/443;
- Exponer los servicios de aplicación solo en la red interna de Docker;
- No mapear bases de datos y Redis a puertos públicos del host;
- Poner el panel de administración detrás de VPN, túnel SSH o lista blanca.
Ejemplo:
services:
proxy:
image: caddy:2
ports:
- "80:80"
- "443:443"
networks:
- frontend
app:
image: ghcr.io/example/myapp:2026-06-14-1
expose:
- "3000"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
networks:
- backend
networks:
frontend:
backend:
internal: true
internal: true puede evitar que la red backend acceda directamente a la red externa, lo que sirve para aislar la capa de base de datos. Pero atención: si algunos servicios necesitan acceso a internet para actualizar datos, no los pongas en una red totalmente internal.
No abras la base de datos a internet; esto ya se ha tratado aquí:
Los contenedores se pueden borrar, las imágenes se pueden volver a descargar, pero perder un Volume es un incidente real.
Primero lista dónde están los datos:
docker volume ls
docker volume inspect volume_name
En Compose se recomienda nombrarlos explícitamente:
volumes:
postgres_data:
redis_data:
uploads:
Móntalos en el servicio:
services:
db:
volumes:
- postgres_data:/var/lib/postgresql/data
app:
volumes:
- uploads:/app/uploads
Antes de actualizar, haz al menos tres cosas:
- Dump de la base de datos;
- Copia de seguridad del Volume;
- Guardar el
compose.ymly el.envactuales.
Si quieres hacer un simulacro de recuperación, puedes ver:
No creas que con las instantáneas del VPS es suficiente. Las instantáneas sirven para rollback a corto plazo; las copias de seguridad a largo plazo dependen de un repositorio de backup independiente.
En producción no hagas directamente:
docker compose pull
docker compose up -d
Un proceso más estable:
# 1. 记录当前版本
docker compose ps
docker images | grep myapp
# 2. 备份 compose 和 env
cp compose.yml compose.yml.bak.$(date +%F-%H%M)
cp .env.production .env.production.bak.$(date +%F-%H%M)
# 3. 备份数据库和关键 volume
# 这里按你的数据库类型执行 dump
# 4. 拉取新镜像
docker compose pull
# 5. 启动
docker compose up -d
# 6. 看健康状态
docker compose ps
docker compose logs --tail=100 app
Si falla, haz rollback:
# 改回旧镜像 tag
vim compose.yml
docker compose up -d
Por eso el tag de la imagen debe estar fijado. Si usas latest, al hacer rollback ni siquiera sabrás cuál era la versión anterior.
Abajo hay una plantilla simplificada; no es una configuración universal, pero incluye varias categorías de cosas que producción debe considerar:
services:
proxy:
image: caddy:2.8
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
app:
image: ghcr.io/example/myapp:2026-06-14-1
restart: unless-stopped
env_file:
- .env.production
expose:
- "3000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
mem_limit: 768m
cpus: 1.0
pids_limit: 256
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- .env.production
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"
networks:
- backend
redis:
image: redis:7.2-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
caddy_data:
caddy_config:
postgres_data:
redis_data:
Comprobación antes de publicar:
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=100
docker compose config es muy útil: expande la configuración final fusionada y te ayuda a detectar por adelantado problemas de sintaxis y variables.
Antes de poner un proyecto Compose en un entorno de producción en VPS, confirma al menos:
- Los tags de imagen están fijados y no dependen de
latest; - Web, base de datos y Redis tienen healthcheck;
- Los servicios críticos usan
restart: unless-stopped; - Los contenedores de aplicación tienen límites de CPU/memoria/PID;
- Los logs de Docker tienen max-size/max-file configurados;
-
.env.productionno está en Git y tiene permisos 600; - La base de datos y Redis no exponen puertos públicos;
- Los nombres de Volume son claros y la estrategia de backup está definida;
- Antes de actualizar se hace copia de seguridad de la base de datos, los Volume, el archivo compose y el env;
- Se puede hacer rollback con el tag de imagen anterior;
- El proxy inverso Nginx/Caddy y HTTPS funcionan correctamente;
-
docker compose psno muestra servicios unhealthy.
Es adecuado para proyectos pequeños y medianos, SaaS personales, sitios de herramientas, blogs, sistemas internos y aplicaciones multiservicio de baja complejidad. No es adecuado para orquestación compleja multinodo, pero para ejecutar unos pocos servicios en un VPS, Compose es más simple y controlable que Kubernetes.
Para la mayoría de servicios de producción en VPS, unless-stopped es más intuitivo. Después de detener el servicio manualmente, no volverá a arrancar automáticamente cuando Docker se reinicie. Para servicios que deben estar siempre activos y no deben verse afectados por una detención manual, puedes considerar always.
No necesariamente. healthcheck marca el estado de salud; si se reinicia o no depende también del comportamiento del servicio y de la orquestación externa. Como mínimo te permite usar docker compose ps, sistemas de monitorización y scripts de despliegue para determinar si el servicio está realmente disponible.
Un límite demasiado pequeño afecta al rendimiento, pero no limitar nada es más peligroso. En un VPS se recomienda fijar primero límites superiores conservadores para evitar que un solo contenedor tumbe la máquina, y luego ajustar gradualmente según la monitorización.
No. .env solo evita escribir secretos en el archivo compose. También debes asegurarte de que no entre en Git, de que los permisos sean suficientemente estrictos, de cifrar las copias de seguridad y de no imprimir secretos en los logs.
Ejecutar Docker Compose en producción en un VPS no tiene problema, siempre que no tomes una configuración que "arranca" como una configuración que "funciona de forma estable a largo plazo".
Lo que realmente hay que completar es: versiones fijas, healthchecks, límites de recursos, rotación de logs, gestión de secretos, aislamiento de puertos, copias de seguridad de Volume y actualizaciones con rollback. Con todo esto hecho, un proyecto Compose en un VPS normal pasa de "despliegue de juguete" a "despliegue de producción mantenible".