Cómo instalar y usar Docker Compose en Ubuntu: guía práctica
Índice
- Qué es Docker Compose
- Requisitos previos para instalar Docker Compose en Ubuntu
- Primer ejemplo con Nginx
- Proyecto práctico: aplicación de tareas
- Solución de errores frecuentes
- Comandos esenciales
- Separar desarrollo y producción
- Buenas prácticas y seguridad
- Preguntas frecuentes
Qué 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 en Ubuntu
Para seguir esta guía necesitas:
-
Ubuntu 22.04 o 24.04 de 64 bits, o una versión compatible con los paquetes oficiales de Docker.
-
Un usuario con permisos sudo.
-
Docker Engine y Docker Compose v2. Si todavía no tienes Docker instalado, sigue primero nuestra guía para instalar Docker Engine en Ubuntu.
Docker Compose v2 funciona como un plugin de la CLI de Docker. En Ubuntu puedes instalarlo desde el repositorio oficial de Docker con:
sudo apt update
sudo apt install docker-compose-pluginComprueba que está disponible:
docker compose versionSi aparece una versión de Docker Compose, ya puedes utilizar comandos como docker compose up -d para iniciar los servicios definidos en un archivo Compose.
El proyecto práctico de esta guía utiliza Node.js en el backend. Lo instalaremos en el apartado correspondiente; no es necesario para instalar ni para ejecutar Docker Compose en sí.
Primer ejemplo con Nginx
Antes de crear el proyecto completo, vamos a comprobar cómo funciona Docker Compose con un servicio sencillo de Nginx.
Crea una carpeta para la prueba:
mkdir docker-compose-prueba
cd docker-compose-pruebaCrea el archivo compose.yaml:
services:
web:
image: nginx:alpine
ports:
- "8080:80"Guarda el archivo e inicia el servicio:
docker compose up -dAbre http://localhost:8080 en el navegador. Deberías ver la página de bienvenida de Nginx.

Para comprobar el estado del contenedor:
docker compose psPara detener y eliminar el contenedor creado por esta prueba:
docker compose downEste ejemplo muestra el flujo básico de Docker Compose: definir un servicio en YAML, iniciarlo con up, comprobarlo con ps y detenerlo con down.
En el siguiente apartado construiremos una aplicación con varios servicios para ver cómo se conectan entre sí.
Proyecto práctico: aplicación de tareas
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.

Comprobar logs en tiempo real
Si queremos ver los logs de nuestra API ejecutamos:
docker compose logs -f api

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/health
Comandos esenciales
| 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 sh |
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 producción
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-app
Buenas prácticas y seguridad
Seguridad
-
Nunca ejecutes contenedores como root si no es necesario.Siempre que sea posible, ejecuta los procesos de la aplicación con un usuario sin privilegios dentro del contenedor. Esto no sustituye otras medidas de seguridad, como limitar capacidades, proteger los secretos y mantener actualizadas las imágenes.
-
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.
Preguntas frecuentes
1¿Cómo instalar Docker Compose en Ubuntu?
Si ya tienes Docker Engine y el repositorio oficial de Docker configurado, puedes instalar el plugin de Docker Compose v2 con:
sudo apt update
sudo apt install docker-compose-plugin
Comprueba que se ha instalado correctamente con:
docker compose version
Si todavía no tienes Docker Engine instalado, configura primero el repositorio oficial de Docker y sigue los pasos de instalación correspondientes a tu versión de Ubuntu.
2¿Cuál es la diferencia entre docker compose y docker-compose?
docker compose es la sintaxis de Docker Compose v2, que funciona como un plugin de la CLI de Docker. docker-compose es la sintaxis tradicional de Compose v1, que utilizaba un guion y está obsoleta.
Para proyectos nuevos, utiliza los comandos de Compose v2, como docker compose up -d y docker compose down.
3¿Cómo iniciar un proyecto con Docker Compose?
Entra en el directorio que contiene el archivo compose.yaml o docker-compose.yml y ejecuta:
docker compose up -d
Docker Compose creará e iniciará los servicios definidos en el archivo. Para comprobar su estado, utiliza:
docker compose ps
Si necesitas ver los registros de los servicios, ejecuta docker compose logs -f.
4¿Cómo detener los contenedores de Docker Compose?
Desde el directorio del proyecto, ejecuta:
docker compose down
Este comando detiene y elimina los contenedores y las redes creadas por el proyecto. Por defecto, conserva los volúmenes con nombre, por lo que los datos persistentes no se eliminan automáticamente.
Importante: evita añadir la opción -v si quieres conservar los datos almacenados en los volúmenes del proyecto.
5¿Por qué Docker Compose no encuentra el archivo compose.yaml?
Este error suele aparecer cuando ejecutas el comando desde un directorio que no contiene el archivo de configuración o cuando el archivo tiene otro nombre.
Comprueba los archivos del directorio actual con:
ls -la
Después, entra en el directorio correcto o indica explícitamente la ruta del archivo con la opción -f:
docker compose -f /ruta/al/proyecto/compose.yaml up -d
Asegúrate también de que el archivo contiene una configuración YAML válida y de que estás utilizando la ruta correcta.