Skip to content

Docker y docker-compose ​

"En mi computadora sí funciona" es la frase más famosa del desarrollo. Docker la elimina: empaquetas tu app con todo lo que necesita (versión de Node o PHP, librerías, configuración) en una imagen que corre igual en tu laptop, en el servidor del cliente o en la nube. Con docker compose levantas app + base de datos + Nginx con un solo comando.

Concepto ​

TérminoAnalogíaQué es
ImagenLa receta (o un molde)Plantilla de solo lectura: sistema base + tu código + dependencias.
ContenedorEl pastel hecho con la recetaUna instancia en ejecución de una imagen. Puedes tener varios de la misma imagen.
DockerfileLa receta escritaArchivo con los pasos para construir una imagen.
VolumenUn disco externoAlmacenamiento que sobrevive aunque borres el contenedor (datos de la BD, uploads).
RedUna red local privadaPermite que los contenedores se hablen por nombre (db, app).
RegistroUna tienda de imágenesDocker Hub, GitHub Container Registry (GHCR).
ComposeEl director de orquestaArchivo YAML que define y levanta varios contenedores juntos.

Contenedor vs máquina virtual ​

Una VM incluye un sistema operativo completo (pesada, tarda minutos en arrancar). Un contenedor comparte el kernel del host y solo empaqueta lo necesario: pesa megabytes y arranca en segundos.

Instalación ​

En tu computadora ​

Instala Docker Desktop (Windows con WSL 2, macOS) desde la web oficial de Docker. En Linux puedes usar Docker Engine como en el servidor.

En Ubuntu 24.04 (repositorio oficial) ​

bash
# Dependencias y llave GPG oficial de Docker
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Agregar el repositorio
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Instalar Docker Engine + plugin compose
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Usar docker sin sudo (cierra sesión y vuelve a entrar después)
sudo usermod -aG docker $USER

# Probar
docker run --rm hello-world
docker compose version

Comandos esenciales ​

bash
# Imágenes
docker pull nginx:alpine          # descargar imagen
docker images                     # listar imágenes
docker rmi nginx:alpine           # borrar imagen

# Contenedores
docker run -d --name web -p 8080:80 nginx:alpine   # crear y arrancar en segundo plano
docker ps                         # contenedores corriendo
docker ps -a                      # todos (incluye detenidos)
docker logs -f web                # ver logs en vivo
docker exec -it web sh            # abrir una terminal dentro del contenedor
docker stop web && docker rm web  # detener y borrar

# Limpieza
docker system df                  # cuánto espacio usa Docker
docker system prune               # borrar lo que no se usa (pregunta antes)

-p 8080:80 significa "el puerto 8080 de mi máquina va al 80 del contenedor". Abre http://localhost:8080 y verás Nginx.

Ejemplo 1: Dockerfile para una app Node ​

Estructura:

txt
api/
├── src/
│   └── server.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
txt
# .dockerignore — lo que NO se copia a la imagen
node_modules
npm-debug.log
.git
.env
dist
Dockerfile
docker-compose.yml
dockerfile
# Dockerfile — multi-stage: una etapa para construir y otra ligera para ejecutar

# ---------- Etapa 1: dependencias y build ----------
FROM node:22-alpine AS build
WORKDIR /app

# Copiar primero package*.json aprovecha la caché: si no cambian, no se reinstala
COPY package*.json ./
RUN npm ci

COPY . .
# Si usas TypeScript o un bundler:
# RUN npm run build

# ---------- Etapa 2: producción ----------
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/src ./src

# No ejecutar como root: la imagen oficial trae el usuario "node"
USER node

EXPOSE 3000
CMD ["node", "src/server.js"]
bash
docker build -t api-tienda:1.0 .
docker run -d --name api -p 3000:3000 --env-file .env api-tienda:1.0
curl http://localhost:3000/api/salud

Escucha en 0.0.0.0 dentro del contenedor

Dentro de Docker, tu app debe escuchar en 0.0.0.0 (no en 127.0.0.1), o no recibirá tráfico del exterior del contenedor. Quien limita el acceso es la publicación de puertos (-p).

Ejemplo 2: Dockerfile para PHP con Apache ​

dockerfile
FROM php:8.3-apache

