Guía Practica de Docker Compose para Ubuntu
Tabla de contenidos
- Que es Docker Compose
- Requisitos previos para instalar Docker Compose
- Ejemplo practico con Docker Compose
- Comandos esenciales de Docker Compose en Ubuntu
- Separar desarrollo y producción
- Buenas prácticas
Que es Docker Compose
Docker Compose es una herramienta que permite definir y gestionar aplicaciones multi-contenedor mediante un archivo YAML. En lugar de ejecutar docker run con flags interminables para cada servicio, describes toda tu infraestructura en un unico archivo y la levantas con un solo comando.

En Ubuntu es especialmente util porque:
- Elimina la necesidad de scripts de shell para arrancar servicios
- El mismo compose.yml funciona en local, staging y produccion
- Cualquier persona del equipo puede levantar el proyecto con un comando
- Se integra perfectamente con systemd para arranque automatico
Requisitos previos para instalar Docker Compose
Deberemos de tener instalados:
- Docker con compose v2
- Node.js
Docker Compose v2 viene integrado con Docker Engine. Solo necesitas instalar Docker y ya lo tienes disponible.
En el caso de Node.js para instalarlo deberemos ir a la pagina oficial y copiar el siguiente comando en la terminal.

Node solo lo necesitas en tu maquina para crear el package.json. La aplicacion en si corre completamente dentro de Docker, no necesitas Node para ejecutarla.
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
sudo apt-get install -y nodejsEjemplo practico con Docker Compose
Una aplicación de lista de tareas compartida entre varios usuarios. Cualquier persona que acceda a la URL puede añadir tareas, marcarlas como completadas o eliminarlas. El frontend se actualiza automaticamente cada 4 segundos sin recargar la pagina.
Arquitectura del proyecto

Servicios que usaremos
- nginx -> servirá el HTML y hace de proxy inverso hacia la API
api-> backend con Node.js con Express que expone la API REST - db-> PostgreSQL guarda las tareas de forma persistente
Endpoints de la API
| Método | Endpoint | Descripción |
| GET | /todos | Devuelve todas las tareas |
| POST | /todos | Crea una nueva tarea |
| PATCH | /todos/:id | Actualiza el estado (completada/pendiente) |
| DELETE | /todos/:id | Elimina una tarea |
1.Crear la estructura de carpetas
Crearemos la estructura de carpetas de nuestro pequeño proyecto con un solo comando:
mkdir -p ~/Escritorio/lista_tareas/{api,frontend}La estructura de carpetas y archivos que usaremos sera la siguiente:

