Tutorial

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.

Tus primeros pasos con Docker: corré una API en tu máquina y escribí tu primer Dockerfile y publicada en un registro local.

Contenedores desde cero

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.

Note

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ónQué haceAnalogía
-itTerminal interactiva conectada al contenedorSentarte adentro del auto con el motor prendido
--rmBorra el contenedor apenas terminaUn vaso descartable
--nameNombre fijo en vez de un ID aleatorioPonerle nombre a la mascota
-dCorre en segundo plano (detached)Dejar la lavadora andando y seguir con lo tuyo
-pPublica un puerto: host conectado al contenedorUn caño que conecta la canilla de afuera con la de adentro
-mLimita la memoria del contenedorUn 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
  1. Usa node:24 completa. Alcanza con node:24-alpine, mucho más liviana.
  2. COPY . . antes de RUN npm install. Rompe la caché: cualquier cambio en el código invalida la instalación de dependencias.
  3. No hay .dockerignore. Si tenés node_modules o .git local, se copian al contexto de build.
  4. Corre como root. No hay USER, así que el proceso corre con privilegios de administrador.
  5. 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étricav1v2Cómo medirlo
Tamaño de la imagen (MB)docker image ls lab-api
Segundos de rebuild tras cambiar app.jsCronometrar el docker build
Cantidad de capasdocker history <imagen>
Usuario del procesodocker exec <contenedor> whoami
Estado de saluddocker 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íntomaCausa habitualQué hacer
port is already allocatedEl puerto 3000/3001 ya está en usoUsá otro puerto con -p, o liberá el que está ocupado
the container name "/api" is already in useYa existe un contenedor con ese nombredocker rm -f api y volvé a correrlo
npm ci falla sin lockfileFalta package-lock.json en el contextoCorré npm install dentro de app/ para generarlo
Contenedor unhealthy/health no responde, o falta wget en la imagenRevisá docker logs <contenedor> y el HEALTHCHECK
Cannot connect to the Docker daemonDocker no está corriendoVerificá con docker info; en tu propia máquina, abrí Docker Desktop
exec format error en Apple SiliconLa imagen se construyó para otra arquitecturaAgregá --platform linux/arm64 al build/run en tu propia máquina
docker cp dice No such container: sandboxEl contenedor del Bloque C ya no existeVolvé 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 registroPoco común en el playground; en macOS local suele ser AirPlay ReceiverPublicá 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

rossana suarez

rossana suarez

Roxs🔥| NaranjaX | ROXS FEST 🤩 | AWS Hero⭐| Docker Captain🐳 | DevOps Ambassador⭐|DevOps🔥| DevSecOps🔥|Content Creator roxs.dev | Developer👩‍💻|Mentor👩‍🏫 🇻🇪🇦🇷

Find this author online

More tutorials you might like

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.

Sign up for free