← Volver
DevOps 19 min de lectura

Lo que se promueve es el artefacto, no el código

Promover el mismo artefacto inmutable a través de los ambientes, en vez de reconstruirlo en cada escalón, es la decisión de la que se deducen todas las demás: dónde vive la configuración, qué paridad importa, qué no se puede promover y cuántos ambientes valen lo que cuestan.

Datos verificados al 10 de agosto de 2026. Precios, límites y nombres de flags de proveedores cambian.

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.

Dos pipelines desde el mismo commit: arriba, un único build produce un digest que atraviesa dev, staging y producción, y el rollback vuelve a un digest anterior que ya corrió. Abajo, tres builds por ambiente producen tres digests distintos y el rollback estrena un cuarto.
Contar los objetos es el ejercicio. Arriba hay uno y se puede probar con un comando; abajo hay cuatro, y el cuarto —el del rollback— se estrena en producción durante el incidente.

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.

CosaDónde vivePor qué
Código compilado, dependencias, assetsAdentro del artefactoEs lo que se promueve; cambiarlo es un artefacto nuevo
Endpoint de la base, cola, bucketAfuera, variables de entornoCambia por ambiente por definición
Credenciales, API keys, certificadosAfuera, secret managerAdemás cambian por rotation, sin rebuild
Feature flagsAfuera, servicio de flagsCambian en runtime, sin deploy
Límites de recursos, réplicas, timeoutsAfuera, manifiesto de infraEs dimensionamiento, no lógica
Scripts de migraciónAdentro, ejecución afueraEl 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.

Una línea vertical separa el artefacto —idéntico, con su digest, repetido en dev, staging y producción— de lo que se inyecta afuera: variables de entorno, secrets con lease y TTL, y feature flags evaluados en runtime.
La frontera es también una frontera de auditoría: lo de la derecha tiene digest, firma y diff; lo de la izquierda no tiene ninguna de las tres. Por eso apagar un flag en producción es un deploy aunque no se parezca a uno.

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.

AmbienteQué pregunta respondeQué cuestaCuándo no vale
Local¿Compila y pasan los tests?Casi nadaNunca; siempre vale
Efímero por PR¿Se comporta bien este cambio aislado?Provisión automática, datos sintéticos, TTLSi 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 infraSi sus datos no se parecen a los de producción
ProducciónTodas 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.

Árbol de cuatro preguntas encadenadas sobre un ambiente: si responde algo único, si alguien mira el resultado, si sus datos se parecen a producción en volumen y cardinalidad, y si el lead time que agrega es menor al riesgo que saca. Cada no cae en una acción concreta y los cuatro síes llegan a mantenerlo.
Las cuatro preguntas se hacen en ese orden a propósito: las dos primeras son gratis de responder y descartan la mayoría de los ambientes que existen por inercia.

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ónCosto de igualarlaQué bugs revela
Versión exacta del motor y sus extensionesBajo: una línea de configuraciónFunciones que no existen, planner que elige otro plan
Distribución del tráfico entre endpointsBajo: se replica del log de accesoHot partitions, caches que no sirven, rate limits de terceros
Datos sucios acumuladosBajo si venís de un dump; alto si los sintetizásRegistros viejos sin un campo que hoy es obligatorio, parsers que asumen formato uniforme
Cardinalidad de las relacionesMedio: el generador tiene que producir outliers a propósitoN+1 que solo duele con el cliente que tiene miles de hijos, paginación que revienta
ConcurrenciaMedio: necesitás un generador de carga, no más datosDeadlocks, lost updates, unique violations por race
Volumen de filasAlto: almacenamiento y tiempo de carga en cada refreshQueries 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:

EstrategiaQué ganaQué cuesta
Dump anonimizado de producciónVolumen, cardinalidad y suciedad realesProceso de anonimización auditable; en Argentina la Ley 25.326 y, si hay datos de residentes europeos, el GDPR
Subset referencialmente consistenteMenor tamaño y costo, integridad preservadaEscribir y mantener el extractor; se rompe con cada cambio de esquema
Datos sintéticosCero riesgo legal, reproducibleSolo encuentra los bugs que imaginaste al generarlos
Shadow trafficTráfico real sin impacto en el usuarioDuplicar 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

HerramientaQué resuelve
Imágenes OCI y capas de DockerEl artefacto en sí y su digest: la base verificable de toda la cadena
Helm values filesLa config afuera: un values por ambiente sobre el mismo chart y el mismo digest
Argo CDPromoción declarativa por GitOps y rastro auditable de qué versión está en qué ambiente
GitHub Actions — deployment environmentsLos gates: aprobaciones, secrets por ambiente y restricción de ramas
TestcontainersParidad de dependencias en el ambiente más barato, que corre validación hacia la izquierda
JFrog ArtifactoryPromoción entre repositorios con metadata por artefacto, para cuando la evidencia tiene que acumularse

Las preguntas que ordenan la decisión

  1. ¿Qué objeto viaja entre ambientes, y tiene un identificador inmutable? Si la respuesta es un tag y no un digest, empezá por ahí.
  2. ¿Qué queda adentro del artefacto que difiere entre ambientes? Todo lo que aparezca en esa lista es deuda.
  3. ¿La versión N de tu servicio arranca contra el esquema de N-1? Si no, no tenés rollback.
  4. ¿Cuál de tus ambientes responde una pregunta que ningún otro responde?
  5. ¿Los datos de staging se parecen a los de producción en volumen y cardinalidad, o solo en esquema?
  6. ¿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

Videos

Siguiente · Cloud · 19 min Qué comprás exactamente cuando comprás nube Leer siguiente →