Guía Practica de Docker Compose para Ubuntu

Guía Practica de Docker Compose para Ubuntu
Guía Practica de Docker Compose para Ubuntu

Tabla de contenidos

  1. Que es Docker Compose
  2. Requisitos previos para instalar Docker Compose
  3. Ejemplo practico con Docker Compose
  4. Comandos esenciales de Docker Compose en Ubuntu
  5. Separar desarrollo y producción
  6. 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.

Gemini_Generated_Image_65mg0r65mg0r65mg (1).png

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:

  1. Docker con compose v2
  2. 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.
Captura de pantalla 2026-03-13 212519.png

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 nodejs

Ejemplo 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

Gemini_Generated_Image_nht11bnht11bnht1.png

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:

  1. Express: Para el servidor HTTP
  2. pg: Para conectarse a PostgreSQL
  3. 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.json
💡
El Dockerfile ejecuta 'npm install' durante el build. Si no existe el package.json antes de hacer docker compose up, el build falla con exit code 1. Siempre instala primero las dependencias.

3.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"]
EOF

Creamos el .dockerignore para que node_modules no se copie al contenedor:

cat > ~/Escritorio/lista_tareas/api/.dockerignore << 'EOF'
node_modules
npm-debug.log
.env
EOF
💡
Sin .dockerignore Docker copiaria tu carpeta node_modules local al contenedor. Esto puede causar errores si tu maquina es x86 y el contenedor tiene otra arquitectura.

4.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');
  });
});
EOF
💡
Llamar a initDB() antes de arrancar el servidor garantiza que la tabla existe cuando llegue la primera peticion. En produccion se usarían migraciones formales (como Flyway o node-pg-migrate), pero para desarrollo este patrón es perfecto.

5.Crear la configuración de Nginx

Nginx hace dos cosas:

  1. sirve el HTML estatico y actua como proxy inverso hacia la API
  2. 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;
    }
}
EOF

La 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:

  1. index.html
  2. style.css
  3. app.js

Es recomendable que tengais instalado Vscode o similar

frontend_folder_tree.svg

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

// 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 segundos

7.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
EOF

Es recomendable proteger el archivo y que solo el propietario pueda leerlo

chmod 600 ~/Escritorio/lista_tareas/.env

Archivo 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
EOF

index.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.

💡
Nginx solo esta en la red 'frontend'. La DB solo esta en 'backend' con internal:true, lo que significa que no tiene acceso a internet y Nginx no puede hablar con ella directamente. Solo la API actúa de puente entre las dos redes.

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.conf

A 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 -d

La primera vez que ejecutamos el comando Docker:

  1. Descarga las imagenes nginx:alpine y postgres:16-alpine
  2. Construye la imagen de la API desde el Dockerfile
  3. Crea los contenedores y los arranca

al ejecutar el comando verás una imagen similar a esta:

Captura de pantalla 2026-03-20 143838.png

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://localhost

Deberás ver la interfaz de la app web que hemos creado.

Captura de pantalla 2026-03-20 145208.png

Comrpobar logs en tiempo real

Si queremos ver los logs de nuestra API ejecutamos:

Captura de pantalla 2026-03-21 125507.png

Verificar que los datos persisten

1.Para y vuelve a levantar los contenedores

docker compose down

docker compose up -d

2.Vuelve a acceder a http://localhost y comprueba que los datos siguen ahí

Captura de pantalla 2026-03-21 130404.png

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 cors

Vuelve a levantar docker

docker compose up -d --build

La pagina carga pero no responde la API

Comprobamos que los 3 contenedores esten corriendo

docker compose ps

Miramos los logs de nginx en busca del error

docker compose logs nginx

Probamos la API( si esto funciona, el fallo esta en la configuracion de nginx)

docker compose exec api curl http://localhost:3000/health

Comandos esenciales de Docker Compose en Ubuntu

Comandos Docker Compose
```
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
```
```
docker compose up -d

Levanta todos los servicios en background

arrancar
docker compose up --build

Fuerza rebuild de imágenes antes de levantar. Úsalo cuando cambias el código

arrancar
docker compose up -d --build

Rebuild y levanta en background. El más usado en desarrollo

arrancar
docker compose restart api

Reinicia un servicio específico sin tocar el resto

arrancar
docker compose pull

Descarga las últimas versiones de las imágenes del registry

arrancar
docker compose stop

Para los contenedores pero los conserva (se pueden volver a arrancar)

parar
docker compose down

Para y elimina contenedores y redes. Los volúmenes se conservan

parar
docker compose down -v

También elimina los volúmenes ⚠ borra los datos de la DB

parar
docker compose logs -f

Sigue los logs en tiempo real de todos los servicios

debug
docker compose logs -f api

Sigue los logs solo del servicio indicado

debug
docker compose exec api bash

Abre una shell dentro del contenedor. Usa sh si no tiene bash

debug
docker compose top

Muestra los procesos que corren dentro de cada contenedor

debug
docker compose ps

Estado de todos los servicios: corriendo, parado, healthy...

info
docker compose stats

CPU, memoria y red en tiempo real de cada contenedor

info
docker compose config

Valida el compose.yml y muestra la config final con variables resueltas

info
docker compose images

Lista las imágenes usadas por los servicios del proyecto

info
```

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

Gemini_Generated_Image_jq6wz0jq6wz0jq6w (1).png

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 dev

compose.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-stopped

Como usarlos

Desarrollo

Para desarrollo usaremos:

docker compose up -d

Producción

Para producción usaremos:

docker compose -f compose.yml -f compose.prod.yml up -d

Cuando 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.service

en 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.target

Habilitamos e iniciamos el servicio

sudo systemctl daemon-reload


sudo systemctl enable mi-app

sudo systemctl start mi-app

Buenas 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.

Read more