Programación y habilidades digitales docker contenedores docker compose dockerfile seguridad

Aprender Docker desde cero: ruta práctica con contenedores

Ruta práctica para distinguir imágenes y contenedores, crear un Dockerfile, probar redes y volúmenes, usar Compose y documentar límites de seguridad.

Cuatro servicios de software aislados se ejecutan en cápsulas digitales sobre una plataforma de servidor común.
· Crezendo

Docker permite empaquetar una aplicación con el entorno que necesita y ejecutarla como un proceso aislado. Esa frase es útil, pero puede crear dos ideas equivocadas: un contenedor no es una máquina virtual pequeña y una imagen no garantiza que algo funcione en cualquier equipo. Aprender Docker bien consiste en distinguir esas fronteras, construir un artefacto reproducible y demostrar qué ocurre al ejecutarlo.

Esta ruta parte de un laboratorio local con datos sintéticos. No requiere una cuenta en la nube, publicar una imagen ni usar credenciales reales. Al terminar tendrás un Dockerfile, una aplicación mínima, una definición de Compose y una matriz de pruebas. También sabrás qué aspectos siguen pendientes antes de considerar un despliegue real.

Si estás comparando formas de aprender o necesitas adaptar esta práctica a un entorno concreto, puedes consultar si existe una opción pertinente. Conviene confirmar explícitamente alcance, disponibilidad, fecha y costo antes de asumir que hay un servicio o acompañamiento disponible.

El modelo mental: imagen, contenedor y host

Una imagen es un paquete inmutable construido por capas. Incluye el sistema de archivos y la configuración necesarios para iniciar un proceso. Una etiqueta como node:lts-alpine es un nombre cómodo, pero puede apuntar a contenido distinto cuando el editor la actualiza. Un digest identifica contenido concreto. En un laboratorio puede ser razonable usar una etiqueta legible; en un flujo serio debes decidir cuándo actualizarla y registrar o fijar el digest resuelto.

Un contenedor es una instancia ejecutable de una imagen. En Linux, sus procesos comparten el kernel del host y usan mecanismos del sistema operativo para aislar nombres, recursos y permisos. Por eso suele iniciar más rápido y ocupar menos que una máquina virtual completa, pero tampoco ofrece por sí solo la misma frontera que un sistema operativo con kernel independiente.

El host sigue importando. La arquitectura de CPU, el sistema operativo, el kernel, el runtime, los drivers, la red y el almacenamiento pueden cambiar el resultado. «Funciona en un contenedor» reduce variaciones; no elimina todas las dependencias.

Docker es una cadena de herramientas concreta para construir, distribuir y ejecutar contenedores. La Open Container Initiative mantiene especificaciones abiertas para formatos de imagen, ejecución y distribución. Esa estandarización facilita interoperabilidad entre herramientas compatibles, pero no convierte una imagen para una arquitectura o un kernel determinados en universal.

Antes de Docker, ayuda entender cómo escucha una aplicación, qué archivos necesita y qué proceso la inicia. Si todavía estás construyendo esa base, consulta cómo aprender programación desde cero. Para comparar el ejemplo con otro backend, la ruta de PHP desde cero ofrece otro contexto de servidor.

Las piezas que debes reconocer

  • Dockerfile: receta declarativa para construir una imagen.
  • Contexto de build: archivos que el builder puede leer. Debe ser pequeño y excluir secretos y basura mediante .dockerignore.
  • Registro: servicio que almacena y distribuye imágenes. No necesitas publicar nada para completar este laboratorio.
  • Volumen: almacenamiento persistente administrado por Docker, con un ciclo de vida separado del contenedor.
  • Bind mount: ruta del host montada dentro del contenedor. Depende de ese host y puede modificar sus archivos si no se declara de solo lectura.
  • Red: conectividad entre contenedores y hacia el exterior. Estar en una red de Docker no significa que el servicio sea público.
  • Compose: modelo YAML para declarar servicios, redes, volúmenes, configuraciones y secretos, y operarlos como un proyecto.

EXPOSE 3000 en un Dockerfile documenta el puerto esperado por la aplicación; no lo publica en el host. Una entrada como 127.0.0.1:3000:3000 en Compose sí publica el puerto, pero solo en la interfaz local. Esta diferencia evita muchos diagnósticos confusos y exposiciones accidentales.