# Extensiones de PHP comunes
RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libpng-dev libicu-dev \
    && docker-php-ext-install pdo_mysql zip gd intl opcache \
    && a2enmod rewrite headers \
    && rm -rf /var/lib/apt/lists/*

# Configuración de PHP para producción
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Raíz pública en /var/www/html/public
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
    && sed -ri 's!AllowOverride None!AllowOverride All!g' /etc/apache2/apache2.conf

COPY . /var/www/html
RUN chown -R www-data:www-data /var/www/html

EXPOSE 80

Ejemplo 3: docker compose — app Node + PostgreSQL ​

yaml
# docker-compose.yml (o compose.yaml)
services:
  app:
    build: .
    container_name: tienda-app
    restart: unless-stopped
    env_file: .env
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    ports:
      # Solo accesible desde el propio servidor (Nginx del host hará de proxy)
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: tienda-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db_data:/var/lib/postgresql/data
      # Scripts .sql que se ejecutan la primera vez
      - ./database/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    # Sin "ports": la base solo es visible para los contenedores de esta red

volumes:
  db_data:
env
# .env (junto al docker-compose.yml, NO subir a Git)
NODE_ENV=production
PORT=3000
POSTGRES_USER=tienda
POSTGRES_PASSWORD=una-contraseña-larga-y-segura
POSTGRES_DB=tienda

Observa que la app se conecta a la base con el host db: el nombre del servicio funciona como nombre de red.

Variante con MySQL

yaml
  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: tienda
      MYSQL_USER: tienda
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      retries: 5

Comandos de compose ​

bash
docker compose up -d --build   # construir y levantar todo en segundo plano
docker compose ps              # estado de los servicios
docker compose logs -f app     # logs de un servicio
docker compose exec db psql -U tienda   # entrar a la base
docker compose exec app sh     # terminal dentro de la app
docker compose restart app     # reiniciar un servicio
docker compose down            # detener y borrar contenedores (los volúmenes se conservan)
docker compose down -v         # ¡también borra los volúmenes (datos)!

Ejemplo 4: Nginx dentro de compose ​

Si prefieres que todo viva en contenedores, agrega Nginx como servicio:

yaml
services:
  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

  app:
    build: .
    restart: unless-stopped
    env_file: .env
    # sin ports: solo Nginx la ve
nginx
# nginx/default.conf
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Para HTTPS en este escenario es más sencillo usar un proxy con certificados automáticos como Caddy o Traefik, o mantener Nginx + Certbot en el host y publicar la app en 127.0.0.1:3000 (como en el ejemplo 3).

Desplegar en un VPS ​

bash
# En el servidor (con Docker instalado)
git clone git@github.com:tu-usuario/tienda.git ~/tienda
cd ~/tienda
nano .env                 # variables de producción
docker compose up -d --build

# Actualizar tras cambios
git pull
docker compose up -d --build
docker image prune -f     # limpiar imágenes viejas

Y en el Nginx del host, un reverse proxy a 127.0.0.1:3000 + Certbot (ver Nginx y SSL).

Respaldo de la base en Docker ​

bash
# PostgreSQL
docker compose exec -T db pg_dump -U tienda tienda | gzip > respaldo_$(date +%F).sql.gz

# Restaurar
gunzip -c respaldo_2026-10-18.sql.gz | docker compose exec -T db psql -U tienda tienda

Docker se salta ufw

Publicar -p 5432:5432 o "3306:3306" expone la base a todo internet aunque ufw lo bloquee, porque Docker escribe sus propias reglas de iptables. Publica solo lo necesario y, si es interno, como 127.0.0.1:PUERTO:PUERTO.

Error común

Perder los datos con docker compose down -v o por no definir un volumen para la base. Sin volumen, los datos viven dentro del contenedor y desaparecen al recrearlo.

Buenas prácticas ​

  • Usa imágenes oficiales y etiquetas fijas (node:22-alpine, postgres:16), nunca latest en producción.
  • Multi-stage builds para imágenes pequeñas.
  • .dockerignore siempre (evita copiar node_modules y .env).
  • Ejecuta como usuario no root (USER node).
  • Secretos por variables de entorno o archivos montados, nunca dentro de la imagen.
  • restart: unless-stopped para que los servicios vuelvan tras un reinicio.
  • Healthchecks para que depends_on espere a que la base esté lista.

Ejercicios ​

  1. Escribe un Dockerfile para servir un sitio estático compilado (dist/) con nginx:alpine.
  2. Escribe un docker-compose.yml con una app PHP (Dockerfile del ejemplo 2) en el puerto 8080 y MySQL 8.4 con volumen.
  3. ¿Qué hostname usa la app para conectarse a la base en compose y por qué?
Ver solución
dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
yaml
services:
  web:
    build: .
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      DB_HOST: db
      DB_NAME: tienda
      DB_USER: tienda
      DB_PASS: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: tienda
      MYSQL_USER: tienda
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      retries: 5
volumes:
  mysql_data:
  1. Usa el nombre del servicio (db). Compose crea una red interna con DNS propio donde cada servicio es accesible por su nombre.

Resumen ​

  • Imagen = receta; contenedor = instancia en ejecución; volumen = datos persistentes.
  • Dockerfile multi-stage, .dockerignore y usuario no root.
  • docker compose up -d --build levanta app + base + proxy.
  • Los servicios se comunican por nombre; la base no necesita puertos publicados.
  • Cuidado: Docker se salta ufw; publica en 127.0.0.1 lo interno.
  • Siguiente: CI/CD con GitHub Actions.

Academia Web — aprende, copia y construye.