Listado de la etiqueta: devops

Guía práctica: cómo migré un proyecto de Angular 19/20 a Angular 21.2 sin morir en el intento

Migrar un proyecto Angular de v19/20 a v21 puede sonar intimidante. En la práctica, si seguís el proceso correcto, es incremental: cada paso es reversible y el proyecto sigue funcionando en todo momento. Esta es la guía que apliqué en proyectos reales.

Antes de empezar: el inventario

# Ver versión actual y dependencias desactualizadas
ng version
ng update

# Verificar compatibilidad antes de actualizar
npx npm-check-updates -u --target minor

Paso 1: Actualizar el core

# Siempre de a una versión mayor a la vez
# Si estás en v19, primero actualizar a v20, luego a v21

# De v20 a v21:
ng update @angular/core@21 @angular/cli@21

# El comando aplica schematics automáticos:
# - Actualiza imports deprecated
# - Adapta APIs que cambiaron
# - Avisa sobre cambios manuales necesarios

# Verificar que compila
ng build --configuration production

Paso 2: Adoptar Zoneless

// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes)
  ]
};

// angular.json: "polyfills": []  // eliminar zone.js
// npm uninstall zone.js

Paso 3: Migrar de Karma a Vitest

ng generate @angular/build:vitest
ng test  # verificar que los tests pasan
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter

Paso 4: Standalone components

# Angular 21 asume standalone por defecto
# Migración automática:
ng generate @angular/core:standalone --mode=convert-to-standalone
ng generate @angular/core:standalone --mode=prune-ng-modules
ng generate @angular/core:standalone --mode=standalone-bootstrap

Checklist completo

PasoAcciónObligatorio
1ng update @angular/core@21 @angular/cli@21✅ Sí
2Verificar que la app compila y los tests pasan✅ Sí
3Activar provideZonelessChangeDetection()Recomendado
4Eliminar Zone.js de polyfillsCon paso 3
5Migrar a VitestRecomendado
6Nuevos formularios con Signal FormsGradual
7Componentes de nav con funciones standalone del RouterGradual
8Configurar ng mcp para integración con IAOpcional

Errores comunes y cómo resolverlos

// ERROR: El componente no se actualiza después de activar Zoneless
// → Convertir estado a Signals o llamar markForCheck()

// ERROR: Tests fallan con "No current Angular test" después de Vitest
// → Verificar imports de @angular/core/testing

// ERROR: "Cannot find module zone.js"
// → Buscar y eliminar import 'zone.js' en el proyecto:
// grep -r "import 'zone.js'" src/

// ERROR: ExpressionChangedAfterItHasBeenCheckedError en Zoneless
// → Usar signal.update() o signal.set() en lugar de mutación directa

Mi experiencia: cuánto tardó la migración

En un proyecto de tamaño medio (45 componentes, 12 servicios, 80 tests): el ng update tardó 10 minutos. Revisar avisos y corregir deprecaciones: media jornada. Migración a Zoneless con todos los tests adaptados a Vitest: un día. Adopción gradual de Signal Forms en los formularios principales: dos semanas, a medida que tocábamos cada módulo.

No es un proceso de un fin de semana, pero tampoco es una reescritura. Es una evolución incremental que podés hacer en paralelo con el trabajo normal del equipo.


Artículo anterior: Router Signals en Angular 21: navegación standalone sin cargar todo el Router | Fin de la Serie Angular 20 → 21.2

¿Tenés preguntas sobre la migración en tu proyecto específico? Dejá tu caso en los comentarios. 👇

Vitest reemplaza a Karma y Angular habla con tu IA: el nuevo ecosistema de herramientas

Karma murió. No es una exageración: Angular 21 lo retiró como runner por defecto y lo reemplazó con Vitest. Y junto con eso llegó algo que no esperaba: soporte nativo para que asistentes de IA entiendan la estructura de un proyecto Angular en tiempo real con el protocolo MCP.

Por qué Karma quedó obsoleto

Karma fue el runner de tests de Angular durante más de una década. Abre un browser real, corre los tests ahí y reporta los resultados. Funcional, pero lento, difícil de configurar en CI y sin soporte nativo para el modelo Zoneless. En el mundo actual de testing, donde queremos ejecución rápida y paralela, Karma no competía.

Vitest: el nuevo estándar

  • Ejecución en paralelo: múltiples workers, tests más rápidos
  • Sin browser real: usa jsdom o happy-dom — más rápido y estable en CI
  • Soporte nativo Zoneless: sin el overhead de Zone.js en los tests
  • Watch mode inteligente: solo re-ejecuta los tests afectados por cambios
  • API compatible con Jest: si venís de Jest, la curva es mínima
# Configurar Vitest en un proyecto Angular 21 existente
ng generate @angular/build:vitest

# O en angular.json
{
  "test": {
    "builder": "@angular/build:unit-test",
    "options": {
      "runner": "vitest"
    }
  }
}

Tests con Vitest + Signals

import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { describe, it, expect } from 'vitest';
import { ContadorComponent } from './contador.component';

