Declare One Resource and Apply It

In Module 8 the Lending Club default scorer went live: a host, a network, a TLS certificate, a rollback path, a stated cost. The service answered at a real URL and a stranger could reach it. But none of that infrastructure was written down. The host existed because someone clicked a console or ran a one-line deploy, and the only record of what it was (its region, its size, the firewall rule that let traffic in) lived in the console and in the memory of whoever provisioned it.

That gap does not hurt at one host. The person who clicked it can usually find it again. The gap is fatal at a dozen, where the only record of what the system is lives scattered across consoles and people’s heads, and there is nothing to diff, review, or rebuild. A clicked resource is reproducible by nobody; a declared one is reproducible by construction. This module closes that gap the way Docker closed it for the application: the infrastructure becomes code that is declarative, versioned, and reviewable. This first lesson makes the idea tangible on one small resource. Write a config that describes it, run one command to preview the change, run one to apply it, and watch the tool create exactly what the file says. The worked-example tool is Terraform, but the subject is the principle, not the syntax. The next three lessons build on it: why the clicked alternative is the failure this replaces (why-not-click-ops), where the line between application and infrastructure falls (app-vs-infra), and what the tool’s memory of the world can get wrong (state-drift-and-danger).

In Module 8 the Lending Club default scorer went live: a host, a network, a TLS certificate, a rollback path, a stated cost. The service answered at a real URL and a stranger could reach it. But none of that infrastructure was written down. The host existed because someone clicked a console or ran a one-line deploy, and the only record of what it was (its region, its size, the firewall rule that let traffic in) lived in the console and in the memory of whoever provisioned it.

That gap does not hurt at one host. The person who clicked it can usually find it again. The gap is fatal at a dozen, where the only record of what the system is lives scattered across consoles and people’s heads, and there is nothing to diff, review, or rebuild. A clicked resource is reproducible by nobody; a declared one is reproducible by construction. This module closes that gap the way Docker closed it for the application: the infrastructure becomes code that is declarative, versioned, and reviewable. This first lesson makes the idea tangible on one small resource. Write a config that describes it, run one command to preview the change, run one to apply it, and watch the tool create exactly what the file says. The worked-example tool is Terraform, but the subject is the principle, not the syntax. The next three lessons build on it: why the clicked alternative is the failure this replaces (why-not-click-ops), where the line between application and infrastructure falls (app-vs-infra), and what the tool’s memory of the world can get wrong (state-drift-and-danger).

Declarative configuration is a description of desired end state, not a script of steps

The intuitive way to read an infrastructure config is as a script: a list of steps the tool performs top to bottom, the way a shell script runs aws s3 mb, then a call to set encryption, then the next command. Under that mental model the config is a recording of clicks, and running it twice is dangerous, because the second run would try to create what the first run already made. A competent engineer holds this model because every tool they learned first was imperative: a console click, a curl, a deploy script. So they wrap the config in guard logic, an existence check before every create, exactly as they would harden a shell script.

Watch that model break. Here is the imperative version of “make sure one bucket exists,” written the way the script mental model would write it:

import boto3

s3 = boto3.client("s3")


def ensure_bucket(name: str) -> None:
    # The script model: do the steps, and guard each one so a second run
    # does not blow up on "already exists".
    existing = [b["Name"] for b in s3.list_buckets()["Buckets"]]
    if name not in existing:                       # <-- the existence check
        s3.create_bucket(Bucket=name)
    s3.put_bucket_encryption(Bucket=name)          # and a guard for every setting
    s3.put_public_access_block(Bucket=name)


ensure_bucket("lc-scorer-artifacts")              # safe to re-run ONLY because
ensure_bucket("lc-scorer-artifacts")              # we wrote the if-check by hand

The re-runnable safety in that code is something the author built by hand: the if name not in existing check. Forget it, and the second call raises BucketAlreadyOwnedByYou. Every setting needs its own guard. The author owns the sequence and owns knowing what already happened, and the correctness of “run it twice” is theirs to get right line by line.

The declarative model inverts this. Infrastructure-as-code means describing the resources a system runs on (servers, networks, databases, the deployed service itself) as version-controlled configuration, and handing a tool the job of making reality match. The load-bearing word is declarative: the config states the desired end state (this bucket, this region, this encryption setting), not the sequence of calls to reach it. The tool computes the difference between what is declared and what already exists, and carries out only that difference. The same description is therefore safe to apply repeatedly. Applying “this bucket should exist” when it already exists is a no-op, not a duplicate, because the tool reconciles against reality rather than re-running steps blindly. Apply N times, get the same result as applying once: that property is idempotence, and it is the reason a declarative config can be re-run, reviewed as a single artifact, and trusted as the record of what exists. The author writes no existence check; the tool takes care of that when it applies the code.

