7.3 Structuring infrastructure code

Checked against HashiCorp's Terraform documentation, August 2026

What this is and why it exists

The first hundred lines of infrastructure code are pleasant. The problem arrives at the third environment and the second engineer, when the question stops being "does this work" and becomes "can somebody else read this, change it safely, and not collide with me while doing it". This lesson is about the shape that survives — modules that earn their keep, state that several people can share, environments that do not diverge, and a way to adopt what already exists without breaking it.

The vocabulary

  • Root module — the directory you run Terraform in; every configuration has one.
  • Module — "a collection of resources that Terraform manages together", callable with inputs and returning outputs.
  • Composition — building an environment by calling modules, rather than by listing every resource.
  • Remote state — state stored somewhere shared rather than on one laptop.
  • State locking — preventing two applies from writing the same state at once.
  • Workspace — several named states from one configuration directory.
  • Import — bringing an already-existing resource under Terraform's management.

The mental model

Modules are functions, and the same judgement applies. The documentation is measured about when to write one: "when you repeatedly provision collections of resources with similar configuration, such as networking resources for new development environments". That is the test — repetition that has actually happened, not repetition you anticipate. A module written for a second use that never arrives is an abstraction over one caller, which is harder to read than the resources it hides and harder to change than either.

So the useful order is: write the resources plainly, notice the second environment needing the same shape, and extract then. And keep modules shallow. A module that calls a module that calls a module means that finding where a setting comes from is an expedition, and the plan output — the thing you must read before every apply — becomes addresses nobody can map to a file. Two levels is usually plenty.

Remote state with locking is not optional past one person, and the reason is worth stating precisely. State is the record of which real object is which resource. Two applies writing it simultaneously do not merely conflict; they can produce a record that matches nothing, after which Terraform will confidently propose destroying real infrastructure it thinks is missing. A backend "defines where Terraform stores its state data files", and the ones worth using support locking so the second apply waits. Configure it on day one, when the state describes nothing much, rather than after it describes production.

Then the environments question, which is the one every team argues about. Workspaces give several states from one configuration directory, which is neat, and the sharp edge is that the code is identical for every workspace, so any real difference between environments becomes a conditional inside the configuration. That reads badly quickly. A directory per environment — the same modules called with different values — is more files and much plainer: you can see what production is by reading production's directory, and staging cannot be changed by accident while editing development. The rule of thumb: workspaces are fine when environments genuinely differ only by variables; directories are better as soon as they differ structurally, and they usually do.

Importing is the last piece, and it is how a project that started in a console joins the fold without a rebuild. The mechanism is an import block that "specifies the unique infrastructure resource ID to import" and "declares an address for the imported resource in state", together with a matching resource block: as the documentation says, "you must create a destination resource block that matches the address declared in the import block". You can have Terraform draft that block for you — write only the import block and "run the terraform plan command with the generate-config-out flag to generate the resource blocks" — but drafting is not the job. The job is making the configuration match reality so exactly that the plan says no changes.

That is the whole safety rule for adoption, and it deserves saying twice: import, then plan, and do not apply until the plan is empty. A plan that proposes changes after an import is telling you the configuration disagrees with the live resource, and applying it will make production match your incomplete file. Take the resources one at a time, get each to an empty plan, and a system that grew by hand becomes one that is described in code — without an outage in the middle.

What you should now be able to explain or do

Say when a module is worth writing and when it is premature, using the documentation's own test. Explain why deep module nesting makes plans hard to read. Say what two simultaneous applies can do to state, and what prevents it. Choose between workspaces and directories per environment, with a reason. Describe importing an existing resource end to end, including the one condition that must hold before you apply.

Check yourself

When you have actually provisioned similar collections of resources more than once — the documentation's example is networking resources for new development environments. Extract from repetition that happened, not repetition you expect.

Finding where a value comes from becomes an expedition, and the plan output — which you must read before every apply — turns into addresses nobody can map back to a file. Depth trades readability for tidiness, badly.

The state can end up describing nothing that exists, after which Terraform proposes destroying real infrastructure it believes is missing. A backend with locking makes the second apply wait instead.

Workspaces when environments differ only by variable values. A directory each as soon as they differ structurally — you can read production by reading its directory, and you cannot change staging while editing development.

No. A non-empty plan means your configuration does not yet match the live resource, and applying would reshape production to fit an incomplete file. Adjust the configuration until the plan is empty, then move to the next resource.

Go deeper

We haven't checked most of these for screen reader use yet.

Back to Structuring infrastructure code: work through the checklist