TerraformCLI icon
Docker icon
Log icon
If icon
Pause icon
Fail icon
SlackIncomingWebhook icon
Schedule icon

Replace a Terraform resource that plan says is fine

Terraform plan says No changes while the resource is dead. Probe it, plan a replace, apply behind approval, then re-probe.

Categories
Infrastructure

Findings

The flow emits one verdict per run: the HTTP status the health probe got, the terraform plan detailed exit code, the container id recorded in state and, after a replacement, the new container id. A degraded verdict next to a plan exit code of 0 is the case no drift detector reports.

Read them as {{ outputs.probe_and_plan.vars.health_status }}, {{ outputs.probe_and_plan.vars.plan_exit }} and {{ outputs.probe_and_plan.vars.container_id }}, with the same three names on outputs.reverify.vars after a replacement. The raw text stays on the execution as files: {{ outputs.probe_and_plan.outputFiles['plan.txt'] }}, {{ outputs.plan_replacement.outputFiles['replace_plan.txt'] }} and the binary plan at {{ outputs.plan_replacement.outputFiles['replace.tfplan'] }}.

Safety

Read-only by default. dry_run is true and auto_remediate is false, so a plain run probes the resource, saves the replacement plan and stops. The apply needs auto_remediate true, dry_run false and a human answering approved: true on the paused execution. An unanswered pause changes nothing.

Point docker_host at the remote daemon that owns the resource. Never at the daemon running Kestra. terraform apply -replace destroys before it recreates, so a provider aimed at your own host would churn Kestra and every other container beside it.

How it works

A resource can match its configuration byte for byte and still be useless. A container is RUNNING, so the Docker provider reads back the same name, the same image and the same ports the state file records. terraform plan prints "No changes. Your infrastructure matches the configuration." The process inside it died twenty minutes ago.

  1. probe_and_plan runs one Terraform container that does both reads. It fetches the health URL over HTTP, then runs terraform plan -detailed-exitcode. The probe status and the plan exit code come back as task outputs.
  2. report_finding logs both numbers side by side, so the paradox is in the run log rather than in somebody's head.
  3. plan_replacement runs only when the probe failed. It produces terraform plan -replace=<address> and saves the binary plan, so the change a human reviews is the change that gets applied.
  4. approve_replacement pauses. apply_replacement applies the saved plan behind that approval.
  5. reverify probes the health URL again and re-runs plan. fail_when_still_degraded fails the run when the probe is still bad, because a rebuilt resource that is still broken means the fault was never in the resource.

The Terraform project is declared once in probe_and_plan then travels to the later tasks as a task output file. State lives in Postgres through Terraform's built-in pg backend, so every task in the flow reads and writes the same state and the saved plan stays valid across the pause.

What you get

  • A detector for the failure mode drift detection is blind to: config matches state, resource is wedged.
  • The replacement shown as a saved plan before anything is destroyed.
  • terraform apply -replace run against a reviewed plan, never a fresh one.
  • A re-probe that fails the run when the rebuild did not help.
  • A Slack alert naming the resource and the daemon when a stage fails.

Who it's for

Platform and SRE teams who manage long-lived resources with Terraform and have been paged for something plan swore was fine. Anyone running containers, VMs or managed instances where liveness is not visible in the provider's read.

Why orchestrate this with Kestra

The pieces are a probe, a plan, an approval and a second probe, with a destructive step in the middle. A shell script can run the commands. It cannot hold a plan file across a one hour human approval. It cannot keep the state backend credentials off the command line or record both reads as outputs an operator can inspect weeks later. It cannot fail the run on the re-probe instead of on the apply. Kestra gives the pause, the typed inputs that keep the default read-only, the task outputs and the failure branch.

Tested end to end

Run on Kestra 2.0.5 with plugin-terraform 1.2.10, Terraform 1.9.8 and the kreuzwerker/docker provider 3.9.0 against a Docker-in-Docker daemon and a Postgres state backend.

  • Healthy resource: probe 200, plan exit 0, zero findings, nothing planned.
  • Wedged resource: the app process killed inside a container left RUNNING. Probe 0 (connection reset), plan exit 0 with "No changes. Your infrastructure matches the configuration." The detector fired on the same inputs that returned nothing a minute earlier.
  • dry_run: true: the replacement plan was saved and logged (1 to add, 1 to destroy), nothing applied, container id unchanged.
  • auto_remediate: true, dry_run: false, approved: the container was replaced and the re-probe returned 200.
  • Pause declined: no apply, container id unchanged.
  • Re-verify failure: a health URL the replacement cannot satisfy failed the run on the re-probe, not on the apply.
  • Failure path: a wrong state backend endpoint failed terraform init and the errors block posted to Slack.

Prerequisites

Secrets

  • POSTGRES_USERNAME, POSTGRES_PASSWORD: the Terraform state backend.
  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.

Inputs

  • dry_run (BOOL, default true): report only.
  • auto_remediate (BOOL, default false): allow the apply.
  • docker_host: the remote Docker daemon the provider manages.
  • health_url, expected_status, probe_timeout_seconds: the probe.
  • resource_address: what -replace targets.
  • container_name, app_image: the managed resource.
  • state_pg_endpoint, state_pg_database: the pg state backend.

Quick start

  1. Add the three secrets.
  2. Replace the main.tf in probe_and_plan with your own configuration and backend. Keep an output that exposes the resource id so the before and after values show up in the log.
  3. Set docker_host to the remote daemon, health_url to something your users actually hit, resource_address to the address you would type after -replace.
  4. Run it with the defaults. You get a verdict, a saved replacement plan and no changes.
  5. To remediate, run with auto_remediate: true and dry_run: false, then resume the paused execution with approved: true.
  6. Enable the Schedule trigger once the report-only runs look right.

How to extend

  • Swap the provider. Any resource whose provider read cannot see liveness fits: an EC2 instance that passes its system check with a dead service, an ECS task, a Cloud SQL replica that stopped replicating.
  • Probe something sharper than a health URL. A query that must return rows, a queue depth, a replication lag.
  • Loop over several addresses with core.flow.Loop and concurrencyLimit: 1, so two replacements never race on one state lock.
  • Replace the Slack task with PagerDuty or Opsgenie for the on-call path.
  • Drop plan_replacement and apply -replace directly when you trust the detector enough to skip the review step.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.