Skip to main contentUkasyah Nata Dewastoro
Cloud

Stage 6: Bringing a hand-built production estate under Terraform

The real thing. A regulated company runs production on cloud infrastructure that was clicked together over years, and nobody can rebuild it. I imported it into Terraform without recreating a single resource, behind a pipeline where no change reaches a cloud project without a review, a gated plan and an explicit apply.

Zero-downtime imports
Role
Solo engineer; Terraform and the delivery pipeline
Scale
2 cloud projects, ~500 resources
Stack
Terraform, Google Cloud, Cloud Run
Targets
Import, never recreate; no keys; every apply reviewed

Context

Stages 1 to 5 built platforms from nothing. This stage starts from the opposite problem: a regulated company whose production already runs on cloud infrastructure built by hand in the console over several years. Nothing was written down as code. The written inventory listed about half of what actually existed, and some of the rest was attached to nothing at all.

I had about ten weeks as the only engineer on it, with read-only access to start, no access to personal data, and one hard rule: import what exists, never recreate it. Every write to production needed approval from the company's infrastructure lead.

Constraints:

  • Import only: recreating any resource was ruled out
  • State must stay in the company's own region and buckets
  • No service account keys anywhere
  • Every production change approved by the company

Architecture and failover

Each cloud project is its own Terraform root with its own state bucket, versioned and locked down, so staging and production can never share a plan. Shared building blocks live in modules: foundation (network, load balancer), stateful services, serverless apps and data. Resources are declared as map entries, IAM grants are additive only, and every adoption is an import block in code.

The pipeline is the part that keeps production safe. A pull request runs static checks, then a plan per project with a read-only identity. A scripted gate reads each plan before any person does. Nothing applies on merge: an engineer dispatches the apply workflow by hand, and it applies only if the review, the plan fingerprint and an explicit apply comment all line up.

From a pull request to a cloud project
  1. 1
    GateStatic checks

    terraform fmt and validate, tflint, Trivy config scan against a baseline, gitleaks, actionlint, unit tests for the gate.

    If it fails: The pull request cannot merge.

  2. 2
    Plan per project

    Read-only identity through Workload Identity Federation. It cannot read stored files.

  3. 3
    GatePlan gate

    Every import covered, no create, delete or replace without a label, sensitive values redacted, plan fingerprinted.

    If it fails: The pull request is blocked with the exact resource that failed.

  4. 4
    GateReview and merge

    A reviewer approves the change and the plan it produced.

  5. 5
    GateDispatch the apply workflow

    Checks the approval and that the plan fingerprint still matches.

    If it fails: The apply is refused; nothing touches the cloud.

  6. 6
    GateComment /apply with the run ID

    A person releases this exact run.

  7. 7
    DeployApply and tag

    The apply identity runs only from main, then tags the environment and time.

    If it fails: A revert branch is created for the failed run.

A nightly drift check plans every project against the live cloud and opens an issue when someone has changed something outside Terraform.

Try it: merge a change without a review, lose an import silently, or watch the gate stop a replacement.

Reviewed Terraform pipeline for a live production estate topologyA pull request runs static checks, then a plan per cloud project using a read-only identity. A plan gate checks import coverage and blocks create, delete and replace. A manually dispatched apply workflow checks the review, the plan fingerprint and an explicit apply comment before it changes anything.Pull request and workflowsCloud projectsstateapplycomparePull requestTerraform changeStatic checksfmt, tflint, trivyPlan per projectread-only identityApply workflowmanual dispatchReviewapproval + /applyPlan gateimports, no replaceNightly drift checkplan vs liveState bucketsone per projectStaging188 resources managedProductionimports planned
  • Primary / serving
  • Recovering
  • Degraded
  • Down
  • Replication / control

Pick a failure to see how the system detects and recovers from it.

Security audit

Terraform makes infrastructure reviewable, but it does not make it safe. Alongside the import, I audited both projects and turned the result into a register the company could work through.

The audit had two lenses. Trivy scans the Terraform code on every pull request, against a baseline of known findings, so nothing new can be merged without someone seeing it. But Trivy only sees what has been imported. For everything else, I ran a scripted, read-only pass over each live project: a dump script exported configuration with a viewer identity, never secret values, stored files or database rows, and an analysis script scored that dump against all 69 controls of the CIS Google Cloud Foundations Benchmark v4.0.

How each project was audited
  1. 1
    Dump configuration, read-only

    A viewer identity exports IAM, network, compute, storage, database and logging settings to a private temporary folder. No secrets, objects or rows.

  2. 2
    Score against CIS v4.0

    A script checks the dump against all 69 controls and records pass, fail, partial, not applicable or unknown for each.

  3. 3
    GateScan the Terraform code

    Trivy checks every imported resource; known findings sit in a baseline so new ones fail CI.

  4. 4
    Rate and map each finding

    Critical, high, medium or low, mapped to CIS, ISO 27001:2022 Annex A and the local data protection law.

  5. 5
    Build the register

    A generated spreadsheet with an owner, status and fix for every finding, plus a separate register for plaintext settings that should be secrets.

  6. 6
    DeployOrder the fixes

    Each fix becomes its own reviewed pull request through the same Terraform pipeline.

    If it fails: A fix that would break a service waits for an owner decision instead of shipping.

  7. 7
    Delete the dump

    The temporary folder is removed once the register is built.

Every CIS control ended with a recorded result, including the ones I could not check. Those are listed by name with the reason, so the company knows where the audit stopped instead of assuming it covered everything.

The findings themselves stay with the company. What the page can say is the shape of them: the largest group was over-broad identity and access grants, followed by services reachable from the internet that did not need to be, settings that should have been secrets, and logging that was too thin to investigate an incident.

69CIS v4.0 controls scored per project, every one with a recorded result
65findings across staging (37) and production (28), each rated and mapped
10 / 69CIS controls passing at the start, in both projects
0secret values, stored files or data rows read during the audit

The audit also changed the import plan. Importing a service before moving its plaintext settings into the secret store would have copied those values into Terraform state, so the settings move came first and those services were deliberately left out of the import until then.

Key decisions

Two lenses on the same estate: the code and the live cloud

  • security
  • audit

A scanner on Terraform code only sees what has been imported, and most risk in a hand-built estate lives in what has not.

  • Scan the Terraform code onlyCheap and automatic, but blind to everything not yet imported.
  • Buy a commercial cloud posture toolBroad coverage, but a new vendor and data leaving the company for a ten-week engagement.
  • Trivy on the code plus a scripted read-only pass over the live projectsChosenTrivy guards every pull request; the live pass dumps configuration only, never data, and scores it against the full CIS benchmark.

Trade-off: The live pass needs its own tooling and a read-only identity, and some checks stayed unknown where that identity or the local tools could not reach.

Lifecycle

  1. Write the resource in code to match what is live, plus an import block for it.
  2. Open a pull request. The plan must show an import and zero changes.
  3. The gate confirms every import block appears in the plan.
  4. After review, apply. Terraform now owns the resource without having touched it.

Failure modes

FailureBlast radiusDetectionRecovery
Change merged without reviewNone: the apply refuses itApproval check in the apply workflowRevert and resubmit for review
Provider drops an import silentlyOne resource left unmanagedImport coverage check in the gateFix the import address and re-plan
Plan proposes a replaceWould interrupt every dependent serviceGate blocks unlabelled replacesMatch the code to what the provider reads back
Someone edits the cloud by handCode and reality divergeNightly drift issueWrite it into code, or revert it
Apply fails partwayOne projectWorkflow failureRevert branch created for that run
Tag step fails after a good applyApply succeeded but is not recordedWorkflow failureFixed the shell step; tag now runs safely

Results

188staging resources imported and applied, none recreated
256 / 0production imports planned, with zero changes
0service account keys in the pipeline
95automated tests on the gate and apply workflow

Production was enabled in the pipeline, with its imports planned and reviewed, at the end of this stage. The security audit above was delivered as a register, with the critical and high findings ordered to be fixed one reviewed pull request at a time through the same pipeline.

What I'd change

I would count imports from the first day instead of trusting plan output. The provider dropped imports without an error, and I only built the coverage check after I found one missing.

I would agree with the company up front on exactly what Terraform owns. Partway through, the company asked to stop managing some application settings in Terraform, and that cost a round of rework.

I would keep resource counts in one generated report. Three documents ended up disagreeing about how many resources each project had, which undermines a project whose whole point is an accurate inventory.

I would run the live audit before writing any Terraform. It found settings that changed the import order, and finding them earlier would have saved re-planning.

I would test the tag and rollback steps before the first real apply. The first successful apply failed at its tagging step, after the infrastructure had already changed.

That closes the journey: from two servers built by hand, to a live production estate where nothing changes without a reviewed plan.