Skip to content

Ambiente: Staging

Status: 🟢 Aplicado y en uso — en AWS, con deploys automáticos andando. Última actualización: 2026-08-07

Esta página estuvo mal durante diez días. Decía "nada de esto está creado todavía" con Staging ya arriba y ocho deploys exitosos encima. Se corrigió el 2026-08-07. Si estás por creerle a un documento de ambientes, cruzalo contra gh run list --workflow=deploy-staging.yml.

Dónde vive

AWS, la infraestructura de infra/terraform/environments/staging/: ECS Fargate (servicio web

  • servicio queue), Aurora PostgreSQL Serverless v2, ElastiCache Redis, ALB con WAFv2 adelante, secretos en Secrets Manager. El frontend y las dos documentaciones van a Firebase Hosting, que es independiente del cloud del backend.

Hubo un ADR para migrar todo esto a Azure (ADR-006). Se frenó el 2026-08-07: seguimos en AWS. El Terraform de Azure quedó escrito y sin mergear en el branch feature/migracion-azure, por si el pedido vuelve.

URLs

ComponenteURL
Web app principalhttps://soterex.stag.cislatam.net
Backend / APIhttps://api.soterex.stag.cislatam.net
Documentación funcional (VitePress)https://docs.soterex.stag.cislatam.net
Referencia de API (Scalar)https://docs.api.soterex.stag.cislatam.net

Cómo se deploya

Push al branch staging. Nada más. .github/workflows/deploy-staging.yml se dispara solo y corre cuatro jobs en paralelo: frontend, documentación funcional, referencia de API, y backend.

El de backend es el que importa: construye la imagen, la sube a ECR taggeada con el SHA, registra la revisión nueva de la task definition, corre las migraciones como un RunTask puntual antes de tocar el servicio (si fallan, el deploy se corta y el servicio viejo sigue sirviendo), y recién después actualiza web y queue. Un deploy completo tarda entre 8 y 22 minutos.

No hay terraform apply en el pipeline: Terraform gestiona la infraestructura, el pipeline gestiona qué imagen corre. Son dos cosas separadas a propósito. Para cambios de infraestructura están terraform-plan.yml y terraform-apply.yml.

⚠️ Esa separación no estaba realmente implementada, y rompió Staging el 2026-08-10. Los servicios de ECS no tenían ignore_changes = [task_definition], así que un terraform apply que venía a agregar un VPC endpoint —nada que ver con ECS— registró revisiones nuevas apuntando a :initial, el tag que solo existió antes del primer deploy, y apuntó los servicios ahí. Las tasks no pueden bajar esa imagen.

Corregido y aplicado el 2026-08-10: ignore_changes = [task_definition] para que no vuelva a pasar, y deployment_circuit_breaker con rollback para que, si pasa cualquier otra cosa, ECS vuelva solo a la última revisión buena en vez de reintentar en silencio.

El apply que lo trajo sirve además como prueba de que funciona: mostró 0 to add, 2 to change, 0 to destroy —los dos servicios, sólo el circuit breaker— y ni una línea sobre task definitions, sobre exactamente la misma infraestructura que unas horas antes había arrasado con las dos.

Cómo recuperarse (y cómo NO)

Re-lanzar el workflow de deploy no sirve. El repositorio de ECR tiene los tags inmutables y el tag es el SHA del commit: un rerun reconstruye el mismo commit, intenta pushear un tag que ya existe y ECR lo rechaza. El deploy se corta antes de tocar ECS. Se probó el 2026-08-10 y falló justamente así.

Es una propiedad del diseño, no un bug — los tags inmutables son deseables. Pero implica que el rerun nunca es una vía de recuperación en este repo.

Lo que sí funciona: la imagen buena ya está en ECR y las revisiones viejas de la task definition siguen ACTIVE, así que alcanza con volver a apuntar los servicios ahí, sin construir nada.

bash
aws ecs update-service --region us-east-1 --cluster soterex-staging-cluster --service soterex-staging-queue --task-definition soterex-staging-queue:14 --force-new-deployment

(Averiguar la revisión buena con describe-services: es la del deployment en ACTIVE, no la del PRIMARY que está fallando.)

La otra vía es pushear un commit nuevo a staging, que genera un SHA distinto y por lo tanto un tag nuevo. Sirve, pero es más lento y mezcla la recuperación con un cambio de código.

Para correr un comando de artisan puntual (un seeder, un tinker de diagnóstico) sin deployar, está run-artisan-command.yml, que se dispara a mano.

El branch staging no se toca sin OK explícito de Carlos — igual que production. Ver la estrategia de branching.

Terraform desde CI: no funciona todavía

Conviene saberlo antes de confiar en el semáforo verde de un PR de infra.

Staging se aplicó a mano, desde una laptop. terraform-apply.yml nunca corrió — cero ejecuciones desde que existe. terraform-plan.yml corrió sólo tres veces, todas fallidas. Los dos apuntan a soterex-terraform-apply-role, cuya trust policy exigía que el token viniera de ref:refs/heads/*, un claim que ninguno de los dos workflows presenta (declaran environment:, y cuando un job lo declara GitHub emite ese claim en lugar del de la branch). Encima el formato del sub cambió el 15/07/2026 y esa policy quedó con el viejo.

La trust policy ya está corregida en el código (infra/terraform/bootstrap/main.tf), pero bootstrap/ se aplica a mano: hasta que alguien corra ese apply, la de AWS sigue siendo la vieja.

El plan en PRs necesita además un rol aparte. No es un detalle a resolver de paso: el rol de apply crea VPCs, Aurora e IAM, y un PR puede editar el workflow que corre con esas credenciales. Que no sea asumible desde un pull request está bien puesto. Lo que falta es un rol de solo lectura (ReadOnlyAccess + bucket de estado + tabla de locks) para el plan. Hasta entonces ese job es advisory (continue-on-error) y no bloquea merges.

Nada de esto afecta al deploy de la app: deploy-staging.yml usa otro rol, con la trust policy bien puesta, y viene andando desde julio.

Costo: el NAT Gateway se comía $220/mes

Vale conocerlo antes de dimensionar producción, porque el mismo diseño escala el problema.

Una alerta de Cost Anomaly Detection (2026-08-09) mostró 163 GB/día pasando por el NAT Gateway, planos, ~$7.35/día. En un ambiente donde nadie opera. Ese patrón —idéntico todos los días, sin picos ni valles— no es gente usando el sistema: es algo automático.

Es el scheduler. Dispara una task de Fargate cada 5 minutos (288 por día) y cada arranque se baja la imagen entera de ECR, porque Fargate no comparte caché de imagen entre tasks. 163 GB ÷ 288 da ~580 MB por arranque, que es el tamaño de la imagen. Como no había ningún VPC endpoint, cada byte salía por el NAT y se pagaba a $0.045/GB.

Lo que más molesta es que el diseño era una optimización: el comentario del módulo dice que evita "pagar un contenedor prendido todo el día solo para el cron". Un contenedor chico 24/7 sale ~$15/mes.

Corregido y aplicado el 2026-08-10: un VPC Gateway Endpoint para S3 en modules/network. Es gratis —ni cargo por hora ni por GB— y es el que más rinde, porque las capas de las imágenes de ECR viven en S3.

El scheduler quedó apagado (2026-08-10). No hacía falta bajarle la frecuencia: en Staging la integración con Soterex sigue en modo mock, así que los 288 arranques diarios levantaban una task de Fargate para que Laravel decidiera no hacer nada. Se apaga con enabled = false en el módulo; el recurso sigue existiendo en DISABLED, así que volver a prenderlo es cambiar el flag y aplicar. Hay que volver a prenderlo cuando lleguen las credenciales de sandbox de Soterex — sin eso, M5 no ingesta nada aunque el resto esté listo.

Queda por decidir:

  1. La frecuencia del tick, para cuando se vuelva a prender. Si el job real corre cada 30 minutos, un tick cada 30 son 6x menos arranques. Cambia comportamiento (la latencia máxima de cualquier tarea programada pasa a ser 30 min), así que no se toca sin confirmarlo.
  2. Endpoints de tipo Interface (ecr.api, ecr.dkr, logs, secretsmanager). Cuestan ~$7.30/mes cada uno por AZ. Con las capas fuera del NAT lo que queda es tráfico chico, así que conviene medir primero cuánto baja y recién ahí ver si alguno se paga solo.

Para volver a medir:

bash
aws ce get-cost-and-usage --time-period Start=2026-07-28,End=2026-08-10 --granularity DAILY --metrics UnblendedCost UsageQuantity --filter '{"Dimensions":{"Key":"USAGE_TYPE","Values":["NatGateway-Bytes"]}}' --query 'ResultsByTime[].{dia:TimePeriod.Start,GB:Total.UsageQuantity.Amount,USD:Total.UnblendedCost.Amount}' --output table

Redis nunca funcionó en Staging (resuelto el 2026-08-10)

Confirmado resuelto. El worker venía muriendo cada ~2 minutos y siguió haciéndolo hasta las 16:34; el deploy entró 16:36 y a partir de ahí no hubo un solo reinicio en 90+ minutos, cuando al ritmo anterior tendrían que haber pasado unas 50 vueltas del loop. El corte cae exactamente sobre el deploy.

Encontrado el 2026-08-10. La cola venía crasheando y reiniciándose cada 90 segundos, y por lo visto desde hace mucho: queue:work consulta la caché en cada vuelta de su loop, la conexión a Redis falla, el proceso muere, ECS lo repone. En el log, la misma excepción repetida sin fin:

RedisException: read error on connection to
tcp://master.soterex-staging-redis...:6379   ← al hacer Redis->select(1)

La causa es el tcp:// de ese mensaje. ElastiCache tiene transit_encryption_enabled = true, así que exige TLS; el cliente abre TCP plano, el servidor no responde en claro, y el primer comando muere en un "read error" que no dice "falta TLS" por ningún lado.

Lo llamativo es que el código está bien: config/database.php tiene 'scheme' => env('REDIS_SCHEME', 'tcp') en las dos conexiones, y modules/ecs setea REDIS_SCHEME=tls desde el 2026-07-28 (commit 5e3a842, presente en staging).

El problema es cómo se combinan las dos mitades del deploy:

  • Las variables de entorno vienen de Terraform. Sin apply, no llegan.
  • La imagen viene del pipeline, que toma la última revisión de la familia y sólo le reemplaza la imagen — no vuelve a leer el Terraform.

Como terraform apply no se corría desde antes de ese commit, las revisiones que estuvieron corriendo todo este tiempo (queue:14, web:30) nunca tuvieron REDIS_SCHEME. Y el apply del 2026-08-10, que sí lo trajo, generó revisiones (queue:15, web:31) con la imagen rota — el arreglo y la rotura llegaron juntos, así que en la práctica nunca convivieron una imagen buena y la variable correcta.

Cómo se sale: un deploy normal lo resuelve solo, porque renderiza la imagen buena sobre la última revisión de la familia, que ya tiene REDIS_SCHEME=tls. Necesita un commit nuevo en staging (el tag de ECR es el SHA y es inmutable, así que re-lanzar un deploy viejo no sirve).

Vale la pena revisar qué más rompía: CACHE_STORE=redis y QUEUE_CONNECTION=redis, así que todo lo que dependa de caché o de jobs en segundo plano estuvo degradado en Staging desde el principio. El web seguía respondiendo porque su health check no toca Redis.

Cómo llegar de cero a un ambiente andando

Solo hace falta si alguna vez hay que reconstruirlo: el runbook completo está en 2026-07-25-staging-go-live-runbook (Firebase, ACM, Terraform, DNS, GitHub Secrets, primer deploy, verificación).

El bloqueante que ese runbook no cubría —la config de Firebase hardcodeada a literales "REEMPLAZAR" en doc/site/public/está resuelto desde el 2026-07-28: hoy tiene la config real del proyecto soterex-staging. Sigue siendo un valor hardcodeado en el HTML y no una variable de ambiente, lo cual es aceptable acá (una config de cliente de Firebase es pública por diseño), pero significa que para producción hay que resolver el templating por ambiente de verdad — no alcanza con cambiar el literal.

Usuarios y datos

Los usuarios de prueba los crean StagingAdminSeeder y StagingTestUsersSeeder (admin, supervisor, backoffice). Los datos son de prueba: no hay integración real con Soterex todavía (M5 sigue sin credenciales de sandbox), así que las transacciones son las del seeder, no tráfico real.

Esto tiene una consecuencia práctica: Staging se puede reconstruir sin perder nada de valor. No hay dato real que preservar.

Documentación viva — se actualiza junto con el código, no es un anexo aparte.