Si alguna vez te has encontrado con el famoso problema de "funciona en mi máquina pero no en el servidor", Docker es la solución definitiva. En esta guía práctica, vamos a recorrer Docker desde los conceptos fundamentales hasta configuraciones avanzadas con Docker Compose, multi-stage builds y debugging — todo con ejemplos que puedes ejecutar ahora mismo en tu terminal.
Llevo más de 3 años usando Docker en proyectos de producción con Laravel, Node.js y Python, y puedo decirte que una vez que lo integras en tu flujo de trabajo, no hay vuelta atrás. Vamos a ello.
¿Qué es Docker y por qué deberías usarlo?
Docker es una plataforma de contenedorización que empaqueta tu aplicación junto con todas sus dependencias (sistema operativo, librerías, configuraciones) en una unidad portable llamada contenedor. A diferencia de las máquinas virtuales, los contenedores comparten el kernel del sistema operativo host, lo que los hace extremadamente ligeros y rápidos.
Docker vs Máquinas Virtuales
| Característica | Docker | Máquina Virtual |
|---|---|---|
| Tiempo de inicio | Segundos | Minutos |
| Uso de RAM | Mínimo (comparte kernel) | Alto (OS completo) |
| Tamaño en disco | MBs | GBs |
| Aislamiento | A nivel de proceso | Completo (hardware virtualizado) |
| Portabilidad | Excelente | Buena |
Conceptos esenciales
Antes de escribir una sola línea, necesitas entender estos 5 conceptos:
- Imagen (Image): Plantilla de solo lectura con las instrucciones para crear un contenedor. Piensa en ella como una "clase" en programación orientada a objetos.
- Contenedor (Container): Una instancia en ejecución de una imagen. Es la "instancia" de esa clase — puedes tener múltiples contenedores de la misma imagen.
- Dockerfile: Archivo de texto con las instrucciones paso a paso para construir una imagen. Es tu "receta".
- Docker Compose: Herramienta para definir y ejecutar aplicaciones multi-contenedor (por ejemplo: app + base de datos + cache).
- Volumen (Volume): Mecanismo para persistir datos fuera del ciclo de vida del contenedor. Sin volúmenes, los datos se pierden al eliminar el contenedor.
Instalación de Docker
Windows
Descarga Docker Desktop para Windows desde la web oficial. Requiere WSL 2 habilitado. Después de instalar, verifica en PowerShell:
docker --version
# Docker version 27.x.x, build xxxxx
docker compose version
# Docker Compose version v2.x.x
macOS
Descarga Docker Desktop para Mac. Disponible para chips Intel y Apple Silicon (M1/M2/M3/M4):
brew install --cask docker
Linux (Ubuntu/Debian)
En Linux, instala Docker Engine directamente — no necesitas Docker Desktop. Sigue la guía oficial de instalación para Ubuntu:
# Agregar repositorio oficial de Docker
sudo apt-get update
sudo apt-get install 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
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Agregar tu usuario al grupo docker (evita usar sudo)
sudo usermod -aG docker $USER
newgrp docker
# Verificar instalación
docker run hello-world
Tu primer contenedor
Vamos a ejecutar un contenedor de Nginx para servir una página web estática:
# Descargar y ejecutar Nginx
docker run -d --name mi-web -p 8080:80 nginx:alpine
# Verificar que está corriendo
docker ps
Abre http://localhost:8080 en tu navegador y verás la página de bienvenida de Nginx. Así de simple.
Desglosemos el comando:
-d: Ejecuta en segundo plano (detached mode)--name mi-web: Le asigna un nombre legible al contenedor-p 8080:80: Mapea el puerto 8080 de tu máquina al puerto 80 del contenedornginx:alpine: Imagen de Nginx basada en Alpine Linux (solo ~7MB)
Comandos básicos que usarás a diario
# Ver contenedores en ejecución
docker ps
# Ver TODOS los contenedores (incluyendo detenidos)
docker ps -a
# Detener un contenedor
docker stop mi-web
# Iniciar un contenedor detenido
docker start mi-web
# Ver logs del contenedor
docker logs mi-web
docker logs -f mi-web # seguir logs en tiempo real
# Ejecutar un comando dentro del contenedor
docker exec -it mi-web sh
# Eliminar un contenedor (debe estar detenido)
docker stop mi-web && docker rm mi-web
# Eliminar TODOS los contenedores detenidos, imágenes sin uso, etc.
docker system prune -a
Creando tu propio Dockerfile
Vamos a crear una aplicación Node.js real y contenedorizarla. Primero, el proyecto:
mkdir docker-api && cd docker-api
npm init -y
npm install express
Crea el archivo server.js:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
// Simulamos una pequeña "base de datos" en memoria
let tareas = [
{ id: 1, titulo: 'Aprender Docker', completada: false },
{ id: 2, titulo: 'Crear un Dockerfile', completada: false },
];
app.get('/api/tareas', (req, res) => {
res.json({ total: tareas.length, datos: tareas });
});
app.post('/api/tareas', (req, res) => {
const nueva = {
id: tareas.length + 1,
titulo: req.body.titulo,
completada: false,
};
tareas.push(nueva);
res.status(201).json(nueva);
});
app.delete('/api/tareas/:id', (req, res) => {
tareas = tareas.filter(t => t.id !== parseInt(req.params.id));
res.json({ mensaje: 'Tarea eliminada' });
});
app.listen(PORT, () => {
console.log(`API corriendo en http://localhost:${PORT}`);
});
Ahora el Dockerfile:
# Usamos la imagen oficial de Node.js basada en Alpine
FROM node:20-alpine
# Creamos el directorio de trabajo
WORKDIR /app
# Copiamos PRIMERO los archivos de dependencias
# Esto aprovecha la caché de Docker (si package.json no cambia, no reinstala)
COPY package*.json ./
# Instalamos dependencias (solo producción)
RUN npm ci --only=production
# Copiamos el resto del código
COPY . .
# Exponemos el puerto (documentación, no abre el puerto realmente)
EXPOSE 3000
# Ejecutamos como usuario no-root por seguridad
USER node
# Comando para iniciar la app
CMD ["node", "server.js"]
Y el archivo .dockerignore (tan importante como .gitignore):
node_modules
npm-debug.log
.git
.gitignore
.env
Dockerfile
docker-compose.yml
README.md
Construye y ejecuta:
# Construir la imagen
docker build -t mi-api-node .
# Ejecutar el contenedor
docker run -d --name api -p 3000:3000 mi-api-node
# Probar la API
curl http://localhost:3000/api/tareas
Multi-Stage Builds: imágenes más pequeñas y seguras
En proyectos reales, tu imagen de desarrollo tiene herramientas (compiladores, devDependencies) que no necesitas en producción. Los multi-stage builds resuelven esto usando múltiples etapas FROM en un solo Dockerfile:
# --- Etapa 1: Build ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Si tu proyecto necesita compilación (TypeScript, etc.)
# RUN npm run build
# --- Etapa 2: Producción ---
FROM node:20-alpine AS production
WORKDIR /app
# Copiamos SOLO lo necesario desde la etapa de build
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/server.js ./
# Seguridad: no ejecutar como root
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Con este enfoque, la imagen final solo contiene las dependencias de producción y tu código compilado. En proyectos TypeScript o React, la diferencia puede ser de 800MB vs 150MB.
Volúmenes: persistencia de datos
Los contenedores son efímeros — cuando los eliminas, todo su contenido desaparece. Los volúmenes son el mecanismo de Docker para persistir datos:
# Crear un volumen con nombre
docker volume create datos-postgres
# Usar el volumen al ejecutar un contenedor
docker run -d \
--name mi-postgres \
-e POSTGRES_PASSWORD=secreto123 \
-e POSTGRES_DB=miapp \
-v datos-postgres:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16-alpine
# Los datos sobreviven aunque elimines el contenedor
docker stop mi-postgres && docker rm mi-postgres
# Si creas otro contenedor con el mismo volumen, los datos siguen ahí
docker run -d \
--name mi-postgres-2 \
-e POSTGRES_PASSWORD=secreto123 \
-v datos-postgres:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16-alpine
Bind mounts para desarrollo
Durante el desarrollo, quieres que los cambios en tu código local se reflejen inmediatamente en el contenedor, sin reconstruir la imagen. Para eso usamos bind mounts:
# Montamos el directorio actual como volumen
docker run -d \
--name api-dev \
-p 3000:3000 \
-v $(pwd):/app \
-v /app/node_modules \
mi-api-node
El truco de -v /app/node_modules crea un volumen anónimo para node_modules, evitando que los módulos del host sobreescriban los del contenedor (que pueden ser de un OS diferente).
Docker Compose: aplicaciones multi-contenedor
Las aplicaciones reales no son un solo contenedor. Típicamente tienes: aplicación + base de datos + cache + cola de trabajo. Docker Compose orquesta todo esto con un solo archivo YAML.
Crea el archivo docker-compose.yml:
version: '3.8'
services:
# Tu aplicación Node.js
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- PORT=3000
- MONGO_URI=mongodb://mongo:27017/miapp
- REDIS_URL=redis://redis:6379
volumes:
- .:/app
- /app/node_modules
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
# Base de datos MongoDB
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
healthcheck:
test: mongosh --eval "db.adminCommand('ping')" --quiet
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
# Cache con Redis
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
# Interfaz web para MongoDB (opcional pero útil)
mongo-express:
image: mongo-express
ports:
- "8081:8081"
environment:
- ME_CONFIG_MONGODB_SERVER=mongo
depends_on:
- mongo
volumes:
mongo-data:
redis-data:
Los comandos esenciales de Docker Compose:
# Levantar todos los servicios
docker compose up -d
# Ver logs de todos los servicios
docker compose logs -f
# Ver logs solo de un servicio
docker compose logs -f api
# Detener todos los servicios
docker compose down
# Detener y eliminar volúmenes (¡cuidado! borra datos)
docker compose down -v
# Reconstruir imágenes (cuando cambias el Dockerfile)
docker compose up -d --build
# Ver el estado de los servicios
docker compose ps
Healthchecks: verificación de salud
Nota cómo usamos healthcheck y depends_on con condition: service_healthy. Esto asegura que tu API no intente conectarse a MongoDB antes de que esté lista — un problema muy común que causa errores de conexión al iniciar.
Docker Networking: comunicación entre contenedores
Docker Compose crea automáticamente una red interna donde los contenedores se comunican usando sus nombres de servicio como hostname. Por eso en la variable MONGO_URI usamos mongo (el nombre del servicio) en lugar de localhost:
# Dentro de Docker Compose
MONGO_URI=mongodb://mongo:27017/miapp ✅ Correcto
MONGO_URI=mongodb://localhost:27017/miapp ❌ No funciona entre contenedores
Para inspeccionar las redes:
# Listar redes de Docker
docker network ls
# Inspeccionar una red específica
docker network inspect docker-api_default
Debugging: cuando las cosas no funcionan
Docker puede ser frustrante cuando algo falla sin explicación clara. Estas son las técnicas de debugging que uso constantemente:
# 1. Ver logs del contenedor
docker logs mi-contenedor --tail 50
# 2. Entrar al contenedor para investigar
docker exec -it mi-contenedor sh
# 3. Inspeccionar la configuración del contenedor
docker inspect mi-contenedor
# 4. Ver uso de recursos en tiempo real
docker stats
# 5. Ver los procesos dentro del contenedor
docker top mi-contenedor
# 6. Copiar archivos desde/hacia el contenedor
docker cp mi-contenedor:/app/logs/error.log ./error.log
docker cp ./config.json mi-contenedor:/app/config.json
# 7. Ver el historial de capas de una imagen
docker history mi-api-node
Errores comunes y cómo resolverlos
Error: "port is already allocated"
# Encontrar qué proceso usa el puerto
# En Linux/Mac:
lsof -i :3000
# En Windows:
netstat -ano | findstr :3000
# Solución: cambiar el puerto o detener el proceso
Error: "no space left on device"
# Limpiar todo lo que no se usa
docker system prune -a --volumes
# Ver cuánto espacio usa Docker
docker system df
Error: "EACCES permission denied" en volúmenes
# Problema común con USER node y bind mounts
# Solución: ajustar permisos en el Dockerfile
RUN mkdir -p /app && chown -R node:node /app
USER node
Ejemplo real: Laravel + MySQL + Redis con Docker
Para los que trabajan con PHP/Laravel (como yo en varios proyectos), aquí un docker-compose completo:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/var/www/html
depends_on:
mysql:
condition: service_healthy
environment:
- DB_HOST=mysql
- DB_DATABASE=laravel
- DB_USERNAME=laravel
- DB_PASSWORD=secreto
- REDIS_HOST=redis
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- .:/var/www/html
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- app
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: secreto
MYSQL_ROOT_PASSWORD: rootsecreto
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: mysqladmin ping -h localhost -u root --password=rootsecreto
interval: 10s
timeout: 5s
retries: 3
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
mysql-data:
Buenas prácticas para producción
- Usa imágenes Alpine:
node:20-alpinepesa ~50MB vs ~350MB denode:20. Consulta el Docker Hub de Node.js para ver las opciones disponibles. - No ejecutes como root: Siempre agrega
USER node(o el usuario apropiado) en tu Dockerfile. Si un atacante compromete tu contenedor, limitas el daño. - Un proceso por contenedor: No metas tu app, la base de datos y Redis en un solo contenedor. Docker está diseñado para un proceso principal por contenedor.
- Usa .dockerignore: Excluye
node_modules,.git,.env, archivos de test y documentación. Reduce el contexto de build y evita fugas de información sensible. - Ordena las instrucciones del Dockerfile: Pon primero lo que cambia menos (instalación de sistema, COPY package.json, RUN npm ci) y al final lo que cambia más (COPY . .). Esto maximiza el uso de caché.
- Fija versiones de las imágenes: Usa
node:20.11-alpineen lugar denode:latest. Conlatest, tu build puede romperse cualquier día sin que cambies nada en tu código. - Escanea vulnerabilidades: Ejecuta
docker scout quickview mi-imagenpara detectar vulnerabilidades conocidas en tus dependencias. Más información en la documentación de Docker Scout. - Usa healthchecks: Permiten que Docker (y orquestadores como Kubernetes) sepan si tu app está realmente funcionando, no solo si el proceso está vivo.
Cheat sheet de comandos Docker
| Comando | Descripción |
|---|---|
docker build -t nombre . | Construir imagen desde Dockerfile |
docker run -d -p 3000:3000 nombre | Ejecutar contenedor en background |
docker ps | Listar contenedores activos |
docker logs -f nombre | Seguir logs en tiempo real |
docker exec -it nombre sh | Abrir shell dentro del contenedor |
docker stop nombre | Detener contenedor |
docker rm nombre | Eliminar contenedor detenido |
docker images | Listar imágenes locales |
docker rmi nombre | Eliminar imagen |
docker system prune -a | Limpiar todo lo no utilizado |
docker compose up -d | Levantar servicios en background |
docker compose down | Detener y eliminar servicios |
docker compose logs -f | Ver logs de todos los servicios |
Próximos pasos
Con los fundamentos cubiertos, te recomiendo explorar estos temas para llevar Docker al siguiente nivel:
- Docker en CI/CD: Integra Docker con GitHub Actions o GitLab CI para automatizar builds y deployments.
- Docker Registry: Publica tus imágenes en Docker Hub o un registry privado.
- Kubernetes: Cuando necesites orquestar decenas o cientos de contenedores en producción.
- Docker Compose Profiles: Para manejar diferentes configuraciones (development, testing, production) en un solo archivo.
Docker ha cambiado fundamentalmente la forma en que desarrollamos y desplegamos software. Lo que antes requería horas de configuración manual ahora se resume en un docker compose up. Si estás empezando, mi consejo es: contenedoriza tu próximo proyecto desde el día uno. No esperes a que crezca — es más fácil empezar con Docker que migrarlo después.