The File That Says What Exists: Infrastructure and Configuration as Code
An engineer opens a pull request that changes one line of infrastructure code. The plan output says it will destroy and recreate a database. Nobody understands why, so the change is abandoned and the environment stays as it is — which is how a repository stops describing production and becomes a document about it.
The cause is almost never the syntax. It is that three different answers exist to the question “what infrastructure do we have,” and they disagree.
- The declaration — what your code says should exist.
- The record — what the tool believes exists, and which real object corresponds to which declared resource.
- The reality — what the provider actually has, including whatever someone created in the console last Thursday.
Nearly every difficulty in this subject is one of those three drifting from another. What follows is what declaring infrastructure actually commits you to, what the record does and why it deserves operational care, how to review a change, where to draw module and environment boundaries, and why secrets leak through this machinery by default. Terraform and Ansible appear as examples rather than as a comparison; product descriptions follow their documentation, checked in September 2026.
What “declarative” commits you to
Infrastructure as code is defined as using “DevOps methodology and versioning with a descriptive model to define and deploy infrastructure, such as networks, virtual machines, load balancers, and connection topologies,” with an ambition stated as an analogy: “just as the same source code always generates the same binary, an IaC model generates the same environment every time it deploys.”
The problem it was built for has a name. The practice “evolved to solve the problem of environment drift in release pipelines,” where without it “each environment becomes a ‘snowflake,’ a unique configuration that can’t be reproduced automatically.” And the discipline that makes it work is one sentence long: “to make changes, the team edits the source, not the target.” Every console edit is a promise broken to a future reader of the repository.
The mechanism underneath is usually called idempotency — “a deployment command always sets the target environment into the same configuration, regardless of the environment’s starting state.” What is worth noticing is that the published definition names two ways to achieve it: “idempotency is achieved by either automatically configuring the existing target, or by discarding the existing target and recreating a fresh environment.”
Before the fork, though, that quoted sentence claims more than the word normally does, and the difference matters twice over. Idempotency means repeating an operation has the same effect as doing it once. Convergence from an arbitrary starting state means the target ends up matching the definition regardless of where it began. Convergence implies idempotency; idempotency does not imply convergence — and neither implies reversibility. Deleting a resource named by a stable identifier is idempotent, because running that delete again leaves it just as absent — and it is still not undoable. Keep those two questions apart, because they get answered by different things.
Whether an operation is safe to retry depends on whether repeating it can produce a second effect — which is what an idempotent contract rules out, since “an idempotent operation is one where a request can be retransmitted or retried with no additional side effects.” What supplies that contract varies: a caller-supplied request identifier, a natural key the service itself refuses to duplicate, or an operation whose result is the same however many times it lands. So the absence of a request identifier does not by itself make a create unsafe to repeat — you have to know what the API promises.
Whether a result can be undone depends on whether the previous state still exists somewhere, and on what else happened in between. Deleting what you just created does not restore the world if the creation already had effects outside the resource — a notification sent, a record written elsewhere, an identifier handed to another system, a meter started.
Retrying a delete is safe under conditions worth stating rather than assuming: that the target is named by something stable, and not by a name that may since have been reused. A delete aimed at the resource currently called X is a different operation from one aimed at the resource with identifier 4471, and if a later run recreated X, the retry destroys the new one. So the reflex worth having is not “re-run after creates, never after deletes” but four questions: what exactly does this operation target, what does the API promise about repetition, what is the current state, and — separately from all of that — is there a restore path if the answer turns out to be wrong.
Those two routes are the real fork in this subject, and most tool arguments are actually arguments about which one you have chosen.
| Converge in place | Replace | |
|---|---|---|
| What a run does | Inspects the target and changes what does not match | Builds a new instance from a known artifact and swaps it in |
| Typical tooling | Configuration management — Ansible and its peers | Image pipelines plus provisioning |
| Failure mode | State the tool does not know to check stays wrong indefinitely | Anything not captured in the image is lost on replacement |
| Where the truth accumulates | On the host, over time | In the artifact, all at once |
The second column is the immutable approach, where the fleet is kept current by building a new image and replacing hosts rather than repairing each one in place — worked through under golden image. The first is the one people mean by configuration management, and its characteristic weakness is that convergence only covers what the definitions mention. A package installed by hand two years ago is not mentioned anywhere, so no run removes it. That gap between declared and actual is configuration drift, and it is the reason a converging tool needs a detection habit rather than only a correction habit.
Most estates run both, which is fine as long as the boundary is written down: the image owns what every host has, and the configuration layer owns what varies. Where that boundary is unstated, the same setting gets managed in two places and the last run wins.
The record, and why it is an operational asset
A declarative tool cannot work from your code alone, because code does not know which cloud object it created. That is the job of the infrastructure state file. Terraform’s documentation is explicit about both the necessity and the purpose: it “must store state about your workspace’s managed infrastructure and configuration,” using it “to map real world resources to your configuration, keep track of metadata, and to improve performance for large infrastructures.” Its primary purpose is “to store bindings between objects in a remote system and resource instances declared in your configuration.”
So the record is the only thing connecting the declaration to the reality. Lose it and the tool no longer knows that the database in your account is the database in your code — it will offer to create a second one. Corrupt it and it will propose changes based on a false belief about the world.
One boundary before the consequences, because it is easy to read the above as a law of declarative tooling. The record has to exist; a file you own and protect is Terraform’s answer rather than the only one. Azure’s Bicep lists the opposite arrangement as a feature — “no state or state files to manage: Azure stores all states” — and describes its preview operation as one that “checks the current state of your environment and eliminates the need to manage this state.” Everything in this section is about the case where the tool hands you the record, which is worth knowing when you compare tools: where does the binding live, and who is responsible for it decides whether these five consequences are yours.
That is why this file is not source code, even though it lives next to source code and is text. Five consequences follow, and all five are in the documentation rather than being anybody’s opinion.
- Do not put it in version control. The guidance is direct: “avoid storing your state in a version control system or other storage solution that does not support Terraform state locking and secure access control, because doing so can result in data loss or exposure of secrets stored in the state file.” Note that both risks are named — losing it and leaking it.
- Do not hand-edit it. “Terraform stores your workspaces state as a JSON text file. Do not directly edit this file.” It is editable and legible, which is exactly the temptation; the CLI exists so that changes go through something that understands the format.
- Require locking. “If supported by your backend, Terraform will lock your state for all operations that could write state. This prevents others from acquiring the lock and potentially corrupting your state” — and “if state locking fails, Terraform does not continue.” The caveat matters when choosing where to keep it: “not all backends support locking.” Where force-unlock exists, treat it as a last resort — unlocking while someone else holds the lock “could cause multiple writers.”
- Treat it as sensitive. This is the part that surprises people, and it is stated plainly: “Terraform state and plan files contain detailed information about your infrastructure, including resource attributes and metadata that can contain sensitive values, such as initial database passwords or API tokens.” Locally, state is “a plaintext file, which includes any secret values you defined in your configuration.”
- Back it up, and know how you would recover. Storing state locally “risks losing workspace state if the local state file is lost.” A state file with no recovery path is a single point of failure for your ability to change anything.
The practical test of whether a team has internalized this: ask who can read the state file. If the answer is “anyone with repository access,” the answer is also “anyone with repository access can read your database passwords,” and least privilege has a gap in it that no IAM policy will show you.
Reviewing a change to real infrastructure
The distinguishing feature of this kind of code is that you can ask what a change would do before doing it. A plan “creates an execution plan, which lets you preview the changes that Terraform plans to make to your infrastructure,” by reading the current state of existing remote objects, comparing configuration to prior state, and then proposing “a set of change actions that should, if applied, make the remote objects match the configuration.” Critically, “the plan command alone does not actually carry out the proposed changes.”
Its stated use is exactly the review workflow: “check whether the proposed changes match what you expected before you apply the changes or share your changes with your team for broader review” — and the documentation names the review pattern, where “in teams that use a version control and code review workflow for making changes to real infrastructure, developers can use speculative plans to verify” a change. So the reviewable artifact is the plan output, not only the diff. Reviewing a one-line change without its plan is reviewing the intention rather than the effect, which is how the opening scenario happens.
What a plan does not tell you is worth stating, because a green plan reads like safety.
- Whether a replacement is acceptable. The plan says a resource will be destroyed and recreated. Whether that means thirty seconds of downtime or the loss of a volume is domain knowledge the tool does not have.
- How long the intermediate state lasts, and what happens if it fails there. A plan does express ordering — dependencies determine sequence, and a replacement is shown as a destroy and a create whose order you can influence, since by default Terraform “destroys the existing object and then creates a new replacement object” while
create_before_destroyreverses that. What it cannot tell you is the wall-clock window, whether the new object is actually ready when it reports created, or what state you are left in if the run dies midway. Note also thatcreate_before_destroyis “an opt-in behavior” precisely because two objects of the same kind often cannot coexist — so reading the order in the plan is part of judging safety. - Whether anything outside this configuration depends on what is changing. A security group nobody declared may be the only thing letting a legacy job connect.
- Whether the world changed since the plan was made. A plan reflects a refresh at one moment. Two different staleness problems hide here and only one of them has a guard. Terraform records the state it planned against and refuses to apply a saved plan when the stored state has moved on, so that case fails loudly rather than applying silently. Changes made outside the tool are a different matter — they are not in the state, so nothing detects them, and the documentation’s own warning about speculative plans applies just as well to old saved ones: “other changes made to the target system in the meantime might cause the final effect of a configuration change to be different than what an earlier speculative plan indicated, so you should always re-check the final non-speculative plan before applying.” Re-plan when a plan has aged, and treat the built-in check as covering the record rather than the world.
Two habits follow. Make the plan output part of the review, in the pull request, so that the person approving sees the effect rather than the wording — the same review discipline as any other change, with branch and merge mechanics doing the work. And read every destroy line out loud. Destruction is the one action that is irreversible in the way that matters: re-running the pipeline will happily confirm the resource is gone, and nothing in it brings the data back. Retrying a destroy is usually harmless when it names a stable identifier; the thing you cannot do is retract it, and the only preparation that helps is a restore path you have tested. Where a change is significant enough that someone will ask about it in a year, record the reasoning in an architecture decision record rather than in the commit message.
Modules and environment boundaries
Modules are usually introduced as a way to avoid repetition, which is the least interesting reason to have them. But before the criteria, a distinction that the word “boundary” blurs and that decides whether any of this works: a module is a unit of code, not a unit of execution.
Splitting a configuration into child modules under the same root leaves you with one plan, one state, and one apply. The refactor improves readability and reuse and changes nothing about what a single bad run can touch. What separates execution is a separate root configuration with its own state and its own credentials — and then the coupling between them becomes an explicit contract (an output read as an input, a data lookup, a parameter passed in) rather than a shared graph.
So read the three criteria below as reasons to consider a separate root where they bite hardest, and as reasons for child modules where they do not.
- Rate of change. A network that changes twice a year and a service definition that changes weekly are awkward together, because the slow thing is refreshed and evaluated on every fast change and each evaluation is a chance to propose something unintended. Note that moving the network into a child module does not stop this — it is still in the same plan. Only a separate root stops it.
- Ownership. A unit that two teams both need to change is a coordination cost on every change. Boundaries that follow ownership let each team move without waiting.
- Blast radius. Everything sharing one state and one apply can be damaged by one bad run — and “sharing one state and one apply” is the operative phrase rather than “being in the same module.” This is the argument for keeping the database’s definition in a different root configuration from the thing that redeploys most often, with its own credentials so that the fast-moving pipeline could not destroy it even if the plan asked to.
Environments deserve stronger separation than modules do, and the line to hold is that each environment has its own state and its own credentials. A single configuration that can touch staging and production means one mistaken parameter can apply a staging change to production, and the review that would have caught it was reviewing something that looked identical. Separate state also makes the promotion story honest: you are applying the same definitions with different inputs, not running a script that branches on which environment it thinks it is in.
Two related notes. The isolation this buys is the same reasoning as workload isolation and tenant isolation, applied to the tooling that changes things rather than to the things themselves. And the account and organizational structure this sits inside — who may create what, and where the guardrails live — is a separate design covered under landing zone and policy as code.
Secrets leak through this machinery by default
The single most consequential fact in this article is the one quoted earlier: “if you add secret values directly to your configuration, Terraform stores those secrets in its state and plan files.” The leak is not a misconfiguration — it is what happens when you do the obvious thing, and it spreads to wherever plans are archived, which for many teams means a CI log.
The obvious fix — reference the secret instead of writing it — is necessary and not sufficient, and this is the point at which most treatments of the subject stop too early. Two designs get called “referencing a secret” and they differ in exactly the way that matters.
- The runtime resolves the reference. Your configuration passes an identifier — a secret name, an ARN, a path — and the application or service fetches the value itself when it runs. Terraform never holds the value, so it cannot store it.
- Terraform resolves the reference. You read the secret from a store during the run and pass the value to a resource argument. Terraform now holds the value, and by default it goes into state and plan just as a hardcoded one would. The source of the value changed; the exposure did not.
Marking things sensitive does not close the second case either, and the documentation says so directly: “Terraform stores values with the sensitive argument in both state and plan files, and anyone who can access those files can access your sensitive values.” sensitive redacts output — it does not decline to store. The plan command’s own reference repeats the point for plan files: “if your plan includes any sort of sensitive data, even if obscured in Terraform’s terminal output, it will be saved in cleartext in the plan file. You should therefore treat any saved plan files as potentially-sensitive artifacts.”
What does omit values is a separate, newer mechanism: ephemeral values, which “are available at runtime, but Terraform omits them from state and plan files entirely.” It comes with version and provider conditions worth checking before you plan around it — the sensitive argument needs Terraform 0.15 or later, the ephemeral argument and block need 1.10 or later, a write-only argument on a managed resource needs 1.11 or later, and support is not universal because “each provider defines any available ephemeral blocks and write-only arguments on their managed resources.”
So the honest instruction has three parts. Prefer the design where the runtime resolves the reference and Terraform never sees the value. Where Terraform must see it, use ephemeral or write-only paths if your version and provider support them, and otherwise treat state and plan as secret material — access-controlled, encrypted at rest, excluded from logs and from version control. And if secrets have already passed through state, changing the configuration does not unpublish them: rotate the affected credentials and deal with the stored copies, including old plan files and state versions.
With that established, secrets management is the surrounding discipline, and five of its published principles matter most here.
- Centralize, with care about what that means. “You must standardize and centralize the secrets management solution with care” — and realistically, “standardizing and centralizing can mean that you use multiple secret management solutions.” One interface per team, not necessarily one system per company.
- Nobody reads everything. “Engineers should not have access to all secrets in the secrets management system, and the Least Privilege principle should be applied.”
- Take humans out of the path. “Manual maintenance not only increases the risk of leakage; it also introduces the risk of human errors,” so “it is better to limit or remove the human interaction with the actual secrets.”
- Prefer short-lived credentials. “Dynamic secrets should be used where possible to reduce the surface area of credential reuse.” A credential that expires on its own is worth more than a rotation policy nobody executes — but read the expiry mechanism rather than assuming a restart triggers it. In Vault’s model each dynamic secret carries a lease, and Vault “promises that the data will be valid for the given duration, or Time To Live (TTL)”; what invalidates it is expiry or revocation — “when a lease is expired, Vault will automatically revoke that lease,” and an explicit revoke “invalidates that secret immediately.” Restarting your application gets the new instance a new credential; the stolen one remains valid until its lease expires or something revokes it. So the useful number is the TTL, and the useful control is a revoke on the path that handles compromise.
- Plan the whole lifecycle. “Secrets follow a lifecycle. The stages of the lifecycle are as follows: Creation, Rotation, Revocation, Expiration.” Most teams implement creation and hope.
Auditing deserves its own note because the requirement is stronger than usual: the log has to survive an attacker. You “must implement auditing securely to be resilient against attempts to tamper with or delete the audit logs,” recording at minimum who requested a secret and for what role, whether the request was approved, when it was used and by what, expiry, attempts to reuse expired secrets, authorization errors, and updates. That is a more demanding standard than the general-purpose audit log most systems keep, and it applies to the store rather than to your application.
One operational requirement that gets discovered at the worst time: this store is on your recovery path. In an incident, “having to wait for credentials could impact the responsiveness of the operations team.” A secrets service that is down during an outage extends the outage, so it needs its own availability answer and a documented break-glass route.
And keep environment configuration distinct from secrets. An environment variable holding a hostname is ordinary configuration that belongs in version control; one holding a password is a secret that does not, even though the mechanism is identical. Conflating them is how credentials end up in the same file as endpoints — and detection is worth automating, since “shift-left and DevSecOps principles apply to secrets detection as well,” with the practical tip of standard test secrets to keep false positives manageable.
Four ways this is not application code
Teams that treat an infrastructure repository exactly like an application repository hit the same four surprises, and each follows from something above.
- There is external state you did not write. Application code is a pure input to a build; infrastructure code is one of three sources of truth, and the other two can change without you.
- Some changes are irreversible. Re-running a failed deployment is normal, and re-running a destroy usually just confirms the absence — provided it targets a stable identifier rather than a name something else may now hold. What re-running cannot do is undo, so for anything that removes state the question is not whether to retry but whether a tested restore path exists, and whether anything outside the resource already depended on it.
- Rollback is not redeploying the previous version. Reverting the commit produces a plan that tries to reverse the change, and whether that is possible depends on what was destroyed. Where the reverse plan is not acceptable, the way out is forward — the same judgment as with environment promotion, without the luxury of a retained build artifact to fall back to.
- Secrets are in the working files, not only in the runtime. Application secrets live in the environment a process runs in. Here they end up in state and plan output, which are artifacts of the development process itself.
Which suggests where to start if an estate is in the state the opening scenario describes. Get the record trustworthy first — one state per environment, stored somewhere that locks and controls access, backed up, with secrets referenced rather than embedded. Then make plans part of review, so the repository starts describing production again. Module structure and tooling choices are worth arguing about afterwards; they are improvements to a system that works, and no substitute for one.
References: Microsoft Learn, What is infrastructure as code (IaC)?; Malcolm Featonby, Making retries safe with idempotent APIs (Amazon Builders’ Library); Terraform documentation, State; Terraform documentation, State Locking; Terraform documentation, Manage sensitive data in your configuration; Terraform documentation, terraform plan; OWASP, Secrets Management Cheat Sheet; Vault documentation, Lease, renew, and revoke; Terraform documentation, lifecycle meta-argument; Terraform documentation, Modules; Microsoft Learn, What is Bicep?. All checked September 2026; product documentation changes, and the Terraform version requirements above are stated as of that reading.
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
