La decisión que ordena todo el tema de ambientes es una sola: qué objeto viaja de un ambiente al siguiente. Si viaja el código y cada ambiente lo compila por su cuenta, staging valida un artefacto que no es el que va a correr en producción — y todo lo que se prueba ahí es una aproximación. Si viaja el artefacto —el mismo binario, la misma imagen, el mismo digest— staging valida exactamente lo que después se despacha. El MinimumCD Practice Guide lo define sin margen: el artefacto que corre en producción tiene que ser byte-for-byte el mismo que pasó los tests.
Un pipeline que hace docker build en cada stage no decidió reconstruir. Heredó el ejemplo del que se copió. La decisión existe igual; lo único que falta es que alguien la tome.
El artefacto inmutable, y por qué el digest importa
Jez Humble y David Farley enunciaron el principio en Continuous Delivery (2010), capítulo 5, "Anatomy of the Deployment Pipeline", bajo el título "Only Build Your Binaries Once". El argumento que dan es operativo, no estético: si el deploy falla, querés poder descartar el paquete como causa. Si cada ambiente compila el suyo, no podés.
La versión implementable de esa regla es que el objeto que se promueve tiene un identificador criptográfico, no un nombre.
# Esto no identifica nada: el tag se puede mover.
docker pull ghcr.io/acme/api:v2.4.1
# Esto identifica un contenido exacto. Si cambia un byte, cambia el digest.
docker pull ghcr.io/acme/api@sha256:9c4f...e21b
Un tag es un puntero mutable. v2.4.1 puede apuntar hoy a una imagen y mañana a otra, y nada en el sistema te avisa. El digest es el hash del manifiesto: si dos ambientes corren el mismo digest, corren el mismo software. Es la única afirmación de paridad que se puede verificar con un comando.
Si dos ambientes corren el mismo digest, la pregunta "¿va a funcionar igual?" deja de ser una opinión y pasa a ser una propiedad verificable.
El argumento más caro de aprender es el del rollback. Con promoción de artefacto, volver atrás es apuntar el deploy al digest anterior — un objeto que ya corrió, ya se observó y ya funcionó. Con rebuild, volver atrás es compilar de nuevo desde un commit viejo, y eso produce un artefacto nuevo que nunca corrió en ningún lado. Estás haciendo un deploy inédito en el peor momento posible, con el incidente abierto y el reloj corriendo.
La lista de cosas que pueden diferir entre el build de staging y el de producción es larga y aburrida: la base image detrás del tag node:22-alpine se actualizó entre las dos corridas, un paquete transitivo publicó un patch, el índice de apt trae otra versión del runtime de C, el lockfile se resolvió distinto porque alguien corrió npm install en vez de npm ci. Ninguna de esas diferencias aparece en el diff del pull request. Todas aparecen en el incidente. El capítulo 8 del SRE Book de Google le pone nombre a la contramedida: hermetic build, un build que produce el mismo resultado porque fija sus herramientas y sus dependencias en vez de tomar lo que haya ese día.
Hay un tercer argumento, si te importa la cadena de suministro. La firma y el attestation de procedencia —con cosign, en el marco de SLSA— se emiten sobre un digest. Reconstruir en cada ambiente invalida la cadena entera: lo que atestiguaste en CI no es lo que corre.
De ahí se deduce dónde vive la configuración
Si el artefacto tiene que ser idéntico entre ambientes, no puede contener nada específico de un ambiente. Eso incluye la URL de la base, los endpoints de terceros, los defaults de los feature flags, los niveles de log y —sobre todo— los secrets. Es el factor V de The Twelve-Factor App: el build produce el artefacto, el release lo combina con la config del ambiente, el run lo ejecuta. Tres etapas separadas, y la única que se promueve es la primera.
La regla operativa para decidir de qué lado va cada cosa es directa: si el valor cambia entre ambientes, va afuera; si es el mismo en todos, puede ir adentro.
| Cosa | Dónde vive | Por qué |
|---|---|---|
| Código compilado, dependencias, assets | Adentro del artefacto | Es lo que se promueve; cambiarlo es un artefacto nuevo |
| Endpoint de la base, cola, bucket | Afuera, variables de entorno | Cambia por ambiente por definición |
| Credenciales, API keys, certificados | Afuera, secret manager | Además cambian por rotation, sin rebuild |
| Feature flags | Afuera, servicio de flags | Cambian en runtime, sin deploy |
| Límites de recursos, réplicas, timeouts | Afuera, manifiesto de infra | Es dimensionamiento, no lógica |
| Scripts de migración | Adentro, ejecución afuera | El script viaja con el artefacto; cuándo corre lo decide el ambiente |
Para los secrets, las tres opciones que se usan de verdad: HashiCorp Vault, con secrets dinámicos que se emiten con lease y TTL y se revocan solos; AWS Secrets Manager, con rotation automática vía Lambda; y los environment secrets de GitHub Actions o las masked variables de GitLab, cuando la superficie es chica y el CI es el único consumidor.
En ECS la inyección es declarativa y el digest queda explícito en la task definition:
resource "aws_ecs_task_definition" "api" {
family = "api"
container_definitions = jsonencode([{
name = "api"
image = "${var.registry}/api@${var.image_digest}"
environment = [
{ name = "LOG_LEVEL", value = var.log_level },
]
secrets = [
{ name = "DB_PASSWORD", valueFrom = aws_secretsmanager_secret.db.arn },
]
}])
}
El costo de esta decisión es real y conviene decirlo: la configuración se convierte en una superficie de cambio que no pasa por el pipeline. Un artefacto validado en staging puede romperse en producción por una variable mal escrita, y ese cambio no dejó rastro en ningún commit. Se compensa con tres controles. Versionar la config en un repo aparte y tratar un cambio de config como un deploy, con su autor, su registro y su rollback. Validar el schema de configuración al arrancar el proceso: si falta una variable, el proceso muere ruidoso en vez de servir tráfico degradado. Y detectar el drift antes de que sea un ambiente fantasma, corriendo terraform plan por schedule contra cada ambiente y fallando el job si hay diferencias:
# Nightly, contra cada ambiente. El exit code es el control.
terraform plan -detailed-exitcode -refresh-only -var-file=envs/produccion.tfvars
# 0 = sin drift | 2 = hay drift, fallá el job | 1 = error de ejecución
Sin ese tercer control, la config es el único componente del sistema sobre el que nadie puede afirmar qué está corriendo.
Lo único que no se promueve
El esquema de la base no se promueve. El artefacto viaja hacia adelante y puede volver; el esquema, en la práctica, solo va hacia adelante. Esa asimetría es la que rompe los rollbacks que en el papel eran instantáneos: revertís el deploy al digest anterior y el código viejo se encuentra con una columna que ya no existe.
La salida es expand/contract, y son tres deploys, no uno. El primero agrega estructura sin sacar nada, y el índice va en su propia migración:
-- V12__pagos_add_status.sql (transaccional, seguro)
ALTER TABLE pagos ADD COLUMN status text;
UPDATE pagos SET status = 'settled' WHERE settled_at IS NOT NULL;
-- V13__pagos_status_index.sql (NO transaccional; ver nota abajo)
CREATE INDEX CONCURRENTLY idx_pagos_status ON pagos (status);
La separación no es cosmética. CREATE INDEX CONCURRENTLY no puede ejecutarse dentro de un bloque de transacción, y los runners de migraciones envuelven cada archivo en BEGIN/COMMIT por default. En el mismo archivo que el ALTER TABLE, esa migración falla. Flyway lo resuelve con executeInTransaction=false en el script config; sqlx pide -- no-transaction en la primera línea; Alembic exige un autocommit_block().
El segundo deploy es el artefacto que escribe y lee la columna nueva, y sigue tolerando la vieja. El tercero, recién cuando ninguna versión en circulación depende de la columna vieja, la borra:
-- V21__pagos_drop_settled_at.sql (semanas después, no minutos)
ALTER TABLE pagos DROP COLUMN settled_at;
De ahí sale la invariante que hay que sostener, y es la que decide si tu rollback existe:
La versión N del artefacto tiene que funcionar contra el esquema de N-1 y contra el de N. Si no se cumple, tu plan de rollback es un restore de backup.
Cuántos ambientes hacen falta
Un ambiente se justifica si responde una pregunta que ningún otro responde, y si alguien mira la respuesta. Los dos criterios, juntos. Un ambiente que nadie mira es un costo de infraestructura y de lead time, no un control.
| Ambiente | Qué pregunta responde | Qué cuesta | Cuándo no vale |
|---|---|---|---|
| Local | ¿Compila y pasan los tests? | Casi nada | Nunca; siempre vale |
| Efímero por PR | ¿Se comporta bien este cambio aislado? | Provisión automática, datos sintéticos, TTL | Si tarda más en levantar que lo que dura el review |
| Staging | ¿El artefacto se integra con las dependencias reales? | El más caro: paridad de datos e infra | Si sus datos no se parecen a los de producción |
| Producción | Todas las que importan | — | — |
Los dos ambientes que sobran con más frecuencia son "QA" y "pre-producción" cuando ya existe staging. Suelen nacer de un límite organizacional —un equipo que pide su propio entorno— y no de una pregunta técnica sin responder. El síntoma es medible: la cola de espera. Si un cambio espera para entrar a un ambiente, ese ambiente está agregando lead time sin agregar información.
Vale mirar cómo lo resuelve alguien que publicó sus números. Slack describe su cadena como staging, dogfood, canary y producción por porcentajes crecientes hasta el 100%, y define staging como un entorno de producción que no acepta tráfico público. Lo que separa a esos ambientes es el tráfico y los datos, no la infraestructura.
El caso contrario también existe. Un ambiente de más se justifica cuando el blast radius de equivocarse es asimétrico: una migración de datos irreversible, una integración con un tercero que factura por request y no tiene sandbox, un release que requiere aprobación regulatoria con evidencia. En fintech el ambiente de integración con el proveedor de pagos existe porque el proveedor no te deja ensayar contra producción.
Por qué "funciona en staging" precede al incidente
La paridad de datos importa más que la paridad de infraestructura. La razón es de forma: la infraestructura falla de maneras que ya conocés y para las que hay alertas, mientras que los datos fallan de maneras que nadie anticipó porque nadie los miró. DORA trata el manejo de datos de prueba como una capability con evidencia detrás, no como una preferencia de estilo.
Esta tabla ordena el gasto. Va de lo más barato de igualar a lo más caro, que es el orden en el que conviene atacarlo:
| Dimensión | Costo de igualarla | Qué bugs revela |
|---|---|---|
| Versión exacta del motor y sus extensiones | Bajo: una línea de configuración | Funciones que no existen, planner que elige otro plan |
| Distribución del tráfico entre endpoints | Bajo: se replica del log de acceso | Hot partitions, caches que no sirven, rate limits de terceros |
| Datos sucios acumulados | Bajo si venís de un dump; alto si los sintetizás | Registros viejos sin un campo que hoy es obligatorio, parsers que asumen formato uniforme |
| Cardinalidad de las relaciones | Medio: el generador tiene que producir outliers a propósito | N+1 que solo duele con el cliente que tiene miles de hijos, paginación que revienta |
| Concurrencia | Medio: necesitás un generador de carga, no más datos | Deadlocks, lost updates, unique violations por race |
| Volumen de filas | Alto: almacenamiento y tiempo de carga en cada refresh | Queries sin índice, timeouts, seq scans que en chico eran invisibles |
El volumen está último porque es el más caro y el que todos intentan primero. Una query sin índice es instantánea con cientos de filas y bloquea la tabla con decenas de millones: el plan de ejecución no es el mismo, y staging con dos órdenes de magnitud menos de datos no lo va a mostrar nunca.
Las estrategias para poblar esos datos, con lo que cuesta cada una:
| Estrategia | Qué gana | Qué cuesta |
|---|---|---|
| Dump anonimizado de producción | Volumen, cardinalidad y suciedad reales | Proceso de anonimización auditable; en Argentina la Ley 25.326 y, si hay datos de residentes europeos, el GDPR |
| Subset referencialmente consistente | Menor tamaño y costo, integridad preservada | Escribir y mantener el extractor; se rompe con cada cambio de esquema |
| Datos sintéticos | Cero riesgo legal, reproducible | Solo encuentra los bugs que imaginaste al generarlos |
| Shadow traffic | Tráfico real sin impacto en el usuario | Duplicar requests con efectos secundarios es peligroso; solo sirve para lecturas |
La anonimización tiene un requisito que casi siempre se descubre tarde: tiene que ser determinística y respetar el formato. Un CUIT reemplazado por XXXXXXXXXXX no prueba nada, porque el código valida el dígito verificador y rechaza la fila antes de llegar a la lógica que querías ejercitar. El reemplazo tiene que ser otro CUIT sintético válido. Y el mismo CUIT original tiene que mapear siempre al mismo sintético, o se rompen los joins por documento y los tests de deduplicación pierden sentido.
PostgreSQL Anonymizer, de Dalibo, resuelve el otro problema: dónde viven las reglas. Se declaran como security labels sobre las columnas, dentro del catálogo de la base, así que una columna nueva sin regla se detecta al hacer el dump y no seis meses después.
SECURITY LABEL FOR anon ON COLUMN clientes.cuit
IS 'MASKED WITH FUNCTION public.cuit_sintetico(cuit)';
SECURITY LABEL FOR anon ON COLUMN clientes.email
IS 'MASKED WITH FUNCTION anon.pseudo_email(email)';
La combinación que funciona: sintéticos para el ambiente efímero por PR, subset anonimizado con volumen representativo para staging, y aceptar explícitamente que la última clase de bugs se descubre en producción. Esa aceptación es la que justifica invertir en canary releases, feature flags y rollback rápido en vez de en un cuarto ambiente.
Cada peso que ponés en un ambiente más rinde menos que el mismo peso puesto en achicar el blast radius del deploy a producción.
Cómo se ve la promoción en el pipeline
En GitHub Actions, un solo job construye y todos los demás reciben el digest por outputs. Los deployment environments aportan lo que hace falta arriba de eso: required reviewers, wait timer, deployment branch policies y environment secrets.
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v6
id: push
with:
push: true
provenance: true # attestation SLSA sobre el digest
sbom: true # inventario de dependencias, adjunto a la imagen
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
staging:
needs: build
environment: staging
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh "ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}"
produccion:
needs: [build, staging]
environment: produccion # required reviewers y wait timer se configuran acá
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh "ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}"
Dos detalles que decide el needs. El job de producción lo declara sobre build y sobre staging: sin build en esa lista, el contexto needs no expone el digest y el script recibe una cadena vacía. Y provenance con sbom son los que convierten el párrafo sobre SLSA en algo que existe en el registry.
En GitLab el equivalente es la palabra clave environment con protected environments para las aprobaciones, when: manual para el gate y resource_group para que dos deploys al mismo ambiente no se pisen — sin eso, dos pipelines concurrentes pueden dejar el ambiente corriendo el artefacto más viejo de los dos.
En un flujo GitOps con Argo CD o Flux, la promoción es todavía más literal: un commit en el repo de manifiestos que reemplaza el digest del ambiente destino. El artefacto no se toca; se mueve un puntero. El diff del pull request de promoción muestra exactamente una línea, que es el mejor registro de auditoría que vas a conseguir.
Las herramientas que implementan esto
| Herramienta | Qué resuelve |
|---|---|
| Imágenes OCI y capas de Docker | El artefacto en sí y su digest: la base verificable de toda la cadena |
| Helm values files | La config afuera: un values por ambiente sobre el mismo chart y el mismo digest |
| Argo CD | Promoción declarativa por GitOps y rastro auditable de qué versión está en qué ambiente |
| GitHub Actions — deployment environments | Los gates: aprobaciones, secrets por ambiente y restricción de ramas |
| Testcontainers | Paridad de dependencias en el ambiente más barato, que corre validación hacia la izquierda |
| JFrog Artifactory | Promoción entre repositorios con metadata por artefacto, para cuando la evidencia tiene que acumularse |
Las preguntas que ordenan la decisión
- ¿Qué objeto viaja entre ambientes, y tiene un identificador inmutable? Si la respuesta es un tag y no un digest, empezá por ahí.
- ¿Qué queda adentro del artefacto que difiere entre ambientes? Todo lo que aparezca en esa lista es deuda.
- ¿La versión N de tu servicio arranca contra el esquema de N-1? Si no, no tenés rollback.
- ¿Cuál de tus ambientes responde una pregunta que ningún otro responde?
- ¿Los datos de staging se parecen a los de producción en volumen y cardinalidad, o solo en esquema?
- ¿Cuánto lead time agrega cada gate, y qué clase de incidente evitó el último año?
Las métricas de DORA sirven acá como instrumento y no como objetivo. Hoy son cinco: change lead time, deployment frequency, change fail rate, failed deployment recovery time y deployment rework rate. Un ambiente de más empeora las dos primeras de inmediato. Si a cambio no mejora las tres últimas de forma medible, no está pagando su costo.
La promoción del artefacto cambia de qué se tratan los incidentes: dejan de ser sorpresas sobre qué versión está corriendo y pasan a ser problemas de datos, de configuración o de carga. Esas tres son categorías con dueño, con instrumentación posible y con solución conocida. "Funciona en staging" deja de ser una frase premonitoria y pasa a ser un dato con alcance definido.
Para seguir
Lecturas
- Immutable Artifacts — MinimumCD Practice Guide — la definición canónica: el artefacto que corre en producción es byte-for-byte el que pasó los tests, y promover es desplegar ese mismo objeto.
- Patterns — Continuous Delivery (Jez Humble) — fuente primaria de 'only build packages once' y de desplegar igual a todos los ambientes, con la justificación original.
- The Twelve-Factor App — V. Build, release, run — la separación estricta entre build, release y run es el mecanismo que hace posible promover en vez de reconstruir.
- Release Engineering — Google SRE Book, capítulo 8 — hermetic builds, artefactos versionados y promoción controlada en una organización enorme; el vocabulario técnico que falta en la mayoría de las discusiones.
- Deploys at Slack — caso público con números: staging definido como un entorno de producción sin tráfico público, y canary por porcentajes hasta el 100%.
- DORA — Capabilities: Test data management — la evidencia detrás de la afirmación más discutible del artículo: los datos de prueba adecuados correlacionan con performance de entrega.
- Accelerate State of DevOps Report 2024 — la edición donde el núcleo de métricas de entrega está mejor tratado; sirve para poner precio a una promoción lenta o a un ambiente de más.
Videos
- Adopting Continuous Delivery — Jez Humble — el coautor de Continuous Delivery explicando el deployment pipeline y por qué el binario se construye una sola vez.
- Continuous Delivery Pipelines Webinar — Dave Farley — la anatomía del pipeline etapa por etapa: qué produce el release candidate y por qué cada etapa posterior consume el mismo artefacto.
- Sustainable Continuous Delivery — Manuel Pais — el ángulo organizacional: cuándo un ambiente extra deja de ser red de seguridad y pasa a ser una cola de espera con dueño.