DevOps de Cero a Heroe: Tu Primera API en TypeScript con Express y Docker

2026-04-24 | Gabriel Garrido | 11 min de lectura
Share:

Apoya este blog

Si te resulta util este contenido, considera apoyar el blog.

Introduccion

Bienvenido al segundo articulo de la serie DevOps de Cero a Heroe. En el primer articulo configuramos nuestro entorno de desarrollo y nos familiarizamos con las herramientas basicas. Ahora es momento de construir algo real: una API REST que vamos a poder deployar, testear e iterar a lo largo del resto de la serie.


Vamos a armar un task tracker simple usando TypeScript y Express. Nada sofisticado, solo operaciones CRUD sobre un array en memoria. El objetivo no es hacer una app production-ready ahora, sino tener una API funcionando que podamos containerizar, deployar y mejorar en los proximos articulos.


Despues de que la API este andando, vamos a escribir un Dockerfile usando multi-stage builds, configurar un .dockerignore, correr el container con un usuario no-root, agregar un endpoint de health check, y armar todo con Docker Compose para desarrollo local con hot reload.


Vamos a ello.


Por que TypeScript y Express?

Puede que te preguntes por que no usamos Python, Go, o algo distinto. TypeScript con Express es uno de los stacks mas comunes que te vas a encontrar en la vida real. Tiene un ecosistema enorme, el tooling es maduro, y los conceptos se traducen directamente a otros lenguajes y frameworks.


Para DevOps, el lenguaje en si importa menos que entender como buildear, testear, empaquetar y deployar aplicaciones. Elegimos TypeScript porque nos da type safety sin demasiada ceremonia, y Express porque es lo suficientemente minimalista como para que podamos enfocarnos en el lado DevOps de las cosas.


Configuracion del proyecto

Primero, crea un directorio nuevo e inicializa el proyecto:


mkdir task-api && cd task-api
npm init -y

Instala las dependencias que necesitamos:


npm install express
npm install -D typescript @types/express @types/node ts-node nodemon

Ahora crea la configuracion de TypeScript. Esto le dice al compilador como procesar nuestro codigo:


// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Actualiza la seccion de scripts de tu package.json:


{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts"
  }
}

Crea el directorio de codigo fuente:


mkdir src

Definiendo el modelo de tareas

Arranquemos con una definicion de tipos simple para nuestras tareas. Crea src/types.ts:


// src/types.ts
export interface Task {
  id: number;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}

export interface CreateTaskRequest {
  title: string;
  description?: string;
}

export interface UpdateTaskRequest {
  title?: string;
  description?: string;
  completed?: boolean;
}

Esto nos da un contrato claro de como se ve una tarea y que datos esperamos al crear o actualizar una.


Construyendo la API

Ahora armemos la API propiamente dicha. Crea src/index.ts:


// src/index.ts
import express, { Request, Response } from "express";
import { Task, CreateTaskRequest, UpdateTaskRequest } from "./types";

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json());

// In-memory storage
let tasks: Task[] = [];
let nextId = 1;

// Health check endpoint
app.get("/health", (_req: Request, res: Response) => {
  res.json({
    status: "healthy",
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
  });
});

// GET /tasks - List all tasks
app.get("/tasks", (_req: Request, res: Response) => {
  res.json({
    data: tasks,
    count: tasks.length,
  });
});

// GET /tasks/:id - Get a single task
app.get("/tasks/:id", (req: Request, res: Response) => {
  const task = tasks.find((t) => t.id === parseInt(req.params.id));
  if (!task) {
    res.status(404).json({ error: "Task not found" });
    return;
  }
  res.json({ data: task });
});

// POST /tasks - Create a new task
app.post("/tasks", (req: Request, res: Response) => {
  const body: CreateTaskRequest = req.body;

  if (!body.title || body.title.trim() === "") {
    res.status(400).json({ error: "Title is required" });
    return;
  }

  const now = new Date().toISOString();
  const task: Task = {
    id: nextId++,
    title: body.title.trim(),
    description: body.description?.trim() || "",
    completed: false,
    createdAt: now,
    updatedAt: now,
  };

  tasks.push(task);
  res.status(201).json({ data: task });
});

// PUT /tasks/:id - Update a task
app.put("/tasks/:id", (req: Request, res: Response) => {
  const taskIndex = tasks.findIndex((t) => t.id === parseInt(req.params.id));
  if (taskIndex === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }

  const body: UpdateTaskRequest = req.body;
  const existing = tasks[taskIndex];

  const updated: Task = {
    ...existing,
    title: body.title?.trim() ?? existing.title,
    description: body.description?.trim() ?? existing.description,
    completed: body.completed ?? existing.completed,
    updatedAt: new Date().toISOString(),
  };

  tasks[taskIndex] = updated;
  res.json({ data: updated });
});