Idempotence is precise, not a vibe. An action is idempotent when its result is identical whether it runs once or many times. The assignment X = 5 is idempotent: run it any number of times and X is 5. The statement X = X + 1 is not, because each run changes the result. Declarative config maps to the first: “this bucket should exist with these settings” is X = 5 for infrastructure. The imperative script maps to the second unless guards are bolted on to make it converge.

The piece that makes a generic description executable is a provider: a plugin that knows how to translate a declared resource into the specific API calls one cloud needs (create the object, read its current settings, update it, delete it) and how to map the cloud’s real fields back onto the config so the tool can tell when they differ. Providers are thin wrappers over the cloud’s own API; Terraform’s aws_s3_bucket resource maps onto the same S3 API calls the boto3 code above made by hand. Each declared resource binds a local name (used only inside the config, to reference this resource from others) to a real cloud object the provider manages. The settings inside are the desired end state for that object’s fields. The smallest real config is one provider block and one resource block, and reads like a fact, not a procedure:

# main.tf (Terraform / HCL, not Python; shown for shape only)
provider "aws" {
  region = "us-east-1"                    # which cloud, where
}

resource "aws_s3_bucket" "artifacts" {    # ONE resource: a type + a LOCAL name
  bucket = "lc-scorer-artifacts"          # the desired end state of its fields
}
# workflow:  terraform plan  -> shows what would change
#            terraform apply -> makes reality match

There is no if, no create, no ordering. The block asserts a fact about the world, and the tool’s job is to make the fact true. The local name artifacts is internal bookkeeping; it lets another block refer to this bucket, and it is not the bucket’s real name (bucket = "lc-scorer-artifacts" is). That separation matters once configs reference each other, which is where the later lessons go.

The non-obvious cost, what declarative gives up to get idempotence. Declarative does not mean the tool always knows the cheapest path or the right order. It means the author surrenders owning the steps, and that surrender is the cost as much as the benefit. Two consequences a beginner trips on, both born of the same root, that the tool, not the author, decides what happens:

First, idempotence is the provider’s promise, not a language guarantee. The config is declarative, but whether re-applying is truly a no-op depends on the provider correctly reading the resource’s current state and recognizing that it already matches. A provider with an incomplete read can report a phantom diff (it believes a field changed because it cannot see the real value), so “apply twice equals apply once” holds only as far as the provider’s reconciliation is honest. The symptom is a config nobody edited that nonetheless plans a one-line change every single run; the root cause is the provider’s read, not the file. The boundary it violates is the silent assumption that “declarative” lives in the language. It does not; it lives in the provider’s read of reality.

Second, and more dangerous in practice: a field left out of the config is not “unmanaged”; it is “declared as default.” Once a resource is in the config, the tool owns it. An omitted setting means “I want the provider’s default for this field,” not “leave whatever is there alone.” Believing omission means “do not touch” is the first way people reset a value they cared about. Consider the bucket above: someone enabled versioning by hand in the console after the config was written, and the config never mentions versioning. On the next apply the tool reads “versioning unset, so use the provider default, which is disabled” and quietly turns it off, because the config, not the live value, wins for a managed resource. The fix is not to remember every field; it is to read the plan before applying, which is the next section.

A preview is a deterministic diff over config, recorded state, and reality

The natural prediction, once the declarative model clicks, is that applying a config edit “saves the change”: config in, that one field updated, the way saving a file writes exactly what was typed. Change region from us-east-1 to us-west-2, run apply, and the region updates. Edit equals outcome.

That prediction is wrong in a way that matters, and the reason is the whole skill of this module. The same edited config produces different changes depending on what currently exists in the cloud, and the execution carries out only what the preview computed, not what the author intended. One config line can resolve to a create on a fresh environment, an update-in-place on an existing one, a no-op where reality already matches, or a destroy where the cloud has drifted underneath. The config names the destination; it does not name what the trip costs. Only the diff against current reality does that.