Verifica el entorno antes de construir

Instala una distribución compatible de Docker Engine y el plugin de Compose siguiendo la documentación vigente para tu sistema. En equipos administrados, confirma primero la política de virtualización y privilegios. Luego registra estas salidas, sin publicar nombres de usuario, rutas sensibles ni configuración privada:

docker version
docker info
docker compose version

docker version permite comprobar cliente y servidor. docker info muestra el contexto operativo y puede advertir sobre funciones del kernel. docker compose version confirma que usarás el comando actual docker compose. Si el cliente no llega al daemon, resuelve ese problema antes de tocar el laboratorio; agregar el usuario indiscriminadamente a un grupo con control del daemon no es una corrección inocua.

El socket o API del daemon tiene gran poder sobre el host. Solo usuarios confiables deben acceder a él. Nunca lo expongas sin protección ni lo montes dentro de un contenedor que no controles.

Laboratorio: una API HTTP sin dependencias

Crea una carpeta fuera de un proyecto con datos reales y usa esta estructura:

docker-lab/
├── src/
│   └── server.js
├── package.json
├── Dockerfile
├── compose.yaml
├── .dockerignore
└── README.md

El servidor usa únicamente el módulo HTTP incluido con Node. Así el ejercicio se concentra en la frontera del contenedor y no en instalar paquetes.

// src/server.js
import http from "node:http";

const host = "0.0.0.0";
const port = Number.parseInt(process.env.PORT ?? "3000", 10);

const server = http.createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(200, { "content-type": "application/json" });
    response.end(JSON.stringify({ status: "ok" }));
    return;
  }

  response.writeHead(404, { "content-type": "application/json" });
  response.end(JSON.stringify({ error: "not_found" }));
});

server.listen(port, host, () => {
  console.log(`listening on ${host}:${port}`);
});

El package.json solo define el modo de módulos y el comando de inicio:

{
  "name": "docker-lab-api",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.js"
  }
}

No uses información de clientes, tokens o contraseñas. El endpoint debe responder siempre con el mismo dato sintético. Si quieres practicar persistencia más adelante, diseña datos desechables y una prueba de restauración separada.

Construye una imagen entendible

Usa este Dockerfile inicial:

FROM node:lts-alpine

WORKDIR /app
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src

USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

La base es deliberadamente pequeña y el proceso final no corre como root. WORKDIR evita depender de una ubicación implícita. La forma JSON de CMD entrega señales directamente al proceso. La etiqueta de la base sigue siendo mutable: después de un build registra la referencia resuelta con docker image inspect, evalúa la procedencia de la imagen y define una política para reconstruir cuando haya correcciones.

Limita el contexto con .dockerignore:

.git
.env
.env.*
node_modules
npm-debug.log*
*.log
README.md

Excluir un secreto del contexto es mejor que confiar en no copiarlo por accidente. Si un build legítimo necesita una credencial temporal, no la pases mediante ARG o ENV: esos valores pueden persistir en capas o metadatos. Usa los mounts de secretos o SSH que proporciona el builder y concede solo el acceso necesario.

En una aplicación con compilación sí puede convenir un build multietapa para separar herramientas de construcción del runtime. Aquí añadir etapas no aportaría evidencia: no existen dependencias ni artefactos que compilar.

Declara la ejecución con Compose

Crea compose.yaml:

services:
  api:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    restart: "no"

Esta definición mantiene el laboratorio acotado: construye desde el directorio actual, publica el servicio solo en localhost, deja el sistema de archivos raíz en solo lectura, proporciona un /tmp efímero y elimina capacidades Linux adicionales. La compatibilidad de cada control depende de la plataforma; compruébala en la configuración renderizada y en el contenedor, no la des por sentada.

Compose crea una red del proyecto aunque solo haya un servicio. Si luego agregas otro, ambos pueden comunicarse por el nombre del servicio dentro de esa red. Publicar un puerto es una decisión distinta y solo hace falta cuando un cliente fuera de la red de Compose debe llegar a él.

Antes de iniciar, valida la definición:

docker compose config
docker compose build --pull
docker compose up -d
docker compose ps
docker compose logs api

config detecta errores de YAML y muestra el modelo resuelto. build --pull consulta una base disponible más reciente; no ofrece reproducibilidad por sí solo, por eso debes conservar el digest y la evidencia del build. ps y logs demuestran que el proceso inició, pero todavía falta probar su comportamiento.

Consulta http://127.0.0.1:3000/health con el navegador o con una herramienta HTTP:

curl http://127.0.0.1:3000/health

La respuesta esperada es un estado HTTP 200 y {"status":"ok"}. Un resultado distinto es una señal para investigar, no para reiniciar comandos al azar.

Inspecciona en vez de adivinar

Una secuencia de diagnóstico útil va de la declaración al proceso:

  1. docker compose config: ¿el valor resuelto es el esperado?
  2. docker compose ps: ¿el contenedor está creado y en ejecución? ¿qué puerto muestra?
  3. docker compose logs api: ¿el proceso inició o salió con un error?
  4. docker compose exec api id: ¿el proceso interactivo usa un usuario no root?
  5. docker inspect sobre el contenedor: ¿red, mounts, imagen y configuración coinciden con la intención?
  6. Petición a /health: ¿la aplicación responde desde el punto de acceso previsto?

Prueba además el límite de solo lectura:

docker compose exec api sh -c "touch /should-fail"

La operación debe fallar. Después verifica que /tmp sí permite un archivo efímero si la aplicación lo necesitara. Esto produce evidencia de que el control está activo, no solo escrito en YAML.

Si el contenedor sale, revisa su código y logs. Si corre pero no responde, confirma que la aplicación escucha en 0.0.0.0, que el mapeo corresponde al puerto interno y que no existe un conflicto en el puerto local. Si responde dentro pero no desde el host, investiga publicación y firewall. Cambiar todo a 0.0.0.0 o desactivar controles de seguridad solo oculta la causa y puede ampliar la exposición.

Volúmenes y bind mounts no son equivalentes

La capa escribible del contenedor es efímera: eliminar el contenedor elimina esos cambios. Un volumen nombrado tiene su propio ciclo de vida y sirve para datos que deben sobrevivir a la recreación. Eso no lo convierte en backup. Debes definir exportación, restauración, cifrado, permisos y retención por separado.

Un bind mount enlaza una ruta concreta del host. Es útil para desarrollo, pero acopla el contenedor a la estructura y permisos de ese equipo. Además, un mount escribible puede modificar o borrar archivos del host. Prefiere rutas exactas y readonly cuando solo se necesita leer. No montes la raíz del host, el socket Docker ni directorios amplios como atajo.

Para comprender persistencia, puedes extender el laboratorio con un volumen nombrado y una herramienta desechable de una fuente confiable. La prueba completa debe escribir un marcador sintético, eliminar el primer contenedor, leer el marcador desde otro contenedor en modo de solo lectura y, al final, borrar el volumen por su nombre exacto. Registra la referencia y el digest de cualquier imagen auxiliar; no bases la evidencia en una etiqueta mutable desconocida.

En una aplicación real, la persistencia suele conectarse con una base de datos. La guía de SQL para consultar bases de datos ayuda a separar el aprendizaje de consultas del diseño de backups, migraciones y acceso que un contenedor no resuelve automáticamente.

Seguridad mínima antes de pensar en producción

Un contenedor no vuelve confiable una aplicación vulnerable. Revisa al menos estas fronteras:

  • Usa imágenes de procedencia conocida, reduce paquetes y reconstruye con correcciones. Conserva digest, fecha, Dockerfile y resultado del build.
  • Escanea dependencias e imagen con herramientas adecuadas a tu flujo. Un escaneo sin hallazgos no prueba ausencia de vulnerabilidades.
  • Ejecuta como usuario no root. Evalúa un daemon rootless cuando sea compatible, sin tratarlo como sustituto de permisos, actualizaciones y aislamiento.
  • Elimina capacidades innecesarias, impide escalada de privilegios y evita --privileged, red/namespace del host y dispositivos amplios.
  • Declara límites de CPU y memoria después de medir la aplicación. Docker no impone límites de recursos por defecto; un proceso puede afectar al host.
  • Separa secretos de la imagen y del repositorio. En Compose concede cada secreto solo al servicio que lo necesita; en producción usa el almacén seguro de la plataforma.
  • Restringe tráfico de entrada y salida según la necesidad. Una red privada de Compose no reemplaza políticas del host o de la plataforma.
  • Protege el daemon y sus logs. Quien controla el socket puede alcanzar capacidades equivalentes a gran privilegio sobre el host.
  • Diseña backup y restauración para los datos persistentes, y prueba ambos. Recrear un contenedor no recupera información perdida.
  • Mantén inventario, logs y procedimiento de respuesta. La imagen, el runtime, el host y la aplicación tienen ciclos de actualización distintos.