describe('ContadorComponent', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ContadorComponent],
      providers: [provideZonelessChangeDetection()]
    });
  });

  it('debería incrementar el contador', () => {
    const fixture = TestBed.createComponent(ContadorComponent);
    const comp = fixture.componentInstance;

    expect(comp.contador()).toBe(0);
    comp.incrementar();
    // Sin detectChanges() manual — Signals actualizan sincrónicamente
    expect(comp.contador()).toBe(1);
    expect(comp.doble()).toBe(2);
  });

  it('debería renderizar el valor actualizado', async () => {
    const fixture = TestBed.createComponent(ContadorComponent);
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();

    await fixture.whenStable();
    expect(fixture.nativeElement.querySelector('p').textContent).toContain('1');
  });
});

ng mcp: Angular habla con tu IA

La feature que más me sorprendió de Angular 21.2: soporte nativo para el Model Context Protocol (MCP). Con un solo comando, Angular expone la estructura del proyecto a asistentes de IA locales para que entiendan el contexto en tiempo real.

# Levantar el servidor MCP de Angular
ng mcp

# Configuración en .cursor/mcp.json o claude_desktop_config.json:
{
  "mcpServers": {
    "angular": {
      "command": "ng",
      "args": ["mcp"],
      "cwd": "/path/to/mi-proyecto-angular"
    }
  }
}

Con el servidor MCP activo, el asistente de IA tiene acceso al árbol de componentes, rutas del router, servicios y sus dependencias, grafo de Signals y configuración del proyecto. En la práctica: cuando le pedís al asistente que genere un componente, lo hace correctamente dentro del contexto de tu proyecto — no código genérico que hay que adaptar.

Migrar de Karma a Vitest

# 1. Desinstalar Karma
npm uninstall karma karma-chrome-launcher karma-jasmine karma-jasmine-html-reporter

# 2. Ejecutar el schematic de migración
ng generate @angular/build:vitest

# 3. Verificar tests
ng test

# Los tests de Jasmine son mayormente compatibles
# Solo ajustar: jasmine.SpyObj → vi.Mocked cuando sea necesario

Artículo anterior: Novedades del template en Angular 21: regex, spread, instanceof y más | Serie Angular 20 → 21.2 | Próximo: Router Signals en Angular 21: navegación standalone sin cargar todo el Router →

Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro

Me pidieron «alta disponibilidad» para un sistema crítico. Tenía Docker Compose funcionando perfecto en un solo nodo. Pensé: «con –scale lo resuelvo». Hasta que entendí que escalar horizontalmente en múltiples nodos con Compose no es trivial. Ese fue el momento en que Kubernetes dejó de ser «esa tecnología complicada» y se convirtió en la herramienta correcta para el trabajo.

Docker standalone: cuándo alcanza

No todo necesita Kubernetes. Docker solo, o Docker Compose, es perfecto para muchos casos de uso:

  • Aplicaciones en un solo servidor
  • Entornos de desarrollo local
  • Proyectos personales o startups en etapa temprana
  • Workloads que no requieren alta disponibilidad real
  • Pipelines de CI/CD

La tabla que lo resume todo

CaracterísticaDocker soloDocker SwarmKubernetes
ComplejidadBajaMediaAlta
HA multi-nodoNoSí (básico)Sí (avanzado)
Auto-scalingManualBásicoHPA / VPA automático
Self-healingrestart policySí (avanzado)
Rolling updatesManualSí (con control fino)
SecretsEnv vars / archivosDocker SecretsKubernetes Secrets + Vault
NetworkingBridge / overlay básicoOverlayCNI (Calico, Flannel, etc.)
Observabilidaddocker logs / statsBásicaPrometheus, Grafana, Jaeger
Curva de aprendizajeDíasSemanasMeses

Docker Swarm: el punto medio

Swarm es el orquestador nativo de Docker. Más simple que Kubernetes, soporta multi-nodo y HA básica. Si necesitás distribuir contenedores en 2-5 nodos sin la complejidad de K8s, Swarm es una opción válida.

# Inicializar un swarm
docker swarm init --advertise-addr 192.168.1.10

# Agregar un nodo worker
docker swarm join --token SWMTKN-1-xxx 192.168.1.10:2377

# Desplegar un stack (similar a docker compose)
docker stack deploy -c docker-compose.yml mi-app

# Ver servicios del stack
docker service ls
docker service ps mi-app_api

# Escalar un servicio
docker service scale mi-app_api=3

Kubernetes: cuándo lo necesitás de verdad

En mi cluster SUSE Linux HA con dos nodos, Swarm funcionaba. Pero cuando los requisitos crecieron — deploys sin downtime garantizado, auto-scaling basado en métricas, gestión de secretos centralizada, rollbacks automáticos — Kubernetes fue la respuesta correcta.

La diferencia fundamental: Docker (y Swarm) son herramientas para correr contenedores. Kubernetes es una plataforma para gestionar aplicaciones. La distinción importa cuando tu aplicación crece.

# El mismo concepto en Docker Compose vs Kubernetes

# docker-compose.yml
services:
  api:
    image: mi-api:1.4.2
    replicas: 3
    restart: unless-stopped

# ─────────────────────────────────────────────
# En Kubernetes (deployment.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1    # nunca baja de 2 replicas durante update
      maxSurge: 1          # puede tener 4 temporalmente
  selector:
    matchLabels:
      app: api
  template:
    spec:
      containers:
      - name: api
        image: mi-api:1.4.2
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 30
        readinessProbe:
          httpGet:
            path: /ready
            port: 80

