Skip to content

What Terraform must do

Terraform owns the infrastructure; this tool owns the version. That boundary needs a small number of things declared on your side, and the tool reads them and refuses when one is missing rather than writing it.

Otherwise the next terraform apply rolls the image back to whatever the module declared. One line per resource:

# azurerm_container_app
lifecycle { ignore_changes = [template[0].container[0].image] }
# google_cloud_run_v2_service
lifecycle { ignore_changes = [template[0].containers[0].image] }
# aws_lambda_function
lifecycle { ignore_changes = [s3_key] }

Add the environment to that list once a service’s env moves into the deploy config:

lifecycle {
ignore_changes = [
template[0].container[0].image,
template[0].container[0].env,
]
}

Azure and GCP need nothing else. Updates carry only the template — a merge patch on Azure, a template field mask on GCP — so ingress, identity, IAM, traffic split and declared secrets are untouched.

ECS is the exception to everything above. Image, environment, cpu and probes all live in one immutable container_definitions blob, so field-level ignore_changes is impossible.

Instead each owner gets its own task definition family:

  • Terraform registers the shape into <name>-base. Nothing points at it.
  • evolve-deploy derives the running family from it and registers that.
resource "aws_ecs_task_definition" "purchase_base" {
family = "purchase-base" # the tool's default is <name>-base
# cpu, memory, probes, log config, sidecars — everything but the version
container_definitions = jsonencode([...])
}
resource "aws_ecs_service" "purchase" {
name = "purchase"
cluster = aws_ecs_cluster.platform.id
task_definition = aws_ecs_task_definition.purchase_base.arn # bootstrap only
lifecycle { ignore_changes = [task_definition] }
}

A memory change in Terraform then lands on the next deploy, and Terraform can never roll the image back.

Override the family name with base: on the service or target if <name>-base is not your convention.

The tool reads what you declared and refuses when it is not there. It never creates it.

Set revision_mode = "Multiple" and bootstrap the traffic block, then let go of it:

resource "azurerm_container_app" "site" {
revision_mode = "Multiple"
ingress {
traffic_weight {
label = "blue"
percentage = 100
latest_revision = true
}
}
lifecycle {
ignore_changes = [
template[0].container[0].image,
ingress[0].traffic_weight, # the tool owns this from now on
]
}
}

No revision mode to switch on — several revisions with a split are always allowed. Bootstrap the traffic block with a tag on the side that serves:

resource "google_cloud_run_v2_service" "site" {
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
tag = "blue"
percent = 100
}
lifecycle {
ignore_changes = [
template[0].containers[0].image,
traffic,
]
}
}

keep_warm is refused on Cloud Run: keeping a revision warm is scaling.min_instance_count on the template, which is yours.

ECS owns its own blue/green engine, so what it needs declared is more: an alternate target group, a production listener rule, a test listener rule and a role, all on the service, plus deployment_controller { type = "ECS" }.

resource "aws_ecs_service" "site" {
deployment_controller { type = "ECS" }
# advanced_configuration: alternate_target_group_arn,
# production_listener_rule, test_listener_rule, role_arn
}

The tool reads them off the service and refuses when one is missing — it never writes them. And because a listener rule is not an address, the target needs test_url written down.

On Azure a secret must be declared on the resource — Key Vault URL and the identity allowed to read it — and then referred to by name. A ${secret:ctp-secret} naming something Terraform never declared fails the plan rather than producing a revision that cannot start:

resource "azurerm_container_app" "purchase" {
secret {
name = "ctp-client-secret"
key_vault_secret_id = azurerm_key_vault_secret.ctp.versionless_id
identity = azurerm_user_assigned_identity.purchase.id
}
}

See References and secrets for what ${secret:} names on each cloud — it differs, and it has to.

envFrom expands a JSON object out of a parameter store — never a secret store, so bulk expansion can never mean reading a secret. Terraform writes it with jsonencode:

resource "azurerm_app_configuration_key" "discover_setup" {
configuration_store_id = azurerm_app_configuration.main.id
key = "/evolve/${var.env}/discover/setup"
value = jsonencode(local.discover_env)
content_type = "application/json"
}

Cpu, memory, probes, scaling, networking, IAM, load balancers, queues and event source mappings. The tool’s contract is one sentence:

I set the image and the environment on the running resource, and leave everything else alone.