Tema
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érmino | Analogía | Qué es |
|---|---|---|
| Imagen | La receta (o un molde) | Plantilla de solo lectura: sistema base + tu código + dependencias. |
| Contenedor | El pastel hecho con la receta | Una instancia en ejecución de una imagen. Puedes tener varios de la misma imagen. |
| Dockerfile | La receta escrita | Archivo con los pasos para construir una imagen. |
| Volumen | Un disco externo | Almacenamiento que sobrevive aunque borres el contenedor (datos de la BD, uploads). |
| Red | Una red local privada | Permite que los contenedores se hablen por nombre (db, app). |
| Registro | Una tienda de imágenes | Docker Hub, GitHub Container Registry (GHCR). |
| Compose | El director de orquesta | Archivo 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 versionComandos 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
└── Dockerfiletxt
# .dockerignore — lo que NO se copia a la imagen
node_modules
npm-debug.log
.git
.env
dist
Dockerfile
docker-compose.ymldockerfile
# 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/saludEscucha 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 80Ejemplo 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=tiendaObserva 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: 5Comandos 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 venginx
# 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 viejasY 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 tiendaDocker 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), nuncalatesten producción. - Multi-stage builds para imágenes pequeñas.
.dockerignoresiempre (evita copiarnode_modulesy.env).- Ejecuta como usuario no root (
USER node). - Secretos por variables de entorno o archivos montados, nunca dentro de la imagen.
restart: unless-stoppedpara que los servicios vuelvan tras un reinicio.- Healthchecks para que
depends_onespere a que la base esté lista.
Ejercicios
- Escribe un Dockerfile para servir un sitio estático compilado (
dist/) connginx:alpine. - Escribe un
docker-compose.ymlcon una app PHP (Dockerfile del ejemplo 2) en el puerto 8080 y MySQL 8.4 con volumen. - ¿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 80yaml
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:- 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,
.dockerignorey usuario no root. docker compose up -d --buildlevanta 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.1lo interno. - Siguiente: CI/CD con GitHub Actions.