In Terraform the preview command is plan and the execution command is apply. A plan is not a dry-run that “pretends to execute the steps.” It is a deterministic diff computed over exactly three inputs, and apply runs only that diff. The three inputs are the declared desired state (the config), the tool’s recorded state (its stored memory of the resources it built last time and their last-known field values), and a fresh refresh in which the provider reads the current real values from the cloud. The diff is whatever it takes to move from state-plus-reality to the config. Because the inputs determine the diff, the same config edit resolves to different changes against different realities, and the only honest answer to “what will apply do” is the diff itself, read before it runs. The scrolly traces the three inputs flowing into the diff and the four outcomes that come out.

The config alone cannot say what changes

The first input is the config: the desired end state the author wrote. On its own it cannot say what will change, because it does not know what already exists. “This bucket should exist” is a destination, not a route.

State is the tool’s memory, and memory goes stale

The second input is state: the tool’s stored record of what it built last time and the field values it last saw. State is memory, and memory can be stale, because the cloud may have moved since the tool last looked.

Refresh reads reality, so the diff is against the world

The third input is a fresh refresh, where the provider reads the real cloud right now. This is why the diff is computed against reality, not against stale memory. Turn the refresh off and the plan can be incomplete or wrong.

plan is a deterministic function of those three inputs

The plan is a deterministic diff over exactly config, state, and refresh. The same config against a different reality yields a different diff. There is no fourth input for “what the author meant”; intent never enters.

Per field, the diff is one of four outcomes

Per resource and per field, the diff resolves to one of four outcomes: create (+, in config, not in state), update in place (~, a mutable field differs), destroy (-, in state, removed from config), or no-op (matches reality). The whole plan is nothing more than this set of deltas.

apply runs the diff, not the intent

apply executes the deltas the plan computed (the +, ~, and - lines), never the author’s separate intent. A change nobody expected is a question to answer before running it, not a surprise to find after.

A real apply cannot run inside this lesson, so the model is shown in Python: a tiny reconciler that takes the same three inputs Terraform takes and emits the same four outcomes. Watch how the desired dict, the state dict, and the reality dict combine, and notice that the desired config is identical across two different realities, yet the diff differs.

python
"""A tiny reconciler that takes the same three inputs Terraform takes.

It emits Terraform's four outcomes per field -- create, update, destroy, no-op --
and apply would run ONLY this list. The desired config is identical across two
different realities, yet the diff differs, because the diff is a function of all
three inputs.
"""

from de_refs import plan_resources


desired = {"region": "us-west-2", "encryption": "AES256"}
state = {"region": "us-east-1", "encryption": "AES256"}

# Reality A: cloud matches what state remembers.
reality_a = {"region": "us-east-1", "encryption": "AES256"}
print("plan against reality A:")
for line in plan_resources(desired, state, reality_a):
    print("  " + line)

# Reality B: SAME desired config, but someone changed encryption in the console.
reality_b = {"region": "us-east-1", "encryption": "aws:kms"}
print("plan against reality B (SAME config):")
for line in plan_resources(desired, state, reality_b):
    print("  " + line)

The desired config never changed between the two plans, yet reality B grows an extra ~ update encryption line: the refresh found a value the config does not want, so the plan proposes pulling it back. The same edit produces a different diff because the diff is a function of all three inputs, and reality B differs from reality A in one field. This is the property that separates infrastructure-as-code from clicking: the change is named, computed, and reviewable as an artifact before any API call fires.

The reason this is the safety mechanism and not a formality: apply executes only the diff the most recent plan computed. The execution plan an apply runs is the same plan plan printed; Terraform will even let you save a plan to a file and apply that exact file. So a diff that has been read is a diff that has been approved, and a + or ~ or - nobody expected is a question to answer before applying, not a surprise to discover after. Reading the plan is the habit that turns infra-as-code from “config instead of clicks” into a real discipline.

The failure mode this concept exists to prevent. Editing a config, eyeballing it, deciding “this is a small change,” and running apply without reading the plan. The plan would have shown an update in place on a field that, on that resource type, actually forces the resource to be replaced, not edited. “I read the code so I know what it does” is exactly the reasoning that fails here: reading the config tells the author intent; only the plan, computed through the provider, tells the author what the provider will actually do. Intent and outcome diverge precisely on the resources where the divergence is most expensive. (The destroy-and-recreate case, a -/+ forced by an immutable field, is state-drift-and-danger’s payoff; this lesson establishes only that there are four outcomes and that apply runs the diff, so the destroy case has somewhere to land.)