El camino natural: Docker → Compose → Kubernetes

No es un salto, es una progresión. Empecé con docker run, pasé a Compose para gestionar múltiples servicios, y cuando los requisitos de producción superaron lo que Compose podía manejar cómodamente, migramos a Kubernetes. Los conceptos son los mismos — imágenes, contenedores, redes, volúmenes — pero Kubernetes agrega la capa de orquestación inteligente que necesitás a escala.

El conocimiento de Docker no se descarta al llegar a Kubernetes: es el prerequisito. Todo pod de Kubernetes corre contenedores Docker. Los Dockerfiles que aprendiste a escribir son exactamente los mismos. La diferencia está en quién los gestiona y con qué nivel de sofisticación.

Mi setup actual

Hoy corro Kubernetes en mi cluster on-premise SUSE con dos nodos en HA. Docker sigue presente — lo uso en CI/CD para buildear imágenes y en desarrollo local. Compose lo uso para levantar entornos de desarrollo con múltiples servicios. Kubernetes gestiona producción. Cada herramienta en su lugar.


Artículo anterior: Seguridad en Docker | Fin de la Serie Docker Completo

Esta fue la serie completa sobre Docker. Si llegaste hasta acá, tenés las bases para trabajar con contenedores en entornos reales. El siguiente paso natural es Kubernetes — cubriremos eso en una serie dedicada.


Artículo anterior: Seguridad en Docker: errores que cometí y cómo los corregí | Fin de la Serie Docker Completo

Seguridad en Docker: errores que cometí y cómo los corregí

Desplegué una imagen con credenciales hardcodeadas en el Dockerfile. Una vez. Cuando me di cuenta, la imagen estaba en el registry privado de la empresa y nadie más la había visto — pero el susto fue suficiente para que revisara la seguridad de todos mis contenedores ese mismo día.

Los errores de seguridad más comunes en Docker

La mayoría de los problemas de seguridad en Docker no son bugs exóticos: son malas prácticas que cometemos por desconocimiento o por apurarnos. Acá van los que yo cometí y cómo los corregí.

Error 1: Correr contenedores como root

# ❌ Por defecto, el proceso corre como root dentro del contenedor
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Si el proceso es comprometido, tiene privilegios de root dentro del contenedor

# ✅ Crear y usar un usuario no-root
FROM node:20-alpine
WORKDIR /app

# Crear usuario sin privilegios
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copiar archivos con el usuario correcto
COPY --chown=appuser:appgroup . .
RUN npm install --only=production

USER appuser
CMD ["node", "server.js"]

Error 2: Secretos en el Dockerfile o en variables de entorno planas

# ❌ NUNCA - el secreto queda grabado en una capa para siempre
ENV DB_PASSWORD=mipassword123
RUN curl -H "Authorization: Bearer TOKENREALAQUI" https://api.interna.com/config

# Aunque hagas otra capa que lo "borre", sigue en la historia de la imagen:
docker history mi-imagen  # el secreto es visible

# ✅ Para secrets en build-time, usar BuildKit secrets
# Esto NO deja rastro en las capas
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=api_token     TOKEN=$(cat /run/secrets/api_token) &&     curl -H "Authorization: Bearer $TOKEN" https://api.interna.com/config

# Build:
docker build --secret id=api_token,src=./secrets/api_token .

# ✅ Para secrets en runtime, usar Docker secrets (Swarm) o variables de entorno
# via archivo .env que NUNCA va al repositorio
docker run --env-file .env.prod mi-imagen

Error 3: Imágenes base desactualizadas

# Escanear una imagen en busca de vulnerabilidades conocidas
docker scout cves mi-imagen:latest

# O con Trivy (más completo, lo que uso en CI)
docker run --rm   -v /var/run/docker.sock:/var/run/docker.sock   aquasec/trivy image mi-imagen:latest

# Resultado típico:
# 2026-03-11T10:00:00Z INFO Detected OS: alpine 3.18
# CRITICAL: 0, HIGH: 1, MEDIUM: 3, LOW: 8
# HIGH: libssl CVE-2024-XXXXX - update to 3.1.5-r0

Error 4: El .dockerignore inexistente

# Sin .dockerignore, COPY . . incluye todo — incluyendo:
# - .git/ (historial completo del repo)
# - .env (credenciales locales)
# - node_modules/ (pesado e innecesario)
# - tests/ (código de tests en la imagen de producción)

# .dockerignore completo que uso en todos mis proyectos:
.git
.gitignore
.env
.env.*
!.env.example
**/node_modules
**/bin
**/obj
**/*.log
**/.DS_Store
docker-compose*.yml
Dockerfile*
tests/
docs/
README.md
.github/

Dockerfile inseguro vs seguro: comparación completa

# ❌ Dockerfile inseguro
FROM node:latest                    # versión impredecible
ENV API_KEY=abc123supersecret       # secreto en capa
WORKDIR /app
COPY . .                            # sin .dockerignore, incluye .env y .git
RUN npm install                     # instala todo incluyendo devDependencies
EXPOSE 3000
CMD ["node", "server.js"]           # corre como root
# ✅ Dockerfile seguro
FROM node:20.11.0-alpine3.19        # versión fija y verificable

WORKDIR /app

# Usuario no-root
RUN addgroup -S app && adduser -S app -G app

# Dependencias primero (aprovecha cache, sin devDependencies)
COPY --chown=app:app package*.json ./
RUN npm ci --only=production && npm cache clean --force

# Código fuente (sin secretos - .dockerignore los excluye)
COPY --chown=app:app src/ ./src/

# Sin variables de entorno sensibles en la imagen
# Se pasan en runtime con --env-file

USER app
EXPOSE 3000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --retries=3   CMD wget -q -O /dev/null http://localhost:3000/health || exit 1

CMD ["node", "src/server.js"]

Limitar capacidades del contenedor

# Eliminar todas las capabilities de Linux y agregar solo las necesarias
docker run   --cap-drop=ALL   --cap-add=NET_BIND_SERVICE \   # solo si necesita bind a puerto < 1024
  --read-only \                   # sistema de archivos de solo lectura
  --tmpfs /tmp \                  # área de escritura temporal
  --security-opt=no-new-privileges   --user 1001:1001   mi-imagen:latest

El checklist que uso antes de cada deploy

  • ✅ ¿La imagen corre con usuario no-root?
  • ✅ ¿Hay .dockerignore con .env excluido?
  • ✅ ¿Los secretos van en runtime, no en la imagen?
  • ✅ ¿La imagen base tiene versión fija?
  • ✅ ¿Pasé Trivy o docker scout y no hay CVEs críticos?
  • ✅ ¿Los puertos expuestos son solo los necesarios?
  • ✅ ¿Hay healthcheck configurado?

Artículo anterior: Microservicios con Docker | Serie Docker Completo | Próximo: Docker vs Kubernetes →


Artículo anterior: Microservicios con Docker: lo que aprendí armando mi primera arquitectura | Serie Docker Completo | Próximo: Docker vs Kubernetes: cuándo me alcanza con uno y cuándo necesito el otro →

Microservicios con Docker: lo que aprendí armando mi primera arquitectura

Arrancé con un monolito .NET en un solo contenedor. Funcionaba bien, hasta que el equipo creció y todos tocábamos el mismo código. Desplegar un cambio en la pantalla de login requería redeployar toda la aplicación. Fue entonces cuando empecé a explorar microservicios con Docker.

Monolito vs microservicios: cuándo tiene sentido el cambio

AspectoMonolito en DockerMicroservicios en Docker
Complejidad inicialBajaAlta
Deploy independienteNo — todo o nadaSí — servicio por servicio
Escalabilidad selectivaNoSí — escalar solo lo que lo necesita
Fallo aisladoUn bug afecta todoUn servicio caído no baja todo
Equipos independientesDifícilCada equipo dueño de su servicio
Overhead operacionalBajoAlto — más servicios que monitorear

Mi recomendación: empezá con el monolito. Cuando los puntos de dolor de la tabla de arriba se vuelvan reales en tu día a día, ahí es el momento de dividir.

Mi primera arquitectura de microservicios: auth + api + frontend

# docker-compose.yml — tres servicios independientes

version: '3.8'

services:
  # Servicio de autenticación (JWT, usuarios)
  auth-service:
    build: ./services/auth
    environment:
      - DB_CONNECTION=Host=postgres;Database=auth;Username=auth;Password=${AUTH_DB_PASS}
      - JWT_SECRET=${JWT_SECRET}
      - JWT_EXPIRY=1h
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped
    # Sin puerto expuesto - solo accesible internamente

  # API principal de negocio
  api-service:
    build: ./services/api
    environment:
      - DB_CONNECTION=Host=postgres;Database=apidb;Username=api;Password=${API_DB_PASS}
      - AUTH_SERVICE_URL=http://auth-service:8080
      - CACHE_URL=redis:6379
    depends_on:
      - auth-service
      - redis
    restart: unless-stopped

  # Frontend React
  frontend:
    build: ./services/frontend
    environment:
      - REACT_APP_API_URL=http://api-service:8080
    restart: unless-stopped

  # Proxy - único punto de entrada externo
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/microservices.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - frontend
      - api-service

  # Infraestructura compartida
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_MULTIPLE_DATABASES: auth,apidb  # extensión para múltiples DBs
      POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASS}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready"]
      interval: 10s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

Comunicación entre servicios

Dentro de la red Docker, los servicios se llaman por nombre. La API valida tokens llamando al servicio de auth en cada request:

# En el código de api-service (.NET):
// Validar token contra auth-service
var authResponse = await _httpClient.GetAsync(
    $"{_authServiceUrl}/validate?token={token}"
);

// En el docker-compose, AUTH_SERVICE_URL = http://auth-service:8080
// Docker resuelve "auth-service" al contenedor correcto automáticamente

Deploy independiente: la ventaja real

# Actualizar solo el servicio de auth sin tocar nada más
docker compose up -d --build auth-service

# Escalar solo la API (recibe más carga)
docker compose up -d --scale api-service=3

# Rollback solo del frontend
docker compose stop frontend
docker compose rm -f frontend
TAG=anterior docker compose up -d frontend

Lo que aprendí en el proceso

La transición de monolito a microservicios no es solo técnica: es organizacional. Cada servicio necesita su propio repositorio (o al menos su propia carpeta), su propio pipeline de CI/CD y su propio dueño. La complejidad operacional sube. Por eso Docker Compose no es suficiente para microservicios en producción a escala — ese es el camino hacia Kubernetes, que vemos en el próximo artículo.


Artículo anterior: Docker en CI/CD | Serie Docker Completo | Próximo: Seguridad en Docker →


Artículo anterior: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas | Serie Docker Completo | Próximo: Seguridad en Docker: errores que cometí y cómo los corregí →

Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas

Antes de Docker, nuestro pipeline de CI era una caja negra. «Funcionó en mi máquina pero el build del CI falló». Después de adoptar Docker en el pipeline, los builds fallidos por diferencias de entorno desaparecieron. Si buildea en CI, buildea en producción. Siempre.

Por qué Docker transforma el CI/CD

El problema fundamental del CI tradicional: el servidor de CI tiene su propio entorno — versiones de runtime, librerías del sistema, variables — que puede diferir del de desarrollo y producción. Docker elimina ese problema: el build ocurre dentro de un contenedor con el entorno exacto que vos definís en el Dockerfile.

GitHub Actions: build, test y push a registry

# .github/workflows/ci-cd.yml
name: Build, Test and Deploy

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=sha-

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha    # cache de GitHub Actions
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /apps/miapp
            docker compose pull
            docker compose up -d --no-build
            docker image prune -f

GitLab CI: Docker-in-Docker

# .gitlab-ci.yml
stages:
  - build
  - test
  - push
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  DOCKER_TLS_CERTDIR: "/certs"

# Build de la imagen
build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE

# Tests dentro de contenedor
test:
  stage: test
  image: $DOCKER_IMAGE
  script:
    - dotnet test --no-build --verbosity normal

# Tag como latest en main
push-latest:
  stage: push
  image: docker:24
  services:
    - docker:24-dind
  only:
    - main
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker pull $DOCKER_IMAGE
    - docker tag $DOCKER_IMAGE $CI_REGISTRY_IMAGE:latest
    - docker push $CI_REGISTRY_IMAGE:latest

# Deploy al servidor
deploy-prod:
  stage: deploy
  only:
    - main
  script:
    - ssh -i $SSH_KEY deploy@$PROD_HOST
        "cd /apps/miapp &&
         docker compose pull &&
         docker compose up -d --no-build"

Optimizar el tiempo de build con cache

El mayor costo de tiempo en un pipeline Docker es el build. Con la caché bien configurada, un build que tardaba 8 minutos puede bajar a 40 segundos si solo cambió el código de la aplicación.

# La clave: copiar el .csproj ANTES que el código
# Así el restore se cachea hasta que cambien las dependencias

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Esta capa se cachea hasta que cambie el .csproj
COPY ["MiApi.csproj", "."]
RUN dotnet restore

# Esta capa se invalida con cada commit
COPY . .
RUN dotnet publish -c Release -o /app/publish

El resultado en números

Antes de implementar este pipeline: builds inconsistentes, ~20 minutos por build, deploys manuales con SSH y comandos a mano. Después: builds reproducibles, 2-4 minutos con cache (8 min cold), deploy automático en cada push a main, rollback trivial con docker compose up -d apuntando a la imagen anterior. Menos stress, más confianza en cada deploy.


Artículo anterior: Entornos consistentes | Serie Docker Completo | Próximo: Microservicios →


Artículo anterior: Cómo uso Docker para tener el mismo entorno en dev, test y producción | Serie Docker Completo | Próximo: Microservicios con Docker: lo que aprendí armando mi primera arquitectura →

Cómo uso Docker para tener el mismo entorno en dev, test y producción

El mismo código, el mismo docker-compose.yml, la misma imagen. Pero en desarrollo quiero hot reload, logs verbosos y la base de datos con datos de prueba. En producción quiero imágenes optimizadas, variables reales y sin herramientas de debug. Docker tiene una forma elegante de manejar esto sin duplicar archivos.

El problema clásico: dev funciona, prod falla

Antes de adoptar este patrón, tenía dos docker-compose.yml separados: uno para dev y otro para prod. Se desincronizaban constantemente. Cambiaba algo en dev, me olvidaba de replicarlo en prod, y el deploy fallaba. La solución es usar un archivo base más overrides por entorno.

El patrón: base + override

# docker-compose.yml (base - lo que es igual en todos los entornos)
version: '3.8'

services:
  api:
    image: ${REGISTRY:-localhost}/mi-api:${TAG:-latest}
    environment:
      - ASPNETCORE_ENVIRONMENT=${APP_ENV:-Development}
      - ConnectionStrings__Default=${DB_CONNECTION}
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: miapp
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      retries: 5

volumes:
  postgres-data:
# docker-compose.override.yml (desarrollo - se aplica automáticamente)
version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev    # SDK completo con hot reload
    volumes:
      - .:/app                      # bind mount para hot reload
    ports:
      - "5000:80"                   # expuesto para debuggear
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - Logging__LogLevel__Default=Debug

  postgres:
    ports:
      - "5432:5432"                 # accesible desde el host para pgAdmin
    volumes:
      - ./sql/seed-dev.sql:/docker-entrypoint-initdb.d/seed.sql:ro
# docker-compose.prod.yml (producción - aplicar explícitamente)
version: '3.8'

services:
  api:
    image: miregistry.local/mi-api:${TAG}   # imagen pre-buildeada
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: '0.5'
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - Logging__LogLevel__Default=Warning
    # Sin ports expuestos - solo accesible via nginx

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro

Cómo usarlos

# Desarrollo (usa base + override automáticamente)
docker compose up -d

# Producción (base + prod explícito)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Staging (si tuviera un docker-compose.staging.yml)
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d

Variables de entorno por entorno: los archivos .env

# .env.dev (en el repo - no tiene secretos reales)
APP_ENV=Development
DB_PASSWORD=dev-password
DB_CONNECTION=Host=postgres;Database=miapp;Username=app;Password=dev-password
REGISTRY=localhost
TAG=latest

# .env.prod (NUNCA en el repo - en el servidor o secrets manager)
APP_ENV=Production
DB_PASSWORD=password-super-seguro-generado
DB_CONNECTION=Host=postgres;Database=miapp;Username=app;Password=password-super-seguro-generado
REGISTRY=miregistry.intranet.empresa.com
TAG=1.4.2
# Desarrollo
docker compose --env-file .env.dev up -d

# Producción
docker compose --env-file .env.prod   -f docker-compose.yml   -f docker-compose.prod.yml   up -d

Resultado: el mismo repo, comportamiento correcto en cada entorno

Con este patrón, un desarrollador nuevo puede clonar el repo y levantar el entorno de desarrollo con docker compose up -d. El pipeline de CI/CD usa el archivo de producción con las variables correctas. No hay dos versiones del mismo archivo que se desincronicen. La infraestructura está en el código, versionada junto con la aplicación.

En mi cluster SUSE, el deploy de producción es un script de 4 líneas: pull del repo, pull de la imagen nueva, docker compose up -d con el .env.prod, y verificación de health. Predecible, repetible, auditable.


Artículo anterior: Docker Compose | Serie Docker Completo | Próximo: Docker en CI/CD →


Artículo anterior: Docker Compose: el día que dejé de levantar contenedores a mano | Serie Docker Completo | Próximo: Docker en mi pipeline de CI/CD: builds reproducibles sin sorpresas →

Docker Compose: el día que dejé de levantar contenedores a mano

Tenía un script Bash con 8 comandos docker run. Cada vez que alguien del equipo necesitaba levantar el entorno de desarrollo, le mandaba el script por Slack y rezaba para que no hubiera cambiado nada desde la última vez. Un día un compañero me mostró su docker-compose.yml. Nunca más volví al script.

¿Qué es Docker Compose?

Docker Compose es una herramienta para definir y ejecutar aplicaciones multi-contenedor usando un archivo YAML. En lugar de recordar 8 comandos docker run con todos sus flags, definís todos los servicios, redes y volúmenes en un solo archivo versionado. Un comando levanta todo; otro lo baja.

El docker-compose.yml completo: .NET + PostgreSQL + Redis + Nginx

Este es el stack que uso como base en mis proyectos. Cada servicio tiene su rol claro:

version: '3.8'

services:
  # Proxy inverso - único punto de entrada
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - api
    restart: unless-stopped

  # API .NET 8
  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__Default=Host=postgres;Database=miapp;Username=app;Password=${DB_PASSWORD}
      - Redis__ConnectionString=redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
    # Sin -p: solo accesible internamente a través de nginx

  # Base de datos PostgreSQL
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: miapp
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d miapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # Cache Redis
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    restart: unless-stopped

volumes:
  postgres-data:
  redis-data:

networks:
  default:
    name: miapp-network

El archivo .env: secretos fuera del YAML

# .env (en .gitignore - nunca en el repo)
DB_PASSWORD=password-super-seguro-aqui
REDIS_PASSWORD=otro-password-seguro

Los comandos que uso todos los días

# Levantar todo en background
docker compose up -d

# Levantar y ver los logs mientras arranca
docker compose up

# Levantar solo un servicio (y sus dependencias)
docker compose up -d api

# Ver estado de los servicios
docker compose ps

# Logs de todos los servicios
docker compose logs -f

# Logs de un servicio específico
docker compose logs -f api

# Ejecutar comando en un servicio
docker compose exec api bash
docker compose exec postgres psql -U app -d miapp

# Bajar todo (mantiene volúmenes)
docker compose down

# Bajar y eliminar volúmenes (¡CUIDADO en producción!)
docker compose down -v

# Rebuild y restart de un servicio
docker compose up -d --build api

# Escalar un servicio (múltiples instancias)
docker compose up -d --scale api=3

Health checks: que Compose espere a que los servicios estén listos

Uno de los problemas clásicos: la API arranca antes que la base de datos y falla al conectar. La solución está en los healthcheck y depends_on con condición, como hice en el ejemplo de Postgres. Compose espera hasta que el healthcheck pase antes de arrancar los servicios dependientes.

# Verificar el healthcheck de un servicio
docker compose ps
# NAME              STATUS
# miapp-postgres-1  healthy   ← Postgres superó el healthcheck
# miapp-api-1       running   ← API arrancó después

El antes y el después

Mi script Bash antes:

# ❌ Lo que tenía antes (8 líneas que siempre tenía que recordar actualizar)
docker network create miapp
docker run -d --name postgres --network miapp -e POSTGRES_PASSWORD=... ...
docker run -d --name redis --network miapp ...
docker run -d --name api --network miapp -e DB_HOST=postgres ...
docker run -d --name nginx --network miapp -p 80:80 ...
# etc...

Ahora:

# ✅ Todo el stack en un comando
docker compose up -d

El archivo está en el repo. Cualquier miembro del equipo puede clonar y levantar el entorno completo en un comando. Sin documentación de «cómo levantar el entorno». Sin scripts que se desactualizan. El docker-compose.yml es la documentación.


Artículo anterior: Redes en Docker | Serie Docker Completo | Próximo: Entornos consistentes →


Artículo anterior: Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad | Serie Docker Completo | Próximo: Cómo uso Docker para tener el mismo entorno en dev, test y producción →

Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad

Pasé casi tres horas intentando que un contenedor de frontend se comunicara con un contenedor de backend. Hacía curl http://localhost:3000 desde dentro del frontend y no llegaba nada. El problema no era el código: era que no entendía cómo funcionan las redes en Docker.

El modelo de red de Docker

Cada contenedor tiene su propia interfaz de red virtual y su propia dirección IP dentro de la red Docker. El localhost dentro de un contenedor es ese contenedor, no el host ni otro contenedor. Ese fue mi error: intentar llegar a otro contenedor como si fuera mi máquina.

Los tres drivers de red nativos

┌─────────────────────────────────────────────────────────────────┐
│  BRIDGE (default)                                               │
│                                                                 │
│  Host ──── docker0 ──── contenedor1 (172.17.0.2)               │
│                    └─── contenedor2 (172.17.0.3)               │
│                                                                 │
│  • Red virtual privada                                          │
│  • Contenedores aislados entre sí por defecto                  │
│  • Se exponen puertos explícitamente con -p                     │
├─────────────────────────────────────────────────────────────────┤
│  HOST                                                           │
│                                                                 │
│  Host ──── contenedor (comparte la red del host)               │
│                                                                 │
│  • Sin aislamiento de red                                       │
│  • Máximo rendimiento (sin NAT)                                 │
│  • Solo disponible en Linux                                     │
├─────────────────────────────────────────────────────────────────┤
│  NONE                                                           │
│                                                                 │
│  contenedor (sin red - solo loopback)                          │
│                                                                 │
│  • Aislamiento total                                            │
│  • Para procesos que no necesitan red                           │
└─────────────────────────────────────────────────────────────────┘

La solución: redes definidas por el usuario

La red bridge por defecto tiene una limitación importante: los contenedores no se pueden resolver por nombre, solo por IP. Las redes personalizadas solucionan esto con DNS automático. Esta es la forma correcta de conectar contenedores.

# Crear una red personalizada
docker network create mi-app-network

# Levantar backend
docker run -d   --name backend   --network mi-app-network   -e DB_HOST=postgres   mi-api:latest

# Levantar frontend - puede resolver "backend" por nombre
docker run -d   --name frontend   --network mi-app-network   -p 80:3000   -e API_URL=http://backend:8080   mi-frontend:latest

# Levantar base de datos en la misma red
docker run -d   --name postgres   --network mi-app-network   -v postgres-data:/var/lib/postgresql/data   -e POSTGRES_PASSWORD=secreto   postgres:16-alpine

# Ahora desde frontend podés hacer:
# curl http://backend:8080/api/health  ✅
# La base de datos NO está expuesta al exterior (sin -p)

Publicación de puertos: qué exponés y qué no

Un error común es publicar todos los puertos de todos los servicios. La buena práctica: solo el punto de entrada de tu aplicación (el frontend o la API pública) se expone al host. La base de datos, el cache, los servicios internos — solo accesibles dentro de la red Docker.

# ❌ Exponer todo - superficie de ataque innecesaria
docker run -p 5432:5432 postgres    # DB expuesta al mundo
docker run -p 6379:6379 redis       # Cache expuesta al mundo
docker run -p 8080:8080 mi-api      # API interna expuesta

# ✅ Solo exponer el punto de entrada
docker run -p 80:80 mi-nginx        # Solo el proxy/frontend al exterior
# El resto: en red interna, sin -p

Comandos de diagnóstico de red

# Ver redes existentes
docker network ls

# Inspeccionar una red (ver qué contenedores están conectados)
docker network inspect mi-app-network

# Conectar/desconectar un contenedor de una red en caliente
docker network connect mi-app-network contenedor-existente
docker network disconnect mi-app-network contenedor-existente

# Diagnóstico de conectividad desde dentro de un contenedor
docker exec -it frontend ping backend
docker exec -it frontend curl http://backend:8080/health
docker exec -it frontend nslookup backend  # resolución DNS

Lo que debería haber hecho desde el principio

La solución a mis tres horas de frustración era simple: crear una red personalizada y usar los nombres de contenedor como hostnames. Desde que lo entendí, la comunicación entre servicios es trivial. La clave mental: dentro de una red Docker personalizada, el nombre del contenedor es el hostname. http://backend:8080 funciona igual que http://192.168.1.100:8080, pero sin tener que saber IPs que cambian.


Artículo anterior: Volúmenes y persistencia | Serie Docker Completo | Próximo: Docker Compose →


Artículo anterior: Cuando perdí datos de producción por no usar volúmenes (y cómo no repetirlo) | Serie Docker Completo | Próximo: Docker Compose: el día que dejé de levantar contenedores a mano →

Cuando perdí datos de producción por no usar volúmenes (y cómo no repetirlo)

Era viernes por la tarde. Reinicié un contenedor de PostgreSQL que habíamos levantado «temporalmente» hacía tres meses. En segundos entendí el error: los datos vivían dentro del contenedor, no en un volumen. Tres meses de datos del cliente, gone. Esa tarde aprendí para siempre qué es la persistencia en Docker.

El problema: los contenedores son efímeros por diseño

Cuando Docker crea un contenedor, agrega una capa de escritura sobre la imagen base. Todo lo que el proceso escribe va ahí. Cuando el contenedor se destruye, esa capa desaparece con él. Es intencional: los contenedores son desechables. El problema surge cuando guardamos datos importantes en esa capa temporal.

# Demostración del problema:
docker run -d --name mi-postgres postgres:16
docker exec -it mi-postgres psql -U postgres -c "CREATE TABLE clientes (id serial, nombre text);"
docker exec -it mi-postgres psql -U postgres -c "INSERT INTO clientes VALUES (1, 'Empresa ABC');"

# Destruir el contenedor...
docker rm -f mi-postgres

# Volver a crearlo...
docker run -d --name mi-postgres postgres:16
docker exec -it mi-postgres psql -U postgres -c "SELECT * FROM clientes;"
# ERROR: relation "clientes" does not exist
# Los datos desaparecieron.

Las tres formas de manejar datos en Docker

TipoDónde viveGestionado porCaso de uso
Volumes/var/lib/docker/volumes/DockerBases de datos, datos persistentes en producción
Bind MountsCualquier path del hostVosDesarrollo local, compartir código
tmpfs mountsRAM del hostDockerDatos sensibles temporales, caché efímera

Volúmenes nombrados: la forma correcta para producción

Los volúmenes son el mecanismo preferido para datos persistentes. Docker los gestiona completamente: sabe dónde están, los protege de eliminaciones accidentales y los puede mover entre contenedores fácilmente.

# Crear un volumen nombrado
docker volume create postgres-data

# Usarlo con PostgreSQL
docker run -d   --name mi-postgres   -e POSTGRES_PASSWORD=secreto   -e POSTGRES_DB=miapp   -v postgres-data:/var/lib/postgresql/data   -p 5432:5432   --restart=unless-stopped   postgres:16-alpine

# Ahora los datos sobreviven al contenedor:
docker rm -f mi-postgres
docker run -d   --name mi-postgres   -e POSTGRES_PASSWORD=secreto   -v postgres-data:/var/lib/postgresql/data   postgres:16-alpine
# ✅ Los datos siguen ahí

Comandos de gestión de volúmenes

docker volume ls                          # listar volúmenes
docker volume inspect postgres-data       # detalle de un volumen
docker volume rm postgres-data            # eliminar (solo si no está en uso)
docker volume prune                       # eliminar todos los no usados

Bind Mounts: para desarrollo local

Los bind mounts montan un directorio del host directamente en el contenedor. Los uso mucho en desarrollo: edito el código en mi máquina y el contenedor lo ve en tiempo real, sin necesidad de hacer rebuild.

# Desarrollo de API .NET con hot reload
docker run -d   --name mi-api-dev   -v $(pwd):/app   -w /app   -p 5000:80   -e ASPNETCORE_ENVIRONMENT=Development   mcr.microsoft.com/dotnet/sdk:8.0   dotnet watch run

# Desarrollo Node.js con nodemon
docker run -d   --name mi-node-dev   -v $(pwd):/app   -w /app   -p 3000:3000   node:20-alpine   sh -c "npm install && npm run dev"

Backup y restauración de volúmenes

Después del incidente del viernes, implementé backups automáticos de todos los volúmenes de producción. La estrategia más confiable que encontré:

# Backup de un volumen a un archivo tar
docker run --rm   -v postgres-data:/data   -v $(pwd)/backups:/backups   alpine   tar czf /backups/postgres-$(date +%Y%m%d_%H%M%S).tar.gz -C /data .

# Restaurar desde backup
docker run --rm   -v postgres-data:/data   -v $(pwd)/backups:/backups   alpine   tar xzf /backups/postgres-20260311_030000.tar.gz -C /data

# Backup directo de PostgreSQL (más limpio para DBs)
docker exec mi-postgres   pg_dump -U postgres miapp > backup_$(date +%Y%m%d).sql

La regla que aplico siempre

Si el dato importa, va en un volumen. Sin excepción. Bases de datos, archivos subidos por usuarios, certificados, configuraciones que cambian en runtime. Todo lo que no quiero perder cuando hago docker rm -f va en un volumen nombrado. El viernes que perdí esos datos fue la última vez que cometí ese error.


Artículo anterior: Ciclo de vida de un contenedor | Serie Docker Completo | Próximo: Redes en Docker →


Artículo anterior: docker run y todo lo que nadie te explica del ciclo de vida de un contenedor | Serie Docker Completo | Próximo: Redes en Docker: de ‘no puedo conectar mis contenedores’ a entenderlo de verdad →