2.Instalar dependencias de Node.js
El backend usa 3 paquetes:
- Express: Para el servidor HTTP
- pg: Para conectarse a PostgreSQL
- cors: Para permitir peticiones desde el frontend
cd ~/Escritorio/lista_tareas/api
# Inicializar package.json
npm init -y
# Instalar dependencias
npm install express pg cors
# Verificar que se instalaron
ls
# node_modules/ package.json package-lock.json3.Crear el Dockerfile de la API
El archivo Dockerfile le dice a Docker como construir la imagen del backend:
cat > ~/Escritorio/lista_tareas/api/Dockerfile << 'EOF'
FROM node:20-alpine
# Directorio de trabajo dentro del contenedor
WORKDIR /app
# Copiar PRIMERO los archivos de dependencias
# Esto permite que Docker cachee el npm install
# y no lo repita si solo cambia el codigo
COPY package*.json ./
RUN npm install
# Copiar el resto del codigo
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
EOFCreamos el .dockerignore para que node_modules no se copie al contenedor:
cat > ~/Escritorio/lista_tareas/api/.dockerignore << 'EOF'
node_modules
npm-debug.log
.env
EOF4.Crear el backend: index.js
Este es el corazón de nuestra aplicación. La API expone 4 endpoints que el frontend usará:
cat > ~/Escritorio/lista_tareas/api/index.js << 'EOF'
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
const app = express();
app.use(express.json());
app.use(cors());
// Conexion a PostgreSQL
// La variable DATABASE_URL viene del compose.yml
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Crear la tabla al arrancar si no existe
// Esto evita tener que ejecutar migraciones manualmente
async function initDB() {
await pool.query(`
CREATE TABLE IF NOT EXISTS todos (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
done BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
)
`);
console.log('Base de datos lista');
}
// GET /todos — devuelve todas las tareas
app.get('/todos', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM todos ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /todos — crea una nueva tarea
app.post('/todos', async (req, res) => {
const { text } = req.body;
if (!text || !text.trim()) {
return res.status(400).json({ error: 'El campo text es obligatorio' });
}
try {
const result = await pool.query(
'INSERT INTO todos (text) VALUES ($1) RETURNING *',
[text.trim()]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PATCH /todos/:id — cambia el estado completada/pendiente
app.patch('/todos/:id', async (req, res) => {
const { done } = req.body;
try {
const result = await pool.query(
'UPDATE todos SET done=$1 WHERE id=$2 RETURNING *',
[done, req.params.id]
);
if (!result.rows.length) {
return res.status(404).json({ error: 'Tarea no encontrada' });
}
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /todos/:id — elimina una tarea
app.delete('/todos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM todos WHERE id=$1', [req.params.id]);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Health check — Nginx lo usa para saber si la API esta lista
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Arrancar servidor
initDB().then(() => {
app.listen(3000, () => {
console.log('API corriendo en http://localhost:3000');
});
});
EOF5.Crear la configuración de Nginx
Nginx hace dos cosas:
- sirve el HTML estatico y actua como proxy inverso hacia la API
- El frontend nunca llama directamente a la API, siempre va a traves de Nginx
Esto es una buena practica de seguridad.
cat > ~/Escritorio/lista_tareas/frontend/nginx.conf << 'EOF'
server {
listen 80;
# Sirve el HTML estatico
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy inverso hacia la API Node.js
# /api/todos -> http://api:3000/todos
location /api/ {
proxy_pass http://api:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
EOFLa clave es el bloque location /api/. Cuando el navegador llama a /api/todos, Nginx lo redirige internamente a "http://api:3000/todos". La palabra 'api' es el nombre del servicio en compose.yml y funciona como hostname dentro de la red de Docker.
6.Creamos el frontend
Dividiremos el frontend en 3 archivos:
- index.html
- style.css
- app.js
Es recomendable que tengais instalado Vscode o similar
index.html
<html lang='es'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Todo List Colaborativa</title>
<!-- Carga el CSS externo -->
<link rel='stylesheet' href='style.css'>
</head>
<body>
<div class='contenedor'>
<h1>Lista de tareas</h1>
<p class='subtitulo'>Colaborativa · se actualiza cada 4 segundos</p>
<!-- Caja para escribir nuevas tareas -->
<div class='formulario'>
<input
type='text'
id='nueva-tarea'
placeholder='Escribe una tarea y pulsa Enter...'
maxlength='200'
>
<button id='btn-anadir' onclick='anadirTarea()'>Anadir</button>
</div>
<!-- Aqui aparece el error si la API no responde -->
<p class='error' id='mensaje-error'></p>
<!-- Botones para filtrar tareas -->
<div class='filtros'>
<button class='filtro activo' onclick='cambiarFiltro("todas", this)'>Todas</button>
<button class='filtro' onclick='cambiarFiltro("pendientes", this)'>Pendientes</button>
<button class='filtro' onclick='cambiarFiltro("completadas", this)'>Completadas</button>
</div>
<!-- La lista se rellena dinamicamente con JavaScript -->
<ul class='lista' id='lista-tareas'></ul>
<!-- Contador y hora de ultima actualizacion -->
<div class='pie'>
<span id='contador'>-</span>
<span id='ultima-sync'>-</span>
</div>
</div>
<!-- Carga el JS al final del body para que el HTML ya exista -->
<script src='app.js'></script>
</body>
</html>style.css
Código CSS organizado por secciones.Cada bloque esta comentado para facilitar la lectura y la comprensión del código.
/* ── GENERAL ──────────────────────────────────────────── */
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
margin: 0;
padding: 20px;
}
.contenedor {
max-width: 600px; /* Ancho maximo de la tarjeta */
margin: 0 auto; /* Centrar horizontalmente */
background-color: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
margin-bottom: 5px;
}
.subtitulo {
color: #888;
font-size: 0.85rem;
margin-bottom: 25px;
}
/* ── FORMULARIO ────────────────────────────────────────── */
.formulario {
display: flex; /* Input y boton en la misma fila */
gap: 10px;
margin-bottom: 15px;
}
.formulario input {
flex: 1; /* El input ocupa todo el espacio disponible */
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 1rem;
}
.formulario button {
padding: 10px 20px;
background-color: #2e86c1;
color: white;
border: none;
border-radius: 5px;
font-size: 1rem;
cursor: pointer;
}
.formulario button:hover { background-color: #1a6fa0; }
.formulario button:disabled { background-color: #aaa; cursor: not-allowed; }
/* ── MENSAJE DE ERROR ───────────────────────────────────── */
.error {
color: red;
font-size: 0.85rem;
min-height: 20px; /* Reserva espacio aunque este vacio */
margin-bottom: 10px;
}
/* ── FILTROS ────────────────────────────────────────────── */
.filtros {
display: flex;
gap: 8px;
margin-bottom: 15px;
}
.filtro {
padding: 6px 14px;
border: 1px solid #ccc;
border-radius: 5px;
background: white;
cursor: pointer;
font-size: 0.85rem;
}
.filtro.activo {
background-color: #333; /* Filtro seleccionado: fondo oscuro */
color: white;
border-color: #333;
}
/* ── LISTA DE TAREAS ────────────────────────────────────── */
.lista {
list-style: none; /* Sin puntos de lista */
padding: 0;
margin: 0;
}
.tarea {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #eee;
border-radius: 5px;
margin-bottom: 8px;
}
.tarea.completada { opacity: 0.5; }
.tarea.completada .tarea-texto { text-decoration: line-through; color: #999; }
.tarea-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #2e86c1; /* Color del check nativo del navegador */
}
.tarea-texto { flex: 1; font-size: 0.95rem; }
.tarea-fecha { font-size: 0.75rem; color: #aaa; }
.tarea-borrar {
background: none;
border: none;
color: #ccc;
font-size: 1.1rem;
cursor: pointer;
}
.tarea-borrar:hover { color: #e74c3c; }
/* ── ESTADO VACIO ───────────────────────────────────────── */
.vacio {
text-align: center;
padding: 30px;
color: #aaa;
}
/* ── PIE DE PAGINA ──────────────────────────────────────── */
.pie {
display: flex;
justify-content: space-between;
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #eee;
font-size: 0.8rem;
color: #aaa;
}app.js
Toda la lógica de nuestra app esta en el archivo JavaScript.Cada función hace una sola cosa.
// URL base de la API
// Nginx redirige /api/ hacia el contenedor Node.js internamente
const API = '/api';
// Estado de la aplicacion en memoria
let tareas = [];
let filtroActual = 'todas';
// ── CARGAR TAREAS ────────────────────────────────────────
// Pide la lista de tareas a la API y vuelve a pintar la pantalla
async function cargarTareas() {
try {
const respuesta = await fetch(API + '/todos');
if (!respuesta.ok) throw new Error('Error HTTP ' + respuesta.status);
tareas = await respuesta.json();
mostrarError('');
renderizar();
document.getElementById('ultima-sync').textContent =
'Actualizado: ' + new Date().toLocaleTimeString('es-ES');
} catch (error) {
mostrarError('Sin conexion con la API: ' + error.message);
}
}
// ── ANADIR TAREA ─────────────────────────────────────────
async function anadirTarea() {
const input = document.getElementById('nueva-tarea');
const texto = input.value.trim();
if (!texto) return; // No hacer nada si el campo esta vacio
const boton = document.getElementById('btn-anadir');
boton.disabled = true; // Evitar doble envio mientras espera
try {
const respuesta = await fetch(API + '/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: texto })
});
if (!respuesta.ok) throw new Error('Error HTTP ' + respuesta.status);
input.value = ''; // Limpiar el campo
await cargarTareas(); // Recargar la lista
} catch (error) {
mostrarError('Error al anadir: ' + error.message);
} finally {
boton.disabled = false; // Siempre reactivar el boton
input.focus();
}
}
// ── MARCAR COMO COMPLETADA / PENDIENTE ───────────────────
async function toggleTarea(id, estadoActual) {
try {
await fetch(API + '/todos/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: !estadoActual }) // Invertir el estado
});
await cargarTareas();
} catch (error) {
mostrarError('Error al actualizar la tarea');
}
}
// ── BORRAR TAREA ─────────────────────────────────────────
async function borrarTarea(id) {
try {
await fetch(API + '/todos/' + id, { method: 'DELETE' });
await cargarTareas();
} catch (error) {
mostrarError('Error al borrar la tarea');
}
}
// ── CAMBIAR FILTRO ───────────────────────────────────────
function cambiarFiltro(filtro, boton) {
filtroActual = filtro;
document.querySelectorAll('.filtro').forEach(b => b.classList.remove('activo'));
boton.classList.add('activo');
renderizar();
}
// ── RENDERIZAR LA LISTA ──────────────────────────────────
// Lee el array 'tareas', aplica el filtro y dibuja el HTML
function renderizar() {
// Filtrar segun el boton activo
const tareasFiltradas = tareas.filter(t => {
if (filtroActual === 'pendientes') return !t.done;
if (filtroActual === 'completadas') return t.done;
return true;
});
const lista = document.getElementById('lista-tareas');
if (tareasFiltradas.length === 0) {
lista.innerHTML = '<li class="vacio">No hay tareas aqui.</li>';
return;
}
// Construir el HTML de cada fila de tarea
lista.innerHTML = tareasFiltradas.map(t => `
<li class='tarea ${t.done ? 'completada' : ''}'>
<input
type='checkbox'
class='tarea-checkbox'
${t.done ? 'checked' : ''}
onchange='toggleTarea(${t.id}, ${t.done})'
>
<span class='tarea-texto'>${escaparHTML(t.text)}</span>
<span class='tarea-fecha'>${formatearFecha(t.created_at)}</span>
<button class='tarea-borrar' onclick='borrarTarea(${t.id})'>x</button>
</li>
`).join('');
// Actualizar el contador del pie
const pendientes = tareas.filter(t => !t.done).length;
document.getElementById('contador').textContent =
pendientes + ' pendientes de ' + tareas.length + ' total';
}
// ── UTILIDADES ───────────────────────────────────────────
function mostrarError(mensaje) {
document.getElementById('mensaje-error').textContent = mensaje;
}
// Evita que el texto de una tarea se interprete como HTML
function escaparHTML(texto) {
return texto
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Muestra la hora si es hoy, o la fecha si es de otro dia
function formatearFecha(timestamp) {
const fecha = new Date(timestamp);
const hoy = new Date();
if (fecha.toDateString() === hoy.toDateString()) {
return fecha.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
}
return fecha.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
}
// ── INICIO ───────────────────────────────────────────────
// Tambien enviar con la tecla Enter
document.getElementById('nueva-tarea').addEventListener('keydown', function(e) {
if (e.key === 'Enter') anadirTarea();
});
cargarTareas(); // Cargar al abrir la pagina
setInterval(cargarTareas, 4000); // Recargar cada 4 segundos7.Creación de .env y compose.yml
Archivo .env
Este archivo contiene las credenciales de la base de datos.
cat > ~/Escritorio/lista_tareas/.env << 'EOF'
DB_USER=todouser
DB_PASS=todopass123
DB_NAME=tododb
EOFEs recomendable proteger el archivo y que solo el propietario pueda leerlo
chmod 600 ~/Escritorio/lista_tareas/.envArchivo compose.yml
El compose.yml es el archivo que le dice a Docker qué contenedores levantar y cómo conectarlos entre sí.
Sin él tendrías que arrancar cada contenedor a mano con comandos largos, configurar las redes manualmente y recordar qué variables de entorno necesita cada uno. Con él, un solo comando docker compose up -d levanta todo el stack completo.
En el caso de nuestro ejemplo hace 3 cosas concretas:
- Los servicios —> nginx, api y db: qué imagen usar, qué puertos abrir, qué variables de entorno pasar
- Las redes —> que nginx puede hablar con la api, que la api puede hablar con la db, pero que nginx no puede hablar directamente con la db
- Los volúmenes —> que los datos de PostgreSQL sobreviven aunque pares o reinicies los contenedores
cat > ~/Escritorio/lista_tareas/compose.yml << 'EOF'
name: todo
services:
# Servidor web: sirve el HTML y hace proxy hacia la API
nginx:
image: nginx:alpine
ports:
- "80:80" # Accesible en http://localhost
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
- ./frontend/style.css:/usr/share/nginx/html/style.css:ro
- ./frontend/app.js:/usr/share/nginx/html/app.js:ro
depends_on:
- api
networks: [frontend]
restart: unless-stopped
# Backend: API REST con Node.js
api:
build: ./api # Construye desde el Dockerfile de ./api
environment:
DATABASE_URL: postgresql://${DB_USER}:${DB_PASS}@db:5432/${DB_NAME}
depends_on:
db:
condition: service_healthy # Espera a que la DB este lista
networks: [frontend, backend] # Puente entre nginx y la DB
restart: unless-stopped
# Base de datos: PostgreSQL
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: ${DB_NAME}
volumes:
- pg_data:/var/lib/postgresql/data # Datos persistentes
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks: [backend]
restart: unless-stopped
volumes:
pg_data: # Docker gestiona este volumen
networks:
frontend: # Nginx <-> API
backend: # API <-> DB (sin acceso a internet)
internal: true
EOFindex.html,app.js y style.css necesitan su propia linea en volumes. Si lo omites, Nginx devolvera 404 cuando el navegador intente cargarlo y la pagina aparecera sin estilos o sin funcionar.
8.Verificar la estructura antes de levantar
Antes de ejecutar docker compose up es conveniente comprobar que todos los archivos están en su sitio:
cd ~/Escritorio/lista_tareas
# Ver todos los archivos del proyecto
find . -not -path '*/node_modules/*' -not -name '*.lock' | sort
# Deberias ver:
# ./api/.dockerignore
# ./api/Dockerfile
# ./api/index.js
# ./api/package.json
# ./api/package-lock.json
# ./compose.yml
# ./.env
# ./frontend/index.html
# ./frontend/nginx.confA continuación verificaremos el compose.yml esta OK
docker compose config- Si todo esta bien veras la config completa en pantalla
- Si hay errores los vera aqui antes de levantar
9.Levantar el stack
dentro de la carpeta de nuestro proyecto ( /escritorio/lista_tareas), ejecutamos:
docker compose up -dLa primera vez que ejecutamos el comando Docker:
- Descarga las imagenes nginx:alpine y postgres:16-alpine
- Construye la imagen de la API desde el Dockerfile
- Crea los contenedores y los arranca
al ejecutar el comando verás una imagen similar a esta:

Para verificar que los 3 contenedores estan corriendo ejecutamos el comando:
docker compose ps
10.Probar nuestra aplicación
Abrimos el navegador y copiamos la URL:
http://localhostDeberás ver la interfaz de la app web que hemos creado.

Comrpobar logs en tiempo real
Si queremos ver los logs de nuestra API ejecutamos:

Verificar que los datos persisten
1.Para y vuelve a levantar los contenedores
docker compose down
docker compose up -d2.Vuelve a acceder a http://localhost y comprueba que los datos siguen ahí

Solución de errores frecuentes
Error: port 80 already in use
1.Comprobamos que hay en el puerto 80
sudo lsof -i :80
2.Si el puerto esta ocupado cambiamos el puerto en el compose.yml
ports:
"8080:80" <- accede en http://localhost:8080
Error: la API no conecta con la DB
Comprobaremos los logs de la api en busca del fallo
docker compose logs api
La columna STATUS de db debe decir "Up (healthy)"
Error:npm install did not complete / exit code 1
Asegurate de que en /Escritorio/lista_tareas/api existe package.json y package-lock.json
Si no existe deberemos ejecutar en dicha ubicación:
npm init -y
npm install express pg corsVuelve a levantar docker
docker compose up -d --buildLa pagina carga pero no responde la API
Comprobamos que los 3 contenedores esten corriendo
docker compose psMiramos los logs de nginx en busca del error
docker compose logs nginxProbamos la API( si esto funciona, el fallo esta en la configuracion de nginx)
docker compose exec api curl http://localhost:3000/healthComandos esenciales de Docker Compose en Ubuntu
| Comando | Categoría | Descripción | |
|---|---|---|---|
docker compose up -d |
arrancar | Levanta todos los servicios en background | |
docker compose up --build |
arrancar | Fuerza rebuild de imágenes antes de levantar. Úsalo cuando cambias el código | |
docker compose up -d --build |
arrancar | Rebuild y levanta en background. El más usado en desarrollo | |
docker compose restart api |
arrancar | Reinicia un servicio específico sin tocar el resto | |
docker compose pull |
arrancar | Descarga las últimas versiones de las imágenes del registry | |
docker compose stop |
parar | Para los contenedores pero los conserva (se pueden volver a arrancar) | |
docker compose down |
parar | Para y elimina contenedores y redes. Los volúmenes se conservan | |
docker compose down -v |
parar | También elimina los volúmenes ⚠ borra los datos de la DB | |
docker compose logs -f |
debug | Sigue los logs en tiempo real de todos los servicios | |
docker compose logs -f api |
debug | Sigue los logs solo del servicio indicado | |
docker compose exec api bash |
debug | Abre una shell dentro del contenedor. Usa sh si no tiene bash |
|
docker compose top |
debug | Muestra los procesos que corren dentro de cada contenedor | |
docker compose ps |
info | Estado de todos los servicios: corriendo, parado, healthy... | |
docker compose stats |
info | CPU, memoria y red en tiempo real de cada contenedor | |
docker compose config |
info | Valida el compose.yml y muestra la config final con variables resueltas | |
docker compose images |
info | Lista las imágenes usadas por los servicios del proyecto |
Levanta todos los servicios en background
Fuerza rebuild de imágenes antes de levantar. Úsalo cuando cambias el código
Rebuild y levanta en background. El más usado en desarrollo
Reinicia un servicio específico sin tocar el resto
Descarga las últimas versiones de las imágenes del registry
Para los contenedores pero los conserva (se pueden volver a arrancar)
Para y elimina contenedores y redes. Los volúmenes se conservan
También elimina los volúmenes ⚠ borra los datos de la DB
Sigue los logs en tiempo real de todos los servicios
Sigue los logs solo del servicio indicado
Abre una shell dentro del contenedor. Usa sh si no tiene bash
Muestra los procesos que corren dentro de cada contenedor
Estado de todos los servicios: corriendo, parado, healthy...
CPU, memoria y red en tiempo real de cada contenedor
Valida el compose.yml y muestra la config final con variables resueltas
Lista las imágenes usadas por los servicios del proyecto
Separar desarrollo y produccion
El error mas común es tener un único archivo compose.yml mezclando así la configuración de dev y producción.La solución es usar archivos de override.
Estructura recomendada

compose.yml -- base común
services:
api:
image: mi-org/api:latest
networks: [app-net]
db:
image: postgres:16-alpine
volumes:
- pg_data:/var/lib/postgresql/data
networks: [app-net]
volumes:
pg_data:
networks:
app-net:compose.override.yml -- solo para desarrollo
services:
api:
build: ./api # Build local en dev
volumes:
- ./api:/app # Hot reload del codigo
environment:
NODE_ENV: development
db:
ports:
- "127.0.0.1:5432:5432" # Acceso local a la DB en devcompose.prod.yml -- solo para producción
services:
api:
restart: unless-stopped
environment:
NODE_ENV: production
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
db:
restart: unless-stoppedComo usarlos
Desarrollo
Para desarrollo usaremos:
docker compose up -dProducción
Para producción usaremos:
docker compose -f compose.yml -f compose.prod.yml up -dCuando estemos en modo de producción podemos hacer que docker compose arranque automaticamente cuando encendamos el servidor.
Lo que haremos sera crear el servicio.
sudo nano /etc/systemd/system/mi-app.serviceen su interior escribiremos
[Unit]
Description=Mi App Docker Compose
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/mi-app
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.targetHabilitamos e iniciamos el servicio
sudo systemctl daemon-reload
sudo systemctl enable mi-app
sudo systemctl start mi-appBuenas practicas
Seguridad
- Nunca ejecutes contenedores como root si no es necesario.
- Exponer puertos de DB solo en 127.0.0.1, nunca en 0.0.0.0
- Protege el .env con chmod 600 .env
- Usa redes internas (internal: true) para aislar la capa de datos.
- Actualiza las imagenes periodicamente con docker compose pull
Mantenimiento
- Limpia recursos huerfanos regularmente: docker system prune -f
- Revisa el uso de disco: docker system df
- Usa versiones especificas de imagenes, nunca :latest en produccion
- Haz backup de volumenes antes de actualizar:
docker run --rm -v pg_data:/data -v $(pwd):/backup alpine tar czf /backup/pg_backup.tar.gz /data
Rendimiento
- Usa imagenes alpine cuando sea posible (mucho mas ligeras).
- Define limites de recursos con deploy.resources.limits para evitar que un contenedor consuma toda la RAM del servidor.
- En desarrollo, monta solo las carpetas necesarias en bind mounts, no todo el proyecto.