Llevas semanas escuchando que "tienes que aprender Docker" y cada vez que lo intentas acabas cerrando la terminal. No es porque sea difícil. Es porque la mayoría de tutoriales te explican qué es Docker antes de que entiendas qué problema resuelve.
Así que vamos al revés: primero el problema, luego la herramienta.
El problema que Docker resuelve
Llevas tres días terminando una feature. Funciona en tu ordenador. La subes al repositorio. Tu compañero hace un pull y le revienta con un error que tú no ves por ningún lado.
La razón: tu ordenador tiene Node.js 18, él tiene Node.js 16. Tú usas MySQL 8.0, el servidor de producción tiene MySQL 5.7. Mismo código, tres comportamientos distintos. Esto tiene nombre: "en mi máquina funciona", y antes de Docker se perdían horas cada semana en esto.
Docker soluciona el problema empaquetando tu aplicación con todo lo que necesita: código, dependencias y versiones exactas. Si funciona en tu contenedor, funciona igual en cualquier parte. Sin sorpresas.
¿Qué son los contenedores?
Un contenedor es una unidad de software que empaqueta el código y todas sus dependencias para que la aplicación se ejecute de forma rápida y fiable en cualquier entorno. Puedes imaginarlo como un proceso aislado: corre en tu sistema pero no sabe nada del exterior ni el exterior sabe nada de él.
La diferencia con una máquina virtual es el nivel en el que operan. Una VM emula un ordenador completo: necesita su propio sistema operativo, ocupa 2-3 GB mínimo y tarda minutos en arrancar. Un contenedor Docker comparte el sistema operativo del host, ocupa entre 10 y 100 MB y arranca en segundos. Mismo aislamiento, mucho menos peso.
Instalación paso a paso
Vamos a instalar Docker correctamente. El proceso cambia según tu sistema operativo:
Windows
Necesitas Windows 10 versión 1903 o superior (o Windows 11), WSL 2 activado y virtualización habilitada en la BIOS. Si no tienes WSL 2, Docker Desktop te lo pedirá durante la instalación. Si tu BIOS tiene la virtualización desactivada, el instalador se quejará con un error sobre "Hyper-V not supported" y no continuará.
Paso 1: Descarga Docker Desktop desde docker.com (clic en "Download for Windows")
Paso 2: Ejecuta el instalador como administrador
Paso 3: Durante la instalación, asegúrate de marcar "Use WSL 2"
Paso 4: Reinicia tu ordenador
Paso 5: Abre Docker Desktop y espera a que termine de inicializarse
Linux (Ubuntu/Debian)
Abre tu terminal y ejecuta estos comandos uno por uno:
# Actualizar paquetes
sudo apt update
# Instalar dependencias
sudo apt install apt-transport-https ca-certificates curl software-properties-common
# Añadir clave GPG oficial de Docker
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Añadir repositorio Docker
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Instalar Docker
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io
# Añadir tu usuario al grupo docker (evita usar sudo)
sudo usermod -aG docker $USER
Importante: Después del último comando, cierra sesión y vuelve a iniciarla para aplicar los cambios. El error más típico aquí: ejecutas un comando docker y ves "Got permission denied while trying to connect to the Docker daemon socket". Significa que cerrar solo el terminal no fue suficiente. Cierra la sesión del sistema operativo por completo.
macOS
Paso 1: Descarga Docker Desktop para Mac desde docker.com
Paso 2: Arrastra Docker.app a tu carpeta Aplicaciones
Paso 3: Abre Docker desde Aplicaciones
Paso 4: Acepta los permisos necesarios
Verificar instalación
Abre tu terminal o Command Prompt y ejecuta:
docker --version
Deberías ver algo como:
Docker version 29.7.2, build 8a2f1c9
Si ves el número de versión, ¡Docker está instalado correctamente!
Tu primer contenedor
Vamos a crear tu primer contenedor con un ejemplo súper simple. En tu terminal, ejecuta:
docker run hello-world
Lo que hace: busca la imagen "hello-world" en tu ordenador, como no la encuentra la descarga de Docker Hub, crea un contenedor con esa imagen, lo ejecuta, te muestra el mensaje y se cierra solo.
Si has visto el mensaje "Hello from Docker!", ya tienes el primer contenedor ejecutado. No parece gran cosa, pero acabas de confirmar que tu instalación funciona y que Docker puede descargar imágenes y ejecutarlas.
Containerizar una aplicación web real
Ahora viene lo interesante. Vamos a crear una aplicación web simple y la vamos a containerizar.
Paso 1: Crear la aplicación
Crea una carpeta nueva para tu proyecto:
mkdir mi-app-docker
cd mi-app-docker
Crea un archivo app.js con este contenido:
// app.js - Servidor web simple
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Ruta principal
app.get('/', (req, res) => {
res.json({
message: '¡Hola desde Docker!',
version: '1.0.0',
timestamp: new Date().toISOString()
});
});
// Iniciar servidor
app.listen(PORT, () => {
console.log(`🚀 Servidor ejecutándose en puerto ${PORT}`);
});
Crea el archivo package.json:
{
"name": "mi-app-docker",
"version": "1.0.0",
"description": "Mi primera app containerizada",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
Paso 2: Crear el Dockerfile
Aquí viene la magia. El Dockerfile es la receta que le dice a Docker cómo crear tu contenedor.
Crea un archivo llamado exactamente Dockerfile (sin extensión):
# Usar Node.js versión 24 (LTS) como base
FROM node:24-alpine
# Establecer directorio de trabajo dentro del contenedor
WORKDIR /app
# Copiar package.json y package-lock.json
COPY package*.json ./
# Instalar dependencias
RUN npm install
# Copiar código fuente
COPY . .
# Exponer puerto 3000
EXPOSE 3000
# Comando para iniciar la aplicación
CMD ["npm", "start"]
El orden de las instrucciones importa. Copiamos package.json antes que el código fuente porque Docker cachea cada capa. Si solo cambias el código, Docker no reinstala las dependencias. Si lo pusieras al revés, ejecutaría npm install en cada build aunque no hayas tocado ningún paquete. Aquí es donde la mayoría pierde tiempo la primera vez.
Paso 3: Construir la imagen
Ahora construimos nuestra imagen Docker:
docker build -t mi-app .
Este comando:
build: construye una imagen-t mi-app: le da nombre "mi-app" a la imagen.: busca el Dockerfile en la carpeta actual
❌ Error común:
Si obtienes "no such file or directory", asegúrate de estar en la carpeta que contiene tu Dockerfile y que el archivo se llama exactamente Dockerfile
Paso 4: Ejecutar el contenedor
docker run -p 3000:3000 mi-app
La opción -p 3000:3000 conecta el puerto 3000 de tu ordenador con el puerto 3000 del contenedor.
Abre tu navegador y ve a http://localhost:3000
Deberías ver tu aplicación funcionando desde dentro de un contenedor. Si la imagen ha tardado mucho en construirse o pesa más de 500 MB, casi siempre es porque falta el .dockerignore. Sin él, Docker copia también node_modules entero. Crea el archivo en la raíz del proyecto con estas dos líneas: node_modules y .git.
Comandos de Docker esenciales
Estos son los comandos que usarás constantemente:
Gestión de imágenes
# Ver todas las imágenes
docker images
# Descargar una imagen
docker pull node:24
# Eliminar una imagen
docker rmi mi-app
# Construir imagen
docker build -t nombre-imagen .
Gestión de contenedores
# Ver contenedores ejecutándose
docker ps
# Ver todos los contenedores (incluidos detenidos)
docker ps -a
# Ejecutar contenedor
docker run -p 3000:3000 mi-app
# Ejecutar en background (detached)
docker run -d -p 3000:3000 mi-app
# Detener contenedor
docker stop <container-id>
# Eliminar contenedor
docker rm <container-id>
# Ver logs del contenedor
docker logs <container-id>
Comandos de limpieza
# Eliminar contenedores detenidos
docker container prune
# Eliminar imágenes sin usar
docker image prune
# Limpiar todo (¡cuidado!)
docker system prune
Docker Compose: múltiples contenedores
¿Qué pasa si tu aplicación necesita una base de datos? ¿O un sistema de cache? Aquí es donde Docker Compose brilla.
Ejemplo: App + Base de datos
Las contraseñas de la base de datos no van escritas directamente en el docker-compose.yml — si subes ese archivo a GitHub, quedan expuestas para siempre en el historial. Crea antes un archivo .env en la misma carpeta:
DB_USER=usuario
DB_PASSWORD=tu_password_seguro
DB_NAME=mi_database
Añade .env a tu .gitignore, y ahora sí, crea el archivo docker-compose.yml leyendo esas variables con ${...}:
services:
# Nuestra aplicación
app:
build: .
ports:
- "3000:3000"
environment:
- DB_HOST=db
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
depends_on:
- db
# Base de datos
db:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
- MYSQL_DATABASE=${DB_NAME}
- MYSQL_USER=${DB_USER}
- MYSQL_PASSWORD=${DB_PASSWORD}
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:
💡 Docker Compose lee el .env solo:
No hace falta ningún paso extra para que funcione — docker compose busca automáticamente un archivo .env en la misma carpeta que el docker-compose.yml y sustituye cada ${VARIABLE} por su valor antes de arrancar los contenedores.
Para ejecutar todo el conjunto:
⚠️ Con espacio, no con guion:
Si buscas tutoriales antiguos de Docker vas a ver docker-compose (con guion) por todas partes. Esa versión es la v1, un binario aparte en Python que Docker dejó de mantener en 2024 y que ya no se instala en las versiones actuales. Hoy Compose viene integrado en el propio Docker CLI y se invoca como docker compose, con espacio. El archivo docker-compose.yml sigue llamándose igual — lo único que cambia es cómo escribes el comando.
# Iniciar todos los servicios
docker compose up
# En background
docker compose up -d
# Detener todos los servicios
docker compose down
# Reconstruir y iniciar
docker compose up --build
Con un solo comando levantas toda la infraestructura: tu app, la base de datos y cualquier otro servicio. Los contenedores se hablan por nombre de servicio, los datos de la base de datos sobreviven a los reinicios gracias a los volumes.
El error que pillará a casi todo el mundo la primera vez: depends_on no espera a que MySQL esté listo para aceptar conexiones, solo espera a que el contenedor haya arrancado. La base de datos tarda unos segundos más. Resultado: tu app arranca, intenta conectar, falla, y el contenedor muere. La solución más sencilla es añadir restart: on-failure al servicio de la app para que reintente automáticamente.
Proyecto práctico: app completa
Vamos a crear una aplicación más completa que incluya frontend, backend y base de datos.
Estructura del proyecto
mi-app-completa/
├── frontend/
│ ├── index.html
│ ├── style.css
│ └── Dockerfile
├── backend/
│ ├── app.js
│ ├── package.json
│ └── Dockerfile
└── docker-compose.yml
Frontend simple (frontend/index.html):
<!DOCTYPE html>
<html>
<head>
<title>Mi App Docker</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>🐳 Mi App con Docker</h1>
<button onclick="obtenerDatos()">Obtener datos del backend</button>
<div id="resultado"></div>
</div>
<script>
async function obtenerDatos() {
try {
const response = await fetch('http://localhost:3001/api/datos');
const data = await response.json();
document.getElementById('resultado').innerHTML =
`<p>✅ ${data.message}</p>`;
} catch (error) {
document.getElementById('resultado').innerHTML =
'❌ Error conectando con backend';
}
}
</script>
</body>
</html>
Este proyecto también lee la contraseña desde .env — crea uno en la raíz, junto al docker-compose.yml:
DB_USER=usuario
DB_PASSWORD=tu_password_seguro
DB_NAME=miapp
Docker Compose completo:
services:
frontend:
build: ./frontend
ports:
- "3000:80"
backend:
build: ./backend
ports:
- "3001:3001"
environment:
- DB_HOST=database
depends_on:
- database
database:
image: postgres:18
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Con un solo comando tienes frontend, backend y base de datos funcionando:
docker compose up
Qué toca ahora
El siguiente paso concreto es coger un proyecto tuyo que ya tengas —da igual que sea pequeño— y containerizarlo tú solo sin seguir un tutorial. Ese es el momento donde realmente se asienta todo.
Cuando eso te salga rodado, mira los multi-stage builds: cómo hacer que la imagen de producción sea mucho más pequeña que la de desarrollo. Es lo que marca la diferencia entre una imagen de 800 MB y una de 80 MB.
Errores que cometes al empezar
El primero: tratar los contenedores como máquinas virtuales. Un contenedor debe ser desechable. Si metes datos importantes dentro y el contenedor se reinicia, los pierdes. Los datos van en volumes, no en el sistema de archivos del contenedor.
El segundo: imágenes gigantes. Si tu imagen ocupa más de 500 MB, algo va mal. Usa versiones Alpine de las imágenes base (node:24-alpine en vez de node:24), crea un .dockerignore y no instales dependencias de desarrollo en producción (npm install --production).
El tercero: contraseñas en el Dockerfile. Pasan a la imagen, quedan en el historial de capas y se exponen si subes la imagen a Docker Hub. Las credenciales van en variables de entorno o en Docker secrets, nunca hardcodeadas.