The non-obvious constraint: the diff is only as trustworthy as the refresh. Infrastructure as Code (Morris) describes the preview as a command that “compares the desired state generated in the Compile substep with the existing infrastructure resources” — which quietly assumes the refresh read every relevant field accurately. It does not always. Terraform’s own documentation warns that the plan is computed against a refresh, and that disabling that refresh can produce an incomplete or incorrect plan. Applying code can have effects a preview does not reveal: the same book notes that the preview “may not tell you that java_server_image doesn’t exist, something you won’t discover until the apply command fails to create the server.” A clean-looking plan can therefore still be wrong: the diff under-reports change when the refresh missed a field, and over-reports it when the config sets a value the cloud normalizes. The staff move is to read a diff and ask not only “what does it want to do” but “do I trust the inputs it computed this from.” That question is the on-ramp to state-drift-and-danger, where state becomes a fallible model in its own right rather than a trusted memory.


Try It 1

Predict before running. Given the plan reconciler above, a desired config that drops a field entirely, and a reality where that field still exists, what outcome line will appear for the dropped field: +, ~, -, or 0? Fill in the predicted symbol, then run to check.

python
"""Try It 1 starter: predict the outcome line for a field dropped from the config.

Given the plan reconciler, a desired config that drops a field entirely, and a
reality where that field still exists, what outcome line appears for the dropped
field -- +, ~, -, or 0? Fill in the predicted symbol, then run to check.
"""


def plan(
    desired: dict[str, str], state: dict[str, str], reality: dict[str, str]
) -> list[str]:
    diff: list[str] = []
    for f in sorted(set(desired) | set(state) | set(reality)):
        want = desired.get(f)
        have = reality.get(f)
        if want is not None and f not in state:
            diff.append("+ create   " + f + " = " + want)
        elif want is None and f in state:
            diff.append("- destroy  " + f + " (was " + str(have) + ")")
        elif want is not None and want != have:
            diff.append("~ update   " + f + ": " + str(have) + " -> " + want)
        else:
            diff.append("0 no-op    " + f + " = " + str(have))
    return diff


# 'versioning' was managed before, now removed from the config entirely.
desired = {"region": "us-east-1"}
state = {"region": "us-east-1", "versioning": "Enabled"}
reality = {"region": "us-east-1", "versioning": "Enabled"}

predicted_symbol = "?"  # one of: +  ~  -  0
print("I predict:", predicted_symbol)
for line in plan(desired, state, reality):
    print("  " + line)
Hint The field is in `state` (the tool built it before) and in `reality` (it still exists), but it is gone from `desired`. Re-read the section on the four outcomes: which one is defined as "in state, removed from config"? That is the exact case where omission does not mean "leave it alone."

Solution

The dropped field resolves to - destroy, because it lives in state but is absent from the desired config. This is the omission failure from the first section made concrete: removing a managed field from the config is an instruction to tear it down, not to ignore it.

python
"""Try It 1 solution: a dropped managed field resolves to a destroy.

The field lives in state and in reality, but is gone from desired -- so the plan
proposes - destroy. Removing a managed field from the config is an instruction to
tear it down, not to ignore it.
"""

from de_refs import plan_resources


desired = {"region": "us-east-1"}
state = {"region": "us-east-1", "versioning": "Enabled"}
reality = {"region": "us-east-1", "versioning": "Enabled"}

predicted_symbol = "-"
print("I predict:", predicted_symbol)
for line in plan_resources(desired, state, reality):
    print("  " + line)

The versioning line comes back as - destroy (was Enabled). Reality still has the field and state remembers it, but the config no longer asks for it, so the plan proposes removing it. Reading this one line before applying is the difference between keeping versioning and silently dropping it.


Try It 2

Modify the reconciler to expose the phantom diff failure from the first section. A provider with an incomplete read cannot see a field’s real value, so it reports None for that field even though the config sets it. Add a refresh_blind set of field names whose real value the provider fails to read; for those fields, treat reality as if it had no value, and watch a no-op turn into a spurious ~ update against an unedited config.

python
"""Try It 2 starter: expose the phantom diff failure.

A provider with an incomplete read cannot see a field's real value, so it reports
None for that field even though the config sets it. Add a refresh_blind set of
field names whose real value the provider fails to read; for those fields, treat
reality as if it had no value, and watch a no-op turn into a spurious ~ update
against an unedited config.
"""


def plan(
    desired: dict[str, str],
    state: dict[str, str],
    reality: dict[str, str],
    refresh_blind: set[str] | None = None,
) -> list[str]:
    refresh_blind = refresh_blind or set()
    diff: list[str] = []
    for f in sorted(set(desired) | set(state) | set(reality)):
        want = desired.get(f)
        # When the provider is blind to a field, its refreshed value reads as None.
        have = reality.get(f)  # TODO: if f in refresh_blind, the read fails -> None
        if want is not None and f not in state:
            diff.append("+ create   " + f + " = " + want)
        elif want is None and f in state:
            diff.append("- destroy  " + f + " (was " + str(have) + ")")
        elif want is not None and want != have:
            diff.append("~ update   " + f + ": " + str(have) + " -> " + want)
        else:
            diff.append("0 no-op    " + f + " = " + str(have))
    return diff


desired = {"region": "us-east-1", "encryption": "AES256"}
state = {"region": "us-east-1", "encryption": "AES256"}
reality = {"region": "us-east-1", "encryption": "AES256"}  # truly matches!

# The provider cannot read 'encryption'. Nothing was edited. What does plan say?
print("honest refresh:")
for line in plan(desired, state, reality):
    print("  " + line)

print("blind to encryption:")
for line in plan(desired, state, reality, refresh_blind={"encryption"}):
    print("  " + line)
Hint The phantom diff comes from the refresh, not the config. The blind field's `have` needs to read as `None` even though `reality` actually holds the right value. Re-read "idempotence is the provider's promise, not a language guarantee": the no-op depends on the provider seeing the real value. What comparison does the `~ update` branch make, and how does a `None` on the left force it true?

Solution

The fix is one line: when a field is in refresh_blind, the refreshed value reads as None regardless of what the cloud actually holds. The honest plan is all no-ops; the blind plan invents a ~ update that will never converge.

python
"""Try It 2 solution: the phantom diff comes from the refresh, not the config.

When a field is in refresh_blind, the refreshed value reads as None regardless of
what the cloud actually holds. The honest plan is all no-ops; the blind plan invents
a ~ update that will never converge -- the signature of a provider whose read is
dishonest. This is why idempotence is the provider's promise, not the language's.
"""

from de_refs import plan_resources


desired = {"region": "us-east-1", "encryption": "AES256"}
state = {"region": "us-east-1", "encryption": "AES256"}
reality = {"region": "us-east-1", "encryption": "AES256"}

print("honest refresh:")
for line in plan_resources(desired, state, reality):
    print("  " + line)

print("blind to encryption:")
for line in plan_resources(desired, state, reality, refresh_blind={"encryption"}):
    print("  " + line)

The honest refresh plans 0 no-op encryption; the blind refresh plans ~ update encryption: None -> AES256. Nobody edited the config, yet a change appears every run: the signature of a provider whose read is dishonest. This is why “apply twice equals apply once” is the provider’s promise, not the language’s: the no-op only holds when the refresh can actually see the field.


Summary

  • Infrastructure-as-code means describing resources as version-controlled config and letting a tool make reality match. The load-bearing word is declarative: the config states the desired end state, not the steps, and the tool computes the difference.
  • Declarative config is idempotent: applying it once or many times yields the same result, because the tool reconciles against reality instead of re-running steps. The author writes no existence checks; the provider handles that.
  • A provider is the plugin that translates a declared resource into a cloud’s API calls and reads the cloud’s fields back. Idempotence is the provider’s promise, not a language guarantee, and a blind read produces a phantom diff that never converges.
  • A field omitted from a managed config is “declared as default,” not “left alone.” Removing a managed field plans a - destroy.
  • plan is a deterministic diff over three inputs (config, recorded state, and a fresh refresh of reality), emitting one of four outcomes per field (+ create, ~ update, - destroy, no-op). apply runs only that diff, never a separate intent, which is why a diff that has been read is a diff that has been approved.

Check your understanding:

  • Without looking back: what are the three inputs plan computes its diff from, and why can the same config edit produce a different diff on two different days?
  • A config nobody has edited plans the same one-line ~ update on every single run. Is the bug in the config, the state, or the refresh, and what does that tell you about whether idempotence is holding?
  • A resource’s versioning setting is deleted from the config because it should no longer be managed. What does the next plan propose for that field, and why is “I removed it so the tool will ignore it” the wrong mental model?

This lesson is part of Pro

The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.

Unlock with Pro Sign in