Contenedores desde cero: de local a tu primera imagen Docker
Tus primeros pasos con Docker: corré una API en tu máquina y escribí tu primer Dockerfile y publicada en un registro local.

Objetivo del laboratorio
Vas a tomar una API mínima en Node.js y llevarla por tres etapas: corriendo directo en la máquina, corriendo dentro de un contenedor sin ningún Dockerfile (copiando el código a mano), y finalmente empaquetada en una imagen propia. Vas a construir esa imagen dos veces — una versión ingenua y una optimizada — y vas a medir la diferencia con números reales, no con promesas.
El playground que acompaña esta guía es el oficial docker (una única máquina, docker-01, con Docker ya instalado). Los bloques ::simple-task que vas a ir viendo verifican, sobre esa misma máquina, que el bloque anterior quedó resuelto de verdad.
Cómo usar esta guía
Todos los comandos se ejecutan dentro de docker-01, como el usuario laborant, salvo que se indique otra cosa. Cada paso tiene la misma forma: el comando o el archivo, qué deberías ver, y una pregunta para pensar. No te saltees el "qué deberías ver": ahí está buena parte del aprendizaje.
Por qué en ese orden. Antes de escribir un Dockerfile vale la pena sentir, aunque sea una vez, lo que automatiza. Por eso este laboratorio no arranca directamente con docker build: primero corre la app a mano, después la copia a mano dentro de un contenedor con docker cp, y solo ahí escribe el Dockerfile. El orden es el contenido.
Vocabulario mínimo
- Imagen: paquete inmutable con la aplicación y lo necesario para ejecutarla.
- Contenedor: instancia en ejecución de una imagen.
- Dockerfile: receta que automatiza los pasos para construir una imagen.
- Contexto de build: la carpeta que el cliente de Docker le manda al daemon para construir la imagen.
Anatomía de docker run
La forma general de este comando es:
docker run [opciones] imagen [comando]
imagen es obligatoria. [opciones] cambian cómo corre el contenedor. [comando] es opcional: si no lo indicás, se usa el CMD que trae la imagen.
| Opción | Qué hace | Analogía |
|---|---|---|
-it | Terminal interactiva conectada al contenedor | Sentarte adentro del auto con el motor prendido |
--rm | Borra el contenedor apenas termina | Un vaso descartable |
--name | Nombre fijo en vez de un ID aleatorio | Ponerle nombre a la mascota |
-d | Corre en segundo plano (detached) | Dejar la lavadora andando y seguir con lo tuyo |
-p | Publica un puerto: host conectado al contenedor | Un caño que conecta la canilla de afuera con la de adentro |
-m | Limita la memoria del contenedor | Un tope de gasto en una tarjeta prepaga |
Bloque A · Preparación del entorno (pasos 01 y 02)
Paso 01 · Verificar herramientas
node --version
docker --version
curl --version
Deberías ver: node en v24 o superior y docker respondiendo con una versión. Si node no aparece, esperá unos segundos: el entorno lo instala en cuanto arranca la máquina.
Paso 02 · Crear el proyecto
mkdir -p ~/lab-contenedores/app && cd ~/lab-contenedores/app
pwd
Deberías ver: la ruta absoluta terminada en /lab-contenedores/app. Todos los comandos de esta guía asumen que estás parado ahí, salvo que se indique otra cosa.
Bloque B · Correr la app en tu máquina, sin Docker (pasos 03 y 04)
No tiene sentido contenerizar algo que no probaste. Antes de cualquier docker build, la app tiene que arrancar sola.
Paso 03 · Escribir la app
La app es mínima a propósito: dos rutas, una sola dependencia, y una página estática para ver el resultado en el navegador en vez de leer JSON crudo.
// app.js
const express = require('express');
const os = require('os');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
app.use('/public', express.static(path.join(__dirname, 'public')));
app.get('/', (req, res) =>
res.json({ mensaje: 'Hola desde un contenedor', host: os.hostname() }));
app.get('/health', (req, res) => res.send('ok'));
app.listen(PORT, () => console.log(`API escuchando en ${PORT}`));
// package.json
{
"name": "lab-api",
"version": "1.0.0",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>lab-api</title>
</head>
<body>
<h1>lab-api <span id="estado">consultando...</span></h1>
<p>Mensaje: <span id="mensaje">-</span></p>
<p>Host del contenedor: <span id="host">-</span></p>
<script>
fetch('/')
.then((res) => res.json())
.then((data) => {
document.getElementById('mensaje').textContent = data.mensaje;
document.getElementById('host').textContent = data.host;
document.getElementById('estado').textContent = 'ok';
})
.catch(() => { document.getElementById('estado').textContent = 'error'; });
</script>
</body>
</html>
Guardá los archivos en ~/lab-contenedores/app con el heredoc habitual (creá primero la carpeta public/):
mkdir -p public
cat > app.js << 'EOF'
(pegá acá el contenido de app.js)
EOF
cat > package.json << 'EOF'
(pegá acá el contenido de package.json)
EOF
cat > public/index.html << 'EOF'
(pegá acá el contenido de public/index.html)
EOF
Generarla con una IA en lugar de escribirla a mano
Si preferís pedirle esta misma app a un modelo (ChatGPT, DeepSeek, Claude, Copilot, Kiro o el que tengas a mano), un prompt que fija el contrato exacto:
Escribí una API en Node.js con Express que tenga:
- GET / que devuelva un JSON con un campo "mensaje" y un campo "host" con el
nombre de la máquina (os.hostname()).
- GET /health que devuelva el texto "ok".
- GET /public/ que sirva un archivo estático index.html (usando express.static
sobre una carpeta public/), que muestre el mensaje y el host de forma visual
en vez de JSON crudo.
- Escuche en el puerto de la variable de entorno PORT, o 3000 si no está definida.
No agregues dependencias además de express. No agregues autenticación ni
base de datos.
Antes de confiar en la respuesta, revisá: ¿las rutas responden exactamente lo que pediste? ¿agregó dependencias que no pediste? ¿corre sin errores con node app.js? ¿la página en /public/ muestra el mensaje y el host reales, no datos de ejemplo hardcodeados? Ese hábito de revisar antes de confiar es el mismo que vas a necesitar más adelante con el Dockerfile.
Paso 04 · Instalar y arrancar
npm install
npm start
Deberías ver: el mensaje API escuchando en 3000. El proceso queda corriendo ahí, ocupando la terminal.
Abrí otra terminal (o una segunda pestaña) y probá las rutas:
curl -s localhost:3000/
curl -s localhost:3000/health
Deberías ver: un JSON con mensaje y host (el host es el nombre de tu propia máquina), y el texto ok.
Abrí también http://localhost:3000/public/ desde el navegador de tu playground (o curl -s localhost:3000/public/ si preferís la terminal). Vas a ver el mismo mensaje y host, mostrados en una página en vez de JSON crudo.
Para pensar: si tuvieras que llevar esta app a otra máquina, ¿qué tendría que tener instalado ese equipo para que funcione igual? ¿Qué pasaría si tuviera otra versión de Node?
Dejá la app corriendo y seguí en la otra terminal.
Bloque C · Copiar el código a un contenedor a mano (pasos 05 y 06)
Antes de automatizar nada con un Dockerfile, vale la pena hacerlo una vez a mano: copiar el código a un contenedor y correrlo ahí adentro. Esto es exactamente lo que un Dockerfile va a automatizar en el bloque siguiente.
Paso 05 · Copiar el código con docker cp
Volvé a la terminal donde tenías la app corriendo y pará el proceso con Ctrl+C (o simplemente usá una tercera terminal). Levantá un contenedor con Node ya instalado adentro, sin decirle todavía que corra nada:
docker run -d --name sandbox -p 3001:3000 node:24 sleep infinity
Deberías ver: un ID de contenedor largo. docker ps te muestra sandbox con estado Up.
Copiá el contenido de tu carpeta al contenedor:
docker cp . sandbox:/app
docker exec sandbox ls -la /app
Deberías ver: app.js, package.json y package-lock.json listados adentro del contenedor. El punto después de docker cp significa "el contenido de esta carpeta", no la carpeta en sí.
Paso 06 · Instalar y correr dentro del contenedor
Entrá al contenedor con una terminal interactiva:
docker exec -it sandbox bash
Adentro, repetí los mismos comandos del Paso 04:
cd /app
npm install
node app.js
Deberías ver: el mismo API escuchando en 3000 de antes. La diferencia es que ahora ese proceso vive dentro del contenedor.
Abrí otra terminal en docker-01 (dejá la del contenedor con la app corriendo) y probá contra el puerto publicado:
curl -s localhost:3001/
Deberías ver: la misma respuesta, pero con un host distinto: un ID parecido a un hash, no el nombre de la máquina. Esa es la primera señal de que la app corre aislada.
Cuando termines, volvé a la terminal del contenedor, parate la app con Ctrl+C, salí con exit, y borrá el contenedor:
docker rm -f sandbox
Para pensar: repetiste tres pasos a mano (copiar código, instalar dependencias, arrancar la app) para lograr algo que ya sabías hacer en el Bloque B. Si tuvieras que repetir esto cada vez que alguien necesita levantar la app, ¿qué problema le ves? Guardá esa idea: es la razón por la que existe el Dockerfile.
Bloque D · Escribir y construir la v1 (pasos 07 a 09)
Paso 07 · Escribir el Dockerfile v1
De vuelta en ~/lab-contenedores/app, escribí un Dockerfile de la forma más simple posible, sin optimizar nada todavía:
# Dockerfile
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "app.js"]
Este Dockerfile automatiza exactamente lo que hiciste a mano en el Bloque C.
Paso 08 · Construir y correr
docker build -t lab-api:v1 .
docker run -d -p 3000:3000 --name api lab-api:v1
curl -s localhost:3000/
docker image ls lab-api
Deberías ver: el JSON esperado, y en docker image ls el tamaño real de lab-api:v1. Anotalo, lo vas a comparar en el Bloque F.
Paso 09 · Medir el rebuild
Cambiá el texto del mensaje en app.js y reconstruí:
docker build -t lab-api:v1 .
Deberías ver: que la línea de RUN npm install no dice CACHED. Se reinstala todo, aunque no cambió ninguna dependencia.
Para pensar: ¿por qué se repite npm install si solo cambiaste una línea de texto?
Bloque E · Encontrar los problemas y optimizar (pasos 10 a 12)
Paso 10 · Encontrar los problemas de la v1
Antes de ver la solución, mirá tu Dockerfile y pensá qué le sobra o le falta, pensando en tamaño, velocidad de rebuild y seguridad.
Ver la respuesta
- Usa
node:24completa. Alcanza connode:24-alpine, mucho más liviana. COPY . .antes deRUN npm install. Rompe la caché: cualquier cambio en el código invalida la instalación de dependencias.- No hay
.dockerignore. Si tenésnode_moduleso.gitlocal, se copian al contexto de build. - Corre como root. No hay
USER, así que el proceso corre con privilegios de administrador. - No tiene
HEALTHCHECK. Docker no puede saber si la app realmente responde.
Paso 11 · Escribir el Dockerfile v2 y el .dockerignore
# .dockerignore
node_modules
.git
*.log
.env
Dockerfile*
# Dockerfile (version optimizada)
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "app.js"]
Paso 12 · Construir, correr y confirmar la caché
docker build -t lab-api:v2 .
docker run -d -p 3001:3000 --name api2 lab-api:v2
docker ps
docker exec api2 whoami
Deberías ver: en docker ps, el estado Up ... (healthy) después de unos segundos. En whoami, node, no root.
Repetí la prueba del rebuild: cambiá algo en app.js y reconstruí.
docker build -t lab-api:v2 .
Deberías ver: que esta vez RUN npm ci --omit=dev sí dice CACHED. Solo se reconstruye la capa de COPY . . en adelante.
Para pensar: ¿por qué separar COPY package*.json ./ de COPY . . hace esa diferencia?
Bloque F · Medir, comparar y publicar (pasos 13 y 14)
Paso 13 · Comparar v1 contra v2
docker image ls lab-api
docker history lab-api:v1
docker history lab-api:v2
Completá esta tabla con los números reales de tu corrida (no hay valores fijos: dependen de la versión exacta de la imagen base que se descargó hoy):
| Métrica | v1 | v2 | Cómo medirlo |
|---|---|---|---|
| Tamaño de la imagen (MB) | docker image ls lab-api | ||
Segundos de rebuild tras cambiar app.js | Cronometrar el docker build | ||
| Cantidad de capas | docker history <imagen> | ||
| Usuario del proceso | docker exec <contenedor> whoami | ||
| Estado de salud | docker ps (columna STATUS) |
Para pensar: ¿cuál de las diferencias te parece más importante para producción: el tamaño, la velocidad de rebuild, o el usuario del proceso?
Paso 14 (opcional) · Publicar en un registro
Levantá un registro local (es la misma imagen que usan Docker Hub y otros registros por dentro):
docker run -d --name registry -p 5000:5000 registry:2
Deberías ver: un ID de contenedor. Con docker ps vas a ver registry con estado Up.
Etiquetá tu imagen apuntando a ese registro y publicala:
docker tag lab-api:v2 localhost:5000/lab-api:1.0.0
docker push localhost:5000/lab-api:1.0.0
Deberías ver: las capas subiendo y, al final, el digest sha256:....
Confirmá que quedó publicada consultando la API del registro:
curl -s localhost:5000/v2/_catalog
curl -s localhost:5000/v2/lab-api/tags/list
Deberías ver: {"repositories":["lab-api"]} en la primera consulta, y {"name":"lab-api","tags":["1.0.0"]} en la segunda.
Para pensar: si publicás siempre como latest, ¿cómo sabe alguien que usa tu imagen qué versión exacta está corriendo, o cómo volver a una anterior si algo se rompe?
Opcional - publicar también en Docker Hub, con tu cuenta real
Si además querés practicar con un registro público de verdad (fuera de este playground, en tu propia cuenta):
docker tag lab-api:v2 <tu-usuario>/lab-api:1.0.0
docker login
docker push <tu-usuario>/lab-api:1.0.0
Esto requiere una cuenta de Docker Hub y tus credenciales reales, así que no es parte del flujo principal.
Extra · Servir un sitio con nginx usando docker cp
Este bloque es opcional y no forma parte del objetivo principal. Sirve para practicar docker cp en un escenario distinto: copiar contenido hacia (y desde) un contenedor que ya está corriendo, sin ningún Dockerfile de por medio.
Levantá nginx:
docker run -d --name web -p 8080:80 nginx:1.27-alpine
Para el sitio, pedile a una IA (ChatGPT, DeepSeek, Claude, Copilot, Kiro o la que tengas a mano) que te genere un index.html de una sola página, con un prompt como este:
Generá un único archivo index.html, autocontenido (CSS embebido en un
<style>, sin dependencias externas ni frameworks), para una landing simple
de una sola pantalla. Tiene que tener:
- Un título grande con el texto "Hola desde docker cp"
- Un subtítulo corto explicando que esta página se copió a un contenedor
nginx en ejecución con el comando docker cp
- Una paleta de colores oscura, tipografía legible, y que se vea bien en
el celular
No agregues JavaScript ni imágenes externas.
Guardá el resultado como index.html en tu carpeta de trabajo y copialo al contenedor:
docker cp index.html web:/usr/share/nginx/html/index.html
curl -s localhost:8080
Deberías ver: el HTML que generó la IA, servido por nginx sin haber tocado ninguna imagen ni Dockerfile.
Para practicar el sentido inverso, copiá un archivo desde el contenedor hacia tu carpeta:
docker cp web:/etc/nginx/conf.d/default.conf ./copia-desde-contenedor.conf
Cuando termines, borrá el contenedor:
docker rm -f web
Para pensar: docker cp copia una sola vez, en el momento en que lo ejecutás. ¿Qué diferencia hay con montar un volumen (-v)?
Si algo falla
| Síntoma | Causa habitual | Qué hacer |
|---|---|---|
port is already allocated | El puerto 3000/3001 ya está en uso | Usá otro puerto con -p, o liberá el que está ocupado |
the container name "/api" is already in use | Ya existe un contenedor con ese nombre | docker rm -f api y volvé a correrlo |
npm ci falla sin lockfile | Falta package-lock.json en el contexto | Corré npm install dentro de app/ para generarlo |
Contenedor unhealthy | /health no responde, o falta wget en la imagen | Revisá docker logs <contenedor> y el HEALTHCHECK |
Cannot connect to the Docker daemon | Docker no está corriendo | Verificá con docker info; en tu propia máquina, abrí Docker Desktop |
exec format error en Apple Silicon | La imagen se construyó para otra arquitectura | Agregá --platform linux/arm64 al build/run en tu propia máquina |
docker cp dice No such container: sandbox | El contenedor del Bloque C ya no existe | Volvé a crearlo con docker run -d --name sandbox -p 3001:3000 node:24 sleep infinity |
| El puerto 5000 ya está en uso al levantar el registro | Poco común en el playground; en macOS local suele ser AirPlay Receiver | Publicá el registro en otro puerto (-p 5050:5000) y ajustá localhost:5000 por localhost:5050 en los comandos siguientes |
Anexo · Código de referencia
Dockerfiles completos, por si te trabaste
# Dockerfile v1 - ingenua a proposito
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "app.js"]
# Dockerfile v2 - optimizada
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "app.js"]
# .dockerignore
node_modules
.git
*.log
.env
Dockerfile*
Hecho por Roxs 🔥 · Build with Fire
About the Author
More tutorials you might like

How Container Filesystem Works: Building a Docker-like Container From Scratch
Learn how Linux containers are built from the ground up. Starting with the mount namespace and a root filesystem, see why PID, cgroup, UTS, and network namespaces naturally follow - and how this foundation makes concepts like bind mounts, volumes, and persistence in Docker or Kubernetes much easier to grasp.

How Container Networking Works: Building a Bridge Network From Scratch
Begin with the basics to understand Docker and Kubernetes networking: learn how to create and interconnect Linux network namespaces using only command-line tools.

How Kubernetes Reinvented Virtual Machines - In a Good Sense
How Virtual Machines were used to deploy services. What old problems containers solve and what new problems create. How Kubernetes used containers to recreate Virtual Machines in a better way?

How Container Images Actually Work: Layers, Configs, Manifests, Indexes, and More
A practical deep dive into container image internals that will help you build a clear mental model of how images are composed, identified, stored, and distributed across registries.
Learn by doing, not just by reading or watching
Sign up for a free account to start a VM playground right on this page, track your progress, and get notified about new learning materials.