Las firmas, procedencia y SBOM pueden fortalecer la cadena de suministro si el flujo elegido las genera y verifica. No basta con producir archivos: documenta quién confía en qué identidad, qué política bloquea un artefacto y cómo se actualiza sin quedar anclado para siempre a una base vulnerable.

Matriz de evidencia del laboratorio

Una entrega verificable puede ser una tabla en el README, sin capturas que revelen datos privados:

Pregunta Prueba Criterio de aceptación
¿La definición es válida? docker compose config Sale sin error y muestra solo la configuración prevista.
¿La imagen se construye? docker compose build --pull Termina correctamente y se registra la referencia de imagen.
¿El servicio inicia? docker compose up -d y ps El servicio aparece en ejecución, sin bucle de reinicio.
¿La API funciona? Petición a /health HTTP 200 y cuerpo sintético esperado.
¿El proceso evita root? docker compose exec api id El UID efectivo no es 0.
¿La raíz es de solo lectura? Intento de touch /should-fail La escritura falla.
¿La publicación es local? ps e inspección desde el host El bind aparece en 127.0.0.1, no en todas las interfaces.
¿Hay diagnóstico? logs e inspect Permiten vincular un fallo con proceso, imagen, red o mount.
¿Se puede cerrar limpiamente? docker compose down Se retiran contenedor y red del proyecto sin borrar recursos ajenos.

Guarda también decisiones: por qué se eligió esa base, qué digest se resolvió, qué queda fuera del laboratorio y qué cambiaría antes de producción. No incluyas tokens, archivos .env, rutas de clientes ni dumps.

Para cerrar, ejecuta:

docker compose down

Si creaste un volumen de práctica, elimínalo después por su nombre exacto solo cuando hayas confirmado que contiene datos desechables. Evita docker system prune: una limpieza global puede afectar imágenes, redes, cachés o volúmenes de otros proyectos.

Cómo saber si ya dominas la base

No hace falta memorizar cada opción. La base está presente cuando puedes explicar la diferencia entre imagen y contenedor, predecir qué se pierde al recrearlo, construir una imagen sin secretos, publicar solo el puerto necesario, inspeccionar un fallo y demostrar controles básicos. También debes poder decir «esto no está listo para producción» y enumerar lo que falta.

Como extensión, puedes reemplazar el HTML o respuesta manual por una interfaz pequeña. La introducción a React sirve para practicar esa separación entre frontend y API, manteniendo primero los servicios en una red local y publicando únicamente lo necesario.

El siguiente paso no es añadir más herramientas por reflejo. Repite el laboratorio desde cero con el README, compara el digest y los resultados, provoca un fallo controlado y documenta el diagnóstico. Después elige una sola extensión —persistencia, dos servicios, secretos o límites de recursos— y añade su prueba de aceptación.

Si necesitas evaluar cómo trasladar esta ruta a un caso concreto, contacta a Crezendo para preguntar por las opciones vigentes. Describe sistema operativo, arquitectura, objetivo, restricciones y evidencia esperada; confirma alcance, disponibilidad, fecha y costo antes de compartir datos o asumir un resultado.

¿Tu empresa necesita resolver este reto?

Crezendo diseña talleres a medida para empresas, ONGs y organismos de gobierno. Mira todo lo que podemos hacer por tu organización o cuéntanos tu necesidad para recibir una propuesta y cotización.

Solicitar propuesta y cotización Ver talleres para empresas