// DELETE /tasks/:id - Delete a task
app.delete("/tasks/:id", (req: Request, res: Response) => {
  const taskIndex = tasks.findIndex((t) => t.id === parseInt(req.params.id));
  if (taskIndex === -1) {
    res.status(404).json({ error: "Task not found" });
    return;
  }

  const deleted = tasks.splice(taskIndex, 1)[0];
  res.json({ data: deleted, message: "Task deleted" });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Task API running on port ${PORT}`);
});

export default app;

Probando la API localmente

Inicia el servidor de desarrollo:


npm run dev

Deberias ver Task API running on port 3000. Ahora probemos cada endpoint con curl:


# Health check
curl http://localhost:3000/health | jq

# Crear una tarea
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker", "description": "Build and run containers"}' | jq

# Crear otra tarea
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Write Dockerfile", "description": "Multi-stage build"}' | jq

# Listar todas las tareas
curl http://localhost:3000/tasks | jq

# Obtener una tarea especifica
curl http://localhost:3000/tasks/1 | jq

# Actualizar una tarea
curl -X PUT http://localhost:3000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}' | jq

# Eliminar una tarea
curl -X DELETE http://localhost:3000/tasks/2 | jq

Deberias ver respuestas JSON correctas para cada request. El health check devuelve el uptime del servidor, el POST devuelve la tarea creada con un ID auto-incrementado, y asi sucesivamente.


El Dockerfile

Ahora viene la parte divertida. Vamos a containerizar esta API usando buenas practicas de Docker.


Primero, hablemos de por que importan los multi-stage builds. Un proyecto TypeScript tipico tiene dependencias de desarrollo (el compilador, definiciones de tipos, nodemon) que no necesitamos en runtime. Con multi-stage builds, compilamos en una etapa y copiamos solo el output a una imagen final mas chica. Esto significa imagenes mas livianas, pulls mas rapidos, y una superficie de ataque menor.


Crea el Dockerfile:


# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

# Copiamos los archivos de paquete primero para mejor cache de capas
COPY package*.json ./

# Instalamos todas las dependencias (incluyendo devDependencies para el build)
RUN npm ci

# Copiamos el codigo fuente
COPY tsconfig.json ./
COPY src ./src

# Compilamos TypeScript
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production

# Agregamos un usuario no-root
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -s /bin/sh -D appuser

WORKDIR /app

# Copiamos archivos de paquete e instalamos solo dependencias de produccion
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Copiamos el output compilado desde la etapa builder
COPY --from=builder /app/dist ./dist

# Cambiamos al usuario no-root
USER appuser

# Exponemos el puerto
EXPOSE 3000

# Variable de entorno
ENV NODE_ENV=production

# Health check usando el endpoint /health
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# Iniciamos la aplicacion
CMD ["node", "dist/index.js"]

Repasemos que hace cada parte:


  • Multi-stage build Usamos dos etapas. La primera instala todas las dependencias y compila TypeScript. La segunda solo tiene dependencias de produccion y el JavaScript compilado. Esto mantiene la imagen final liviana.
  • Base Alpine Usamos node:20-alpine en vez de node:20. Alpine es una distribucion Linux minima que produce imagenes mucho mas chicas.
  • Cache de capas Copiamos package*.json antes del codigo fuente. Esto significa que Docker puede cachear la capa de npm ci y solo reinstalar dependencias cuando cambie package.json.
  • Usuario no-root Correr como root dentro de un container es un riesgo de seguridad. Creamos un usuario dedicado y cambiamos a el antes de iniciar la app.
  • Health check Docker puede monitorear la salud del container llamando a nuestro endpoint /health. Orquestadores como Kubernetes usan esto para saber cuando reiniciar containers no saludables.

El archivo .dockerignore

Asi como .gitignore mantiene archivos fuera de tu repositorio, .dockerignore mantiene archivos fuera de tu contexto de build de Docker. Esto hace que los builds sean mas rapidos y previene que archivos sensibles se filtren en las imagenes.


Crea .dockerignore:


node_modules
dist
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
.vscode
.idea
coverage
.nyc_output

La entrada mas importante es node_modules. Sin esto, Docker copiaria todo tu directorio node_modules local al contexto de build, lo cual es lento e innecesario ya que corremos npm ci dentro del container de todas formas.


Construyendo y corriendo el container

Construi la imagen:


docker build -t task-api:latest .

Deberias ver a Docker ejecutando ambas etapas. La primera vez tarda un poco mas porque descarga la imagen base e instala dependencias. Los builds siguientes son mas rapidos gracias al cache de capas.


Verifica el tamanio de la imagen:


docker images task-api

La imagen multi-stage basada en Alpine deberia estar alrededor de 130-150 MB. Compara eso con una imagen node:20 completa que arranca en mas de 900 MB antes de que le agregues tu codigo.


Corre el container:


docker run -d --name task-api -p 3000:3000 task-api:latest

Probalo:


# Health check
curl http://localhost:3000/health | jq

# Crear una tarea
curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Corriendo en Docker!"}' | jq

Verifica el estado de salud del container:


docker inspect --format='{{.State.Health.Status}}' task-api

Despues de unos 30 segundos, deberia mostrar healthy.


Para y elimina el container cuando termines:


docker stop task-api && docker rm task-api

Docker Compose para desarrollo local

Correr docker build y docker run cada vez que cambias codigo se vuelve tedioso rapido. Docker Compose nos da un mejor flujo de trabajo. Podemos definir servicios, montar nuestro codigo fuente como volumen, y tener hot reload dentro del container.


Crea docker-compose.yml:


services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src
      - ./package.json:/app/package.json
    environment:
      - NODE_ENV=development
      - PORT=3000
    restart: unless-stopped

Necesitamos un Dockerfile separado para desarrollo ya que queremos tener ts-node y nodemon disponibles. Crea Dockerfile.dev:


FROM node:20-alpine

WORKDIR /app

# Copiamos archivos de paquete e instalamos todas las dependencias
COPY package*.json ./
RUN npm ci

# Copiamos la configuracion de TypeScript
COPY tsconfig.json ./

# Copiamos el codigo fuente (sera sobreescrito por el volume mount)
COPY src ./src

# Exponemos el puerto
EXPOSE 3000

# Corremos con nodemon para hot reload
CMD ["npx", "nodemon", "--watch", "src", "--ext", "ts", "--exec", "ts-node", "src/index.ts"]

Inicia el entorno de desarrollo:


docker compose up

Ahora edita src/index.ts, guarda el archivo, y mira como nodemon reinicia automaticamente dentro del container. Tus cambios aparecen sin reconstruir la imagen. Este es el flujo de desarrollo que queres: ciclos de feedback rapidos mientras seguis corriendo dentro de un container.


Para correrlo en background:


docker compose up -d

Ver los logs:


docker compose logs -f api

Parar todo:


docker compose down

Por que importan los containers para DevOps

Acabamos de pasar de “codigo en mi maquina” a “codigo en un container.” Puede parecer trabajo extra para una API simple, pero los containers resuelven problemas reales que aparecen en todos los equipos:


  • Reproducibilidad El container corre igual en tu laptop, en CI, y en produccion. No mas conversaciones de “en mi maquina funciona.”
  • Consistencia Todos en el equipo usan la misma version de Node.js, el mismo SO, las mismas dependencias. El Dockerfile es la unica fuente de verdad.
  • Aislamiento Tu app corre en su propio filesystem y namespace de red. No genera conflictos con otros servicios en la misma maquina.
  • Portabilidad La imagen corre donde sea que Docker corra: maquinas locales, VMs en la nube, clusters de Kubernetes. Buildeas una vez y deployeas donde quieras.
  • Inmutabilidad Una vez construida, la imagen no cambia. No te conectas por SSH a produccion para tocar archivos. Buildeas una imagen nueva y la deployeas.

Estas propiedades son la base del DevOps moderno. Cada herramienta y practica que cubrimos en esta serie se construye sobre containers. Los pipelines de CI/CD buildean imagenes de containers. Kubernetes los orquesta. GitOps trackea que version de imagen corre donde. Sin containers, nada de eso funciona tan fluidamente.


Resumen de la estructura del proyecto

A este punto, tu proyecto deberia verse asi:


task-api/
├── src/
│   ├── index.ts
│   └── types.ts
├── .dockerignore
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.dev
├── package.json
├── package-lock.json
└── tsconfig.json

Notas finales

En este articulo construimos una API REST completa con TypeScript y Express, y despues la containerizamos usando buenas practicas de Docker. Cubrimos multi-stage builds, usuarios no-root, health checks, .dockerignore, y Docker Compose para desarrollo local.


La API en si es intencionalmente simple. Guarda tareas en memoria, lo que significa que todos los datos desaparecen cuando el container se reinicia. Eso esta bien por ahora. En un articulo futuro vamos a agregar una base de datos real y aprender a manejar persistencia de datos con containers.


En el proximo articulo, vamos a configurar un pipeline de CI/CD que automaticamente buildee nuestra imagen Docker, corra tests, y pushee la imagen a un container registry. Ahi es donde el flujo de trabajo DevOps realmente empieza a tomar forma.


Espero que te haya resultado util y que lo hayas disfrutado, hasta la proxima!


Errata

Si encontras algun error o tenes alguna sugerencia, mandame un mensaje para que lo pueda corregir.

Tambien podes ver el codigo fuente y los cambios en los fuentes aca



$ Comentarios

Online: 0

Por favor inicie sesión para poder escribir comentarios.

2026-04-24 | Gabriel Garrido