Email copiado — support@tuurt.com
Cargando experiencia
aws · 23 de agosto de 2026 · 7 min

Pipeline de CI/CD con GitHub Actions hacia ECS Fargate: build, push a ECR y despliegue sin downtime

Un pipeline concreto de GitHub Actions hacia ECS Fargate: build, push a ECR, task definition y despliegue rolling sin downtime, con OIDC, health checks y rollback automático.

Por Equipo Tuurt

Pipeline de CI/CD con GitHub Actions hacia ECS Fargate: build, push a ECR y despliegue sin downtime

Cada vez que montamos un pipeline de despliegue continuo para un servicio en ECS Fargate, el requisito que más rápido aparece en la conversación con el cliente es "que no se caiga mientras despliega". Suena obvio, pero conseguirlo exige encadenar bien varias piezas que por separado son simples: construir la imagen, subirla a un registro, actualizar la definición de tarea y dejar que ECS reemplace las tareas viejas por las nuevas sin que el balanceador deje de recibir tráfico en ningún momento. Este artículo describe el pipeline que usamos como base para servicios Node y .NET en ECS Fargate, con GitHub Actions como orquestador.

La forma del pipeline

El flujo tiene cuatro pasos que se ejecutan en orden en cada push a main: construir la imagen Docker, subirla a Amazon ECR, generar una nueva revisión de la task definition con esa imagen, y actualizar el servicio de ECS para que despliegue esa revisión. ECS se encarga del rolling deployment; nuestro trabajo es darle los insumos correctos y configurar los health checks para que sepa cuándo un despliegue salió mal antes de que le llegue tráfico real.

name: deploy
on:
  push:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: pedidos-api
  ECS_CLUSTER: prod-cluster
  ECS_SERVICE: pedidos-api-service
  CONTAINER_NAME: pedidos-api

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configurar credenciales AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Login en ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build y push de la imagen
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> "$GITHUB_OUTPUT"

Un detalle que cambiamos temprano en casi todos los proyectos: usar github.sha como tag de imagen en lugar de latest. Con latest, la task definition no cambia entre despliegues, y ECS no detecta que hay nada nuevo que desplegar porque, desde su perspectiva, sigue apuntando al mismo tag. Con el SHA del commit, cada despliegue produce una revisión de task definition distinta, que es justamente lo que ECS necesita para disparar un rolling deployment real.

Credenciales sin secrets de larga duración

El primer intento que probamos en este tipo de pipeline fue con un access key de IAM guardado como secret de GitHub. Funciona, pero es una credencial de larga duración que vive fuera de AWS y que hay que rotar a mano. Lo reemplazamos por OIDC: GitHub Actions asume un rol de IAM directamente, sin que exista ningún access key almacenado en ningún lado.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:tuurt/pedidos-api:ref:refs/heads/main"
        }
      }
    }
  ]
}

La condición StringLike sobre sub es la parte que importa: sin ella, el rol confía en cualquier repositorio o rama que use ese proveedor OIDC, y con GitHub Actions eso significa confiar en cualquier workflow de la organización entera. Acotarlo al repositorio y a la rama main reduce el radio de lo que un token robado o un workflow mal configurado en otro repo podría hacer.

Actualizar la task definition sin reescribirla a mano

Generar la nueva revisión de task definition a partir de un archivo JSON base y reemplazar solo la imagen es más confiable que reconstruir el JSON completo en el pipeline, porque evita que el pipeline se desincronice de cambios hechos a mano en la task definition (variables de entorno nuevas, límites de CPU o memoria ajustados, montajes de volúmenes agregados por otra persona).

      - name: Descargar task definition actual
        run: |
          aws ecs describe-task-definition \
            --task-definition pedidos-api-task \
            --query taskDefinition > task-definition.json

      - name: Insertar la nueva imagen
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: task-definition.json
          container-name: ${{ env.CONTAINER_NAME }}
          image: ${{ steps.build-image.outputs.image }}

      - name: Desplegar en ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

describe-task-definition trae siempre la última revisión activa, así que cualquier cambio manual hecho por consola o por otro pipeline queda incluido en la siguiente actualización automática, en lugar de revertirse silenciosamente. wait-for-service-stability: true es la línea que conecta el pipeline con el resultado real del despliegue: el job de GitHub Actions no termina en verde hasta que ECS confirma que el número deseado de tareas está corriendo y pasando los health checks, no simplemente hasta que se envió el comando de actualización.

Health checks: la diferencia entre desplegar y desplegar sin downtime

Sin un health check bien configurado, ECS puede considerar que una tarea nueva está lista apenas el contenedor arranca, y empezar a enviarle tráfico antes de que la aplicación termine de inicializar sus conexiones a base de datos o de cargar su caché en memoria. El health check del container, no solo el del target group del load balancer, es el que evita esto:

{
  "healthCheck": {
    "command": ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
    "interval": 15,
    "timeout": 5,
    "retries": 3,
    "startPeriod": 30
  }
}

startPeriod es el campo que más veces omitimos por error en las primeras iteraciones de este pipeline: sin él, los primeros fallos de health check durante el arranque cuentan contra el límite de retries, y una aplicación que tarda 20 segundos en levantar puede marcarse como no saludable antes de haber tenido oportunidad real de responder. Con startPeriod en 30 segundos, esos primeros chequeos no cuentan, y el conteo real de reintentos empieza solo cuando la aplicación ya tuvo tiempo de inicializar.

El endpoint /health en sí debe verificar dependencias reales, no devolver 200 de forma incondicional. Un endpoint que solo confirma que el proceso de Node responde, sin verificar la conexión a la base de datos, deja pasar tráfico hacia una tarea que en teoría está viva pero que en la práctica no puede completar ninguna petición real.

Qué pasa cuando el despliegue nuevo falla

wait-for-service-stability: true también es lo que expone un despliegue fallido en el momento correcto. Si la nueva revisión de task definition no logra pasar los health checks —por una variable de entorno faltante, una migración de base de datos pendiente o un error de configuración— ECS sigue intentando estabilizar el servicio durante el tiempo configurado, y el job de GitHub Actions termina en rojo cuando ese tiempo se agota. Esa señal roja es la que dispara el rollback.

El rollback en ECS Fargate no requiere un mecanismo especial: la revisión de task definition anterior sigue existiendo, así que revertir es volver a desplegar esa revisión concreta.

aws ecs update-service \
  --cluster prod-cluster \
  --service pedidos-api-service \
  --task-definition pedidos-api-task:47 \
  --force-new-deployment

Lo que no resolvimos con esto la primera vez fue el rollback automático: en la primera versión del pipeline, un despliegue fallido dejaba el servicio en un estado mixto —algunas tareas viejas, algunas nuevas fallando— hasta que alguien del equipo intervenía a mano con el comando anterior. Terminamos agregando un paso posterior en el workflow que, si wait-for-service-stability falla, ejecuta automáticamente el update-service hacia la revisión anterior conocida como buena, guardada como output de un job previo. No elimina la necesidad de investigar por qué falló el despliegue, pero sí elimina el minuto o los cinco minutos que el servicio pasaba degradado mientras alguien se conectaba a la terminal para revertir a mano.

Lo que dejamos fuera a propósito

Este pipeline no incluye despliegues blue-green con CodeDeploy ni canary releases con desvío gradual de tráfico. Para la mayoría de los servicios internos y APIs de tamaño medio que desplegamos, un rolling deployment bien configurado —con health checks reales, minimumHealthyPercent en 100 y maximumPercent en 200 para no perder capacidad durante el reemplazo— cubre el requisito de cero downtime sin la complejidad operativa adicional de gestionar un segundo entorno completo o reglas de desvío de tráfico. Reservamos blue-green para los servicios donde un rollback tiene que ser instantáneo a nivel de balanceador, no solo a nivel de task definition, y eso depende del servicio concreto, no es una regla que apliquemos de entrada a todo.

aws ci-cd ecs github-actions
← Volver al blog