Skip to content

VOL 07 / CH 10 / LESSON 01

10.1 IaC: State, Dependency Graphs, and Safe Changes ​

The platform runs on Kubernetes, but its underlying virtual machines, load balancers, DNS records, and databases were all created manually.

The value of Infrastructure as Code (IaC) isn’t converting console clicks into a long block of HCL. It’s establishing a repeatable protocol for change: configurations describe the desired state, tools read the actual state, compute the difference, and then present a plan for review before execution.

This lesson uses Terraform’s semantics as a foundation. OpenTofu’s core configuration, state, and workflow are conceptually similar. Specific providers and versions should still be verified against project lock files and official documentation.

Three States Must Not Be Confused ​

An IaC change involves at least three distinct states:

StateSourcePurpose
Configuration stateHCL in GitRepresents what the team intends to have
Record stateterraform.tfstate or a remote backendBinds resource addresses to actual objects in the environment
Real stateCloud APIs, Kubernetes APIs, etc.Reflects what actually exists at this moment

Terraform's state is not a cache of resource inventories. It stores mappings between aws_instance.api[0] and actual cloud instance IDs, and may also include resource attributes and sensitive values. Deleting the state does not automatically delete cloud resources; it removes the tool's ability to track or manage them.

Therefore, do not commit state to Git, and do not edit state JSON by hand. In production environments, use a remote backend that supports access control, encryption, version recovery, and locking, and treat state as a sensitive asset that should be backed up and audited.

Configuration Forms a Dependency Graph ​

This fragment belongs in a project with an AWS provider, region, version constraints, and authorized credentials already configured. It is not a complete deployable project. Resource references create dependency edges independently of file order:

hcl
resource "aws_vpc" "main" {
  cidr_block = "10.20.0.0/16"
}

resource "aws_subnet" "app" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.20.10.0/24"
}

aws_subnet.app reads the VPC ID, so Terraform can infer the creation order. Only consider depends_on when tools cannot derive constraints from data flow; overusing explicit dependencies expands the propagation of unknowns, making plans more conservative and harder to read.

Plan is a context-aware snapshot ​

First generate and inspect the plan in the relevant project directory:

bash
terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform show tfplan

Run terraform apply tfplan only after review. Passing a saved plan counts as approval: the command does not ask again. Check the execution environment, plan provenance, and creation time.

validate checks only the internal validity of the configuration; it does not prove that permissions, quotas, or remote APIs will allow execution. plan typically refreshes managed objects and compares the configuration against the current state and remote results; it is also not a permanent guarantee. After a plan is generated, if credentials, quotas, external objects, or provider behavior change, an apply may still fail.

In CI environments, terraform plan -detailed-exitcode can distinguish between results:

  • 0: Success with no differences;
  • 1: Execution error;
  • 2: Success with differences present.

Exit code 2 means changes are present, not that execution failed; CI scripts using set -e must handle it explicitly. Saved plans retain sensitive values even when terminal output masks them, so the files must not become public artifacts.

Remote State and Locks Address Different Problems ​

Remote backends allow collaborators to read the same up-to-date state; state locks prevent two write operations from simultaneously modifying the same state. Both are necessary, but locks cannot replace code reviews, nor can they prevent someone from bypassing IaC and directly modifying resources via the console.

The following native S3 locking configuration requires Terraform 1.10 or later. The bucket must exist before init; this uninitialized configuration cannot create its own backend first. Replace the example name and region with resources your team owns:

hcl
terraform {
  backend "s3" {
    bucket       = "atlas-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "ap-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

The S3 bucket should also enable versioning, and the state and .tflock should each be granted the minimum required permissions. The previously common dynamodb_table lock is being deprecated and should not be used as the default solution for new projects.

For a lock error, investigate the holder and confirm that no apply is still running locally, in CI, or on another execution host before checking the backend state. force-unlock is an incident response tool, not a daily "just unlock it" workaround.

Modules Express Capabilities Rather Than Wrap Every Resource ​

A reusable module typically includes:

text
modules/service-network/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
└── README.md

A well-designed module expresses "a service network with a private subnet and essential routing," rather than mechanically wrapping an aws_subnet resource in another layer. Inputs should define types, descriptions, and required validation; outputs should only expose the actual contracts needed by the caller. Constrain provider versions and commit .terraform.lock.hcl, which records selected provider versions and checksums. This file does not lock modules; pin remote modules separately to exact versions or source commits.

Avoid using a single large state to manage all company resources. The state boundary directly determines fault scope, permission boundaries, and concurrency capacity. Common partitioning criteria are environment, lifecycle, and team ownership, rather than slicing resources by type alone.

Import, Move, and Delete Are Three Distinct Operations ​

To bring an existing resource under IaC, write its target resource configuration before declaring the import. This fragment assumes aws_s3_bucket.audit is declared in the project and the existing bucket belongs to your team:

hcl
import {
  to = aws_s3_bucket.audit
  id = "atlas-prod-audit"
}

When renaming or migrating a module's address, use the moved block to record the address change, preventing Terraform from misinterpreting a "rename" as a delete-and-create operation. This example assumes the target resource is declared in the new module and the old address exists in state:

hcl
moved {
  from = aws_s3_bucket.logs
  to   = module.audit.aws_s3_bucket.this
}

state rm makes the tool forget an object without deleting it remotely. If its configuration remains, the next plan may attempt to recreate it and collide with the existing name. destroy or configuration removal can delete the real object. prevent_destroy works only while the resource configuration remains: deleting the whole block removes that protection too. Databases, keys, and state buckets also need service-side deletion protection, backups, and appropriate approval.

Drift Requires Categorized Handling ​

When the console performs hot fixes or external controllers modify resources, the next planned run will detect drift. The appropriate response depends on ownership:

  1. Unauthorized drift: Allow IaC to restore the declared state and trace the permission path that bypassed controls;
  2. Reasonable emergency hot fixes: Restore service first, then propagate the actual changes back into the configuration;
  3. Fields owned by external controllers: Reassign ownership, and where necessary, use ignore_changes for precise attributes;
  4. Update the records only: Review and apply a plan -refresh-only plan. This updates state, not HCL. To accept the change permanently, update the configuration too; otherwise a later normal plan may try to revert it.

A broad ignore_changes = all skips attribute updates but still permits creation and deletion. It provides neither ongoing attribute management nor deletion protection.

An Auditable Production Change Chain ​

text
Requirements and Risk Documentation
  → Modify configurations and test cases
  → CI format validation, linting, and policy checks
  → Generate a change plan with annotations for deletions, replacements, and expanded permissions
  → Approval by code owners and environment owners
  → Execute the same approved plan artifact
  → Validate service behavior and cost signals
  → Store audit records

IaC governs the resource lifecycle. The next lesson shifts application deployment to a continuous reconciler, enabling Git-based desired states to be repeatedly converged rather than executed just once.

References ​

Built with VitePress | Software Systems Atlas