Instalar Compose v2
Si instalaste Docker desde su repositorio oficial, añade el plugin y verifica:
sudo apt update
sudo apt install -y docker-compose-plugin
docker compose version Evita tutoriales antiguos que descargan el binario docker-compose v1.
Crear un proyecto
sudo mkdir -p /opt/miapp
sudo chown "$USER":"$USER" /opt/miapp
cd /opt/miapp Crea compose.yaml:
services:
web:
image: nginx:1.28-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./public:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost"]
interval: 30s
timeout: 5s
retries: 3 Valida y despliega:
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=100 Variables y secretos
Compose lee .env para sustitución. Protege el archivo con chmod 600 .env y añádelo a .gitignore. Para secretos críticos, usa archivos montados o un gestor; evita imprimirlos en logs.
Volúmenes y backups
Usa volúmenes nombrados para datos persistentes. No hagas una copia ciega de una base activa: ejecuta pg_dump, mysqldump u otra herramienta consistente. Prueba la restauración.
Actualizar sin improvisar
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=10m Fija versiones en lugar de depender siempre de latest. Conserva un procedimiento de rollback y comprueba healthchecks antes de retirar la versión previa.
Comandos útiles
| Acción | Comando |
|---|---|
| Ver configuración final | docker compose config |
| Ver servicios | docker compose ps |
| Seguir logs | docker compose logs -f |
| Reiniciar uno | docker compose restart web |
| Detener conservando datos | docker compose down |
| Ver consumo | docker stats |
Después de aprender Compose puedes instalar Coolify para una experiencia PaaS o Portainer para gestión visual. Empieza con Docker en un VPS si aún no dominas imágenes, redes y volúmenes.
Cómo piensa Docker Compose un proyecto
Compose agrupa servicios, redes y volúmenes bajo un mismo nombre de proyecto. Esa agrupación es lo que permite ejecutar up, logs o down sin administrar cada contenedor por separado. El archivo no contiene los contenedores: declara el estado deseado, y Docker crea o reemplaza lo necesario para aproximarse a él.
Conviene distinguir cuatro elementos antes de escribir un despliegue real:
- Un servicio describe cómo ejecutar uno o varios contenedores de la misma aplicación.
- Una imagen contiene el sistema de archivos y el proceso que arrancará el contenedor.
- Una red permite que los servicios se encuentren por nombre sin publicar todos sus puertos.
- Un volumen conserva datos fuera de la vida del contenedor.
El nombre del directorio suele convertirse en el nombre del proyecto. Si copias el mismo compose.yaml a /opt/tienda y /opt/tienda-pruebas, Compose crea conjuntos independientes. También puedes fijarlo al ejecutar un comando:
docker compose --project-name tienda-prod up -d
docker compose --project-name tienda-prod ps Usar un nombre explícito evita sorpresas cuando una automatización cambia de directorio. No uses el mismo nombre para producción y pruebas porque compartirían nombres de red y sería fácil operar el entorno equivocado.
Un compose.yaml preparado para una aplicación real
Un archivo de producción debe limitar exposición, persistir los datos y expresar cómo se comprueba la salud. El siguiente ejemplo une una aplicación web y PostgreSQL. La aplicación solo escucha en loopback para que Nginx del host sea la entrada pública, mientras que la base de datos permanece únicamente en la red interna.
services:
app:
image: ghcr.io/empresa/tienda:1.7.2
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:8080:3000"
networks:
- frontend
- backend
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
networks:
frontend:
backend:
internal: true
volumes:
postgres_data: La red backend marcada como internal no tiene salida externa directa. El contenedor app participa también en frontend, por lo que puede hablar con el proxy o con otros servicios si se amplía el proyecto. PostgreSQL no incluye ports: app se conecta a db:5432 usando el DNS interno de Docker.
El healthcheck de la aplicación debe probar algo significativo y barato. Una ruta /health puede comprobar que el proceso atiende solicitudes; si además consulta todas las dependencias en cada llamada, una avería de la base puede provocar reinicios en cascada. Decide si necesitas una comprobación de vida, una de disponibilidad o ambas, en vez de copiar el mismo endpoint para todo.
Antes de levantar el ejemplo, crea .env sin subirlo a Git:
umask 077
cat > .env <<'EOF'
POSTGRES_DB=tienda
POSTGRES_USER=tienda_app
POSTGRES_PASSWORD=CAMBIA_ESTA_CLAVE
EOF
printf '.env\n' >> .gitignore
docker compose config --quiet config --quiet valida la sintaxis sin imprimir al terminal la configuración interpolada. Si ejecutas docker compose config completo, ten presente que puede mostrar valores sensibles y quedar guardado en el historial de una herramienta de soporte.
Redes y puertos: qué queda expuesto de verdad
expose documenta un puerto para otros contenedores; ports lo publica en el host. No hace falta declarar ninguno para que dos servicios de la misma red se comuniquen. Este detalle permite mantener Redis, PostgreSQL o una API interna fuera de internet.
Hay una diferencia importante entre estas dos publicaciones:
ports:
- "8080:3000" ports:
- "127.0.0.1:8080:3000" La primera escucha en todas las interfaces del VPS. La segunda solo acepta conexiones originadas en el propio host y es adecuada cuando Nginx hace de proxy reverso. No asumas que UFW compensa una publicación demasiado amplia: Docker administra reglas de red propias. Comprueba el resultado desde otra máquina y revisa las direcciones del host:
sudo ss -ltnp
curl -I http://127.0.0.1:8080/health Dentro de un contenedor, localhost siempre apunta a ese contenedor. Si app necesita PostgreSQL, la dirección correcta es db, no 127.0.0.1. Cuando aparece connection refused tras migrar a Compose, este error conceptual es más frecuente que una avería del motor.
Volúmenes, bind mounts y permisos
Usa volúmenes nombrados para datos administrados por un servicio y bind mounts cuando necesitas controlar un archivo concreto desde el host. Un volumen como postgres_data queda en el área de Docker y sobrevive a recreaciones. Un montaje ./nginx.conf:/etc/nginx/nginx.conf:ro hace visible un archivo del proyecto dentro del contenedor.
No montes el directorio completo del código en producción si la imagen ya lo contiene. Un bind mount como .:/app tapa los archivos incorporados durante la construcción y convierte el despliegue en dependiente del contenido accidental del VPS. Ese patrón es útil en desarrollo con recarga en caliente, no como sustituto de una imagen versionada.
Para revisar qué almacenamiento usa cada servicio:
docker compose config
docker volume ls
docker volume inspect tienda-prod_postgres_data
docker compose exec db sh -c 'du -sh /var/lib/postgresql/data' Los problemas de permisos aparecen cuando el proceso del contenedor usa un UID que no coincide con el propietario del directorio del host. No respondas con chmod -R 777: averigua el UID ejecutado, asigna el propietario correcto y concede solo el acceso necesario.
docker compose exec app id
stat -c '%u:%g %a %n' ./datos Backups consistentes de una base en Compose
Copiar el directorio de un volumen mientras la base escribe puede producir una copia incoherente. Usa la herramienta del motor para generar un volcado lógico y sácalo del VPS. Para PostgreSQL:
mkdir -p backups
docker compose exec -T db pg_dump \
-U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc \
> "backups/tienda-$(date +%F-%H%M).dump"
ls -lh backups/ La restauración debe ensayarse en una base distinta, no sobre producción:
docker compose exec -T db createdb -U "$POSTGRES_USER" tienda_restore
docker compose exec -T db pg_restore \
-U "$POSTGRES_USER" -d tienda_restore --clean --if-exists \
< backups/tienda-FECHA.dump Un respaldo que solo existe en /opt/miapp/backups se pierde junto con el disco del VPS. Cópialo cifrado a otra ubicación y registra cuánto tarda la restauración. Ese tiempo, no la mera existencia del archivo, determina si tu recuperación cumple lo que el negocio espera.
Desplegar una versión y poder volver atrás
El rollback más simple consiste en conservar una imagen anterior identificable y no ejecutar migraciones destructivas sin un plan compatible. Etiquetas como 1.7.2 son preferibles a latest, porque permiten saber exactamente qué binario corre.
Antes del cambio, registra el estado:
docker compose ps
docker compose images
docker compose logs --since=15m > /tmp/tienda-antes.log Cambia la etiqueta en compose.yaml, valida y despliega:
docker compose config --quiet
docker compose pull app
docker compose up -d --no-deps app
docker compose ps
docker compose logs --since=5m app
curl -fsS http://127.0.0.1:8080/health --no-deps evita recrear la base cuando solo cambió la aplicación. Si la prueba falla, restaura la etiqueta anterior y repite docker compose up -d --no-deps app. Esto no deshace una migración de esquema incompatible: las migraciones requieren su propia estrategia, por ejemplo cambios aditivos primero y eliminación de columnas en una entrega posterior.
No ejecutes docker image prune -a inmediatamente después de actualizar. Si eliminas la imagen anterior, conviertes un rollback rápido en una descarga que puede fallar justo durante el incidente.
Leer estado y logs sin reiniciar por reflejo
El estado Up solo indica que el proceso principal sigue vivo; no demuestra que la aplicación responda correctamente. Combina la vista de Compose con una petición al servicio y con los logs del periodo afectado.
docker compose ps
docker compose top
docker compose logs --tail=200 --timestamps app
docker compose exec app sh -c 'wget -qO- http://127.0.0.1:3000/health'
docker stats --no-stream Si un contenedor reinicia repetidamente, observa el código de salida antes de recrearlo:
docker inspect -f '{{.State.ExitCode}} {{.State.Error}} {{.RestartCount}}' tienda-prod-app-1
docker compose logs --tail=300 app Un código 137 suele acompañar una terminación forzada, a menudo por falta de memoria, pero debe confirmarse en los registros del kernel. Un error de configuración puede producir un ciclo igual de visible. Comprueba el motivo en lugar de aumentar RAM de inmediato.
Fallos habituales de Docker Compose en un VPS
| Síntoma | Comprobación | Corrección probable |
|---|---|---|
variable is not set | docker compose config | Crear .env o corregir el nombre |
La app no encuentra localhost:5432 | Revisar DATABASE_URL | Usar el servicio db:5432 |
| Datos desaparecen tras recrear | docker inspect y docker volume ls | Montar un volumen en la ruta real del motor |
| Puerto ocupado | sudo ss -ltnp | Liberar el puerto o cambiar la publicación |
Contenedor unhealthy | Inspeccionar salida del healthcheck | Corregir comando, ruta o tiempo de arranque |
| Disco crece sin tráfico | docker system df y tamaño de logs | Rotar logs y limpiar imágenes tras verificar rollback |
| Cambios del YAML no aparecen | docker compose up -d --force-recreate | Recrear solo los servicios afectados |
Cuando Compose no sabe cuál archivo usar, indica la ruta para evitar operar otro proyecto:
docker compose -f /opt/tienda/compose.yaml config --quiet
docker compose -f /opt/tienda/compose.yaml ps Seguridad y límites en un único servidor
Compose mejora la reproducibilidad, no crea una frontera de seguridad equivalente a una máquina virtual. Los contenedores comparten el kernel del VPS y el acceso al socket Docker concede control del host. Evita privileged: true, montajes de / y capacidades que la aplicación no necesita.
Cuando la imagen lo permite, endurece el servicio:
services:
app:
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL Estas opciones no son universales: una aplicación que escribe en rutas internas necesitará volúmenes o tmpfs específicos. Aplícalas en pruebas y verifica funciones como carga de archivos, generación de caché y envío de trabajos.
En un solo VPS, Compose sigue teniendo un punto único de fallo. Puedes reducir el tiempo de recuperación con un archivo versionado, imágenes disponibles y copias externas, pero no obtienes alta disponibilidad. Si el proyecto supera esa tolerancia, la decisión ya no es añadir otra sección al YAML: necesitas distribuir la carga o contratar una plataforma que opere los nodos.
Separar producción, pruebas y tareas puntuales
El mismo archivo base puede servir a varios entornos si las diferencias quedan declaradas y revisables. No copies compose.yaml completo para cambiar dos puertos: las copias divergen y un arreglo de seguridad termina aplicado solo a una. Usa un archivo adicional cuya intención sea explícita.
# compose.staging.yaml
services:
app:
image: ghcr.io/empresa/tienda:1.7.3-rc1
ports:
- "127.0.0.1:18080:3000"
environment:
APP_ENV: staging
db:
volumes:
- postgres_staging:/var/lib/postgresql/data
volumes:
postgres_staging: Valida la combinación antes de iniciarla:
docker compose -f compose.yaml -f compose.staging.yaml config --quiet
docker compose -f compose.yaml -f compose.staging.yaml \
--project-name tienda-staging up -d El nombre distinto del proyecto y el volumen distinto evitan que una prueba use la base de producción. Revisa la configuración resuelta, porque mapas y listas no siempre se combinan como alguien imagina: el último archivo puede reemplazar valores completos.
Los perfiles sirven para herramientas que no deben arrancar siempre. Por ejemplo, una consola administrativa:
services:
adminer:
image: adminer:5
profiles: ["debug"]
ports:
- "127.0.0.1:18081:8080"
networks:
- backend docker compose --profile debug up -d adminer
docker compose --profile debug stop adminer No dejes el perfil de diagnóstico activo después del incidente. Que escuche en loopback reduce exposición, pero sigue teniendo acceso a la red de base.
Ejecutar migraciones y trabajos únicos sin crear servicios permanentes
Una migración debe ejecutarse con la misma imagen y variables de la aplicación, pero no como un contenedor que reinicia indefinidamente. run --rm crea una ejecución puntual:
docker compose run --rm app node dist/migrate.js
docker compose run --rm app node dist/check-data.js Antes confirma el backup y compatibilidad con la versión anterior. Si la migración elimina columnas que el código viejo necesita, cambiar otra vez la etiqueta no constituye rollback.
Para tareas programadas, decide si el cron vive en el host o en un servicio dedicado. No pongas el mismo cron dentro de cada réplica de la aplicación: si escalas a tres contenedores, la tarea se ejecutará tres veces. Un servicio de job con bloqueo distribuido o una cola hace explícita esa responsabilidad.
services:
mantenimiento:
image: ghcr.io/empresa/tienda:1.7.2
profiles: ["jobs"]
env_file: [.env]
networks: [backend]
command: ["node", "dist/maintenance.js"] docker compose --profile jobs run --rm mantenimiento Registra salida y código de retorno desde el programador. Un contenedor que termina con cero puede eliminarse; uno que falla necesita conservar logs suficientes para saber qué operación quedó incompleta.
Comprobar qué cambió antes de recrear servicios
Compose no ofrece una revisión humana de intención: ejecutará la definición resuelta. Guarda la configuración normalizada sin secretos en un entorno seguro y compara los cambios de Git. Presta atención a volúmenes, comandos, puertos y nombres de imagen.
git diff -- compose.yaml compose.staging.yaml
docker compose config --images
docker compose config --services
docker compose pull --policy missing Un cambio que convierte un volumen nombrado en una ruta distinta puede levantar una base vacía sin borrar el volumen antiguo. Antes de declarar pérdida, inspecciona el inventario y los montajes de ambos contenedores. Del mismo modo, cambiar container_name puede provocar conflictos entre entornos y elimina parte de la flexibilidad de los nombres administrados por Compose; úsalo solo si existe una dependencia externa justificada.
Documentar dependencias externas que Compose no crea
El YAML solo reconstruye los recursos declarados; DNS, almacenamiento externo, reglas del proveedor y credenciales pueden quedar fuera. Añade al repositorio un inventario sin secretos que indique dominio, nombre de bucket, política de firewall, ubicación de backups y responsable. Si el VPS se pierde, esa lista evita descubrir dependencias durante la restauración.
Prueba el procedimiento en un directorio limpio con otro nombre de proyecto. Descarga las imágenes, crea .env desde el gestor seguro, restaura una base de prueba y ejecuta una acción de usuario. Registra qué pasos no estaban en Compose y decide si deben automatizarse o documentarse.
También verifica arquitectura de la imagen. Un respaldo perfecto no ayudará si la etiqueta fue eliminada del registro. Conserva artefactos necesarios o una forma probada de reconstruirlos desde el commit y dependencias fijadas.
Asignar recursos sin confundir límites con capacidad
Los límites protegen al host de un servicio descontrolado, pero no crean CPU ni RAM. Antes de fijarlos observa el consumo durante carga y despliegue. En un archivo Compose para un solo motor puedes declarar:
services:
app:
mem_limit: 768m
cpus: 1.5
pids_limit: 200 Comprueba que la versión de Compose interpreta las claves en tu modo de ejecución mediante docker compose config y luego inspecciona el contenedor. No copies únicamente deploy.resources de ejemplos de Swarm esperando que todos sus límites se apliquen igual en un motor local.
docker compose up -d app
docker inspect tienda-prod-app-1 \
--format 'memory={{.HostConfig.Memory}} cpus={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}' Si la aplicación alcanza memoria máxima, puede terminar y reiniciar; revisa OOMKilled y el kernel. Si alcanza CPU, se ralentiza y puede superar timeouts sin caer. Deja margen para el proxy, el sistema, Docker y la base, y no asignes a todos los servicios el 100 % de la máquina simultáneamente.
Después de ajustar, repite una operación real y observa docker stats. El objetivo es contener un fallo sin degradar permanentemente la carga legítima. Documenta por qué existe cada cifra y en qué medición se basó.
Nombrar y etiquetar recursos para saber a quién pertenecen
En un VPS con varios proyectos, nombres y etiquetas evitan limpiar el recurso equivocado. Compose ya aplica etiquetas de proyecto y servicio. Consúltalas antes de borrar:
docker ps --filter label=com.docker.compose.project=tienda-prod
docker volume ls --filter label=com.docker.compose.project=tienda-prod
docker network ls --filter label=com.docker.compose.project=tienda-prod No fijes container_name solo para obtener nombres bonitos: impide escalar un servicio a varias réplicas y puede chocar con otro proyecto. Los nombres generados expresan proyecto, servicio e instancia y son suficientes para la mayoría de operaciones.
Añade etiquetas propias cuando una automatización necesita responsable, entorno o política de respaldo. No almacenes secretos en etiquetas; son visibles mediante inspección. Una convención breve permite construir inventarios y alertas sin inferir propiedad por directorios o nombres históricos.
Compose convierte el despliegue en una definición revisable. Valida el YAML, limita los puertos, separa secretos, persiste datos y diseña actualizaciones y backups antes de llamar “producción” al proyecto.
Hosting con LiteSpeed, NVMe y soporte 24/7 desde $3/mes.