Repository navigation
Build and Destroy Stack Per Service
Side page to OpenTofu, stacks and modules (core step 15), and the fourth lesson of a four-lesson infrastructure-as-code track: OpenTofu from zero, then plans, state, drift and recovery, then Spacelift from zero, then this page. It teaches how to split an environment into one stack per service, how to wire the stacks together, and how to build and destroy the whole set without leaving a bill behind.
Next: Teardown checklist.
Unfamiliar word or acronym? The Glossary defines it.
Status: the free lab was run on Linux; the cloud lab was only validated, never applied; PowerShell was not run. On 2026-10-22 the author ran the whole free lab on Linux with OpenTofu 1.12.6 (the version CI pins) and the
hashicorp/localprovider 2.9.1: five full build-and-destroy cycles, each ending with a checker that found every state empty, plus the wrong-order and forgotten-resource exercises below; the output shown is from that run, trimmed. The cloud lab's five stacks were checked withtofu initandtofu validateagainst thedigitalocean/digitaloceanprovider 2.106.0 (a schema check: no token, no API call, nothing created), so their argument names are right, but nothing in them was ever applied. Expect to fix something. The PowerShell scripts were written from Microsoft's and OpenTofu's documentation and were not run (there was no Windows machine): treat every PowerShell line as est. Vendor facts come from the pages listed in the claims register, read on 2026-10-22. Nothing here was run against this project's own stacks, which are locked.
Prerequisites. The three earlier lessons of the track, which are planned lessons being written at the same time as this one: OpenTofu from zero (init, plan, apply, destroy), Plans, state, drift and recovery and Spacelift from zero. Those links work once the lessons are merged; until then, What is infrastructure as code and OpenTofu, stacks and modules cover the minimum (provider, resource, plan, apply, state, stack). Also useful: The terminal and your first script and Teardown checklist. The free lab needs OpenTofu 1.8 or newer, a terminal and a few megabytes of disk; no account, no money. About 60 minutes of reading and 90 minutes for the free lab (est.); the optional cloud lab adds about an hour (est.) and uses your own account.
- Say why a bigger environment is split into one stack per service, and name the costs of splitting just as plainly as the benefits.
- Lay out a repository so each stack has its own folder, its own state and its own cost line.
- Pass a value from one stack to another (an output read through remote state, a Spacelift stack dependency, or a lookup by tag), and say why a secret is not one of those values.
- Work out a build order from the dependencies and destroy in exactly the reverse order, and read the error you get when you do not.
- Tag every resource so that a forgotten one can be found, and say what a tag audit cannot find.
- Build and destroy five stacks in a loop, and prove with a checker, not with a feeling, that nothing remains.
- Compute a hard cost budget for a cloud run from the repository's price file before you create anything.
- Describe how this project does it (numbered stack directories, a cost gate, golden-address tests, a destroy guard) without running anything against locked stacks.
| Idea | Where it is today | The gap this page fills |
|---|---|---|
| Stack, module, layered stacks | OpenTofu, stacks and modules gives five reasons for layers and says outputs flow from 00 to 10 through Spacelift dependencies. | It describes this project's three stacks. It does not teach the general rule, the costs of splitting, or what to do with fifteen stacks. |
| Destroy order and leftovers | Teardown checklist gives the order and the proof commands for this project. | It is a checklist for one environment. The reasons, the hazards and a practice loop are here. |
| Cost per stack | Cost and CI, tests and the cost gate explain the gate. | Nothing teaches a learner to compute a budget for their own run. |
| Running stacks | Spacelift shows the run lifecycle for this project. | It does not show building and destroying a whole set again and again. |
First the words. A stack is a folder of OpenTofu code that you plan and apply as one unit, with its own state (the file or record of what that code created). A service here means anything one person or team can own, change and switch off on its own: the network, a firewall baseline, one web application, a logging server. One stack per service means one folder, one state, one plan, one apply and one destroy for each.
| Reason | What it buys | A small example |
|---|---|---|
| Blast radius (what it means) | A mistake can only touch what is in that stack's state. | A bad edit to the web app's stack cannot plan a change to the network, because the network is not in that state. |
| State isolation | One state file, one lock, one thing to corrupt or lose. State often holds sensitive values, so fewer secrets per file is a smaller loss. | Two people can apply two services at the same time; only two applies to the same stack have to wait for each other. |
| Trust boundary | Each stack runs with its own credentials and identity, so what one stack's credentials can reach is limited to what that stack needs. The maintainer's ruling for this project is that stack boundaries are trust boundaries: for example, the lxmd service and Reticulum get separate stacks and separate identities, so a mistake or a compromise in one does not carry into the other. | The token for the logging stack cannot touch the network. |
| Ownership | A folder can have an owner, a reviewer and a set of permissions. | The team that runs the web app can change the web app, and is not able to change the network by accident. |
| Independent destroy | You can switch one service off, or rebuild it, without touching the others. | Rebuild a test server nightly while the network stays up. |
| Cost gating per stack | Each stack declares its own spend, so a price change shows up in the right place. | This project keeps one cost.json per stack; see the cost gate. |
| Faster, readable plans | A short plan gets read; a 300-line plan gets skimmed. Fewer resources also means a quicker refresh. | The human who confirms an apply can actually read it. |
Splitting is a trade, not a free win. The costs are real, and they are why you should not split everything.
| Cost | What it looks like | What softens it |
|---|---|---|
| Cross-stack wiring | A value one stack creates (a network ID) is needed by another, so you must publish it and read it. | Few, boring outputs (IDs and names), pinned by a test. See Passing values between stacks. |
| Ordering | Stacks must be built in dependency order and destroyed in the reverse. Get it wrong and a run fails, sometimes half way. | A written order, a script, or the platform's dependency feature. See Build order and destroy order. |
| More runs and more approvals | Ten stacks are ten plans to read. A change that touches three of them is three pull requests or three runs. | Keep the number of stacks to what ownership and change rate justify; use a factory (below) to create stacks the same way each time. |
| Duplicated setup | Each stack repeats the provider block, the backend and the variables. | Put the reusable parts in modules; keep the per-stack code to wiring. |
| Harder refactors | Moving a resource from one stack's state to another's is a careful, manual step. | Decide boundaries early; move with tofu state commands or moved and import blocks, never by hand-editing state. |
| Partial failure | With one stack, a run either worked or it did not. With five, you can end up with three built and two not. | Make every stack safe to run again, and keep a "what is built right now" check (the lab below has one). |
Rule of thumb: split along ownership, change rate and blast radius. Things that always change together, and are owned by the same person, can stay in one stack. A single-person throwaway lab in one folder is fine. An environment where the network changes yearly and an application changes daily is not.
The free lab you will build has this shape. Each box is a folder with its own state; an arrow means "reads an output of".
00-tag <---------- 10-firewall <------- 20-network <--+---- 30-server-a
^ ^ ^ |
| | | +---- 40-server-b
+----------------------+--------------------+ (every stack also reads 00-tag)
Build order: 00-tag, 10-firewall, 20-network, 30-server-a, 40-server-b. Destroy order: exactly backwards. In the cloud lab the same five boxes are a tag, a firewall that is attached by that tag, a private network, and two small servers.
-
One folder per stack, one state per folder.
stacks/<NN>-<name>/with its ownmain.tf,variables.tf,outputs.tf,versions.tfand tests. This project's stacks are00-foundation,10-accessand20-telemetry. -
Numbers with gaps.
00,10,20leave room to slot a layer in later without renaming. The number is the build order; the name says what it is. -
Names carry meaning. This project names resources
<layer>-<region>-<role>. The lab's names carry a prefix (cah-lab-) so a script can find everything the lab made. -
A committed example, an ignored real file.
terraform.tfvars.exampleis committed and*.tfvarsis ignored, so values with an account in them never reach Git. (The lab'sterraform.tfvarsfiles live in your scratch folder and hold no secrets.) See Variables, tfvars and secrets. -
Validation at the edge. Variables reject bad input before a plan touches an API. The lab's consumer stack refuses to plan unless
00-tagis in itsneedslist. - Look up, do not re-create, what exists. A data source finds something that is already there.
- Only a short, pinned list of outputs. This project has a test that pins the exact list, so adding an output (and perhaps leaking something through it) has to be done on purpose.
- Provider lock files are committed, so every run uses the same provider build.
-
A cost manifest per stack (
cost.jsonhere), read by the cost gate. -
Tests that need no account. Each stack has
tests/*.tftest.hclfiles with mocked providers, so CI proves the shape for free.
Once the network lives in one stack and the server in another, the server needs the network's ID. There are four ways, and each has a catch.
| Way | How it works | Catch |
|---|---|---|
| Remote state data source | The consumer reads the producer's root module outputs with data "terraform_remote_state". The free lab does this. |
OpenTofu's documentation warns that the reader needs access to the entire state snapshot, which often includes sensitive information, and that the data source does not respect the sensitivity of the source state. |
| Spacelift stack dependency with an output reference | A downstream stack lists an upstream stack's output and maps it to an environment variable, TF_VAR_<name> for OpenTofu. This project does this, so stack 10 never has read access to stack 00's whole state, only the values it consumes. |
It only respects tracked runs, uses the outputs of a successful run that included an apply, and (per Spacelift's documentation) does not manage creating or deleting stacks. See Spacelift and the third lesson of the track. |
| Lookup by tag or name | The consumer uses a provider data source to find the resource that exists, by its tag or name. | The thing must already exist and the name must be stable. There is no recorded link, so destroy order is not enforced by anything. |
| Publish somewhere on purpose | One stack writes the shared value to a named place (a parameter store, a DNS record); others read it there. OpenTofu's documentation recommends this when you can, because it lets you set different access for shared data and for state. | One more moving part to run. |
Never pass a secret this way. An output lands in the state, state sits somewhere (a plain-text JSON file when it is local, per OpenTofu's documentation), and a stack dependency copies it into another stack's environment and logs. Pass identifiers: IDs, names, CIDR ranges, tag names. Pass secrets by giving each stack its own read of the secret store (this project uses Doppler with a per-stack list of the names a stack may load), and mark sensitive outputs sensitive = true knowing that this hides them in the terminal, not in the state file.
A quick test for a candidate output: "if this value appeared in a bug report, would I mind?" If you would mind, it is not an output.
Every arrow in the picture above is a dependency: the consumer cannot plan until the producer's outputs exist. A build order is any ordering where each stack comes after everything it reads. (Computer science calls that a topological order; for a handful of stacks you can just read it off the picture.) Two rules follow.
- Build with the dependencies first.
- Destroy in exactly the reverse: the consumers first, the producers last.
Why the reverse? Because a consumer holds things that sit inside the producer's things. In the cloud, a network cannot be deleted while a server is still attached to it, and the provider refuses with an error (the exact wording varies by provider; est.). In the free lab the failure is different but just as real: the consumer reads the producer's outputs on every plan, including the plan that destroys it. If the producer is gone, the consumer can no longer even plan its own destruction, and you are stuck with resources nothing can remove. You will make this happen on purpose in Exercise 5.
A destroy that reports success is a claim. The evidence is a list that comes back empty. To make that list possible, every resource must carry something a script can search for:
- A tag or label (a short text attached to a resource) naming the environment and the stack, and a name prefix as a second handle. Use both, because not every resource type takes tags. DigitalOcean's documentation says tags apply to droplets "and other DigitalOcean resources", but I did not check which types, so confirm each type you use (est.).
- A tag is applied at creation, in the same code that creates the resource. A tag added later by hand is a tag somebody has to remember.
- Put the tag in the first stack, as this project does (a baseline tag in
00-foundation) and as the lab does (00-tag). Later stacks read it instead of inventing their own spelling. - A tag is not a proof by itself. It lets a script ask "show me everything with this tag", and the answer proves something only if nothing is untagged. Exercise 6 shows the gap.
Cost has to be visible per stack, or nobody sees which stack is the expensive one.
-
A manifest per stack. This project's
cost.jsonlists the machines a stack plans, andscripts/cost_check.pyprices each fromdocs/cost-prices.json, adds them up per cloud and fails if the sum goes over the approved figure, if a stack directory has no manifest, if an unpriced billable resource type appears, or if a size is missing from the price table. The gate reads source code; it does not see what is actually running. See Cost and CI, tests and the cost gate. - A budget before the run. For your own runs, write the arithmetic down first. The cloud lab below ships a small script that reads only the price file.
- A time counter. Hourly billing means cost is size times time. The cheapest habit is to put a calendar reminder at the time you plan to have destroyed everything.
- A spend alert. DigitalOcean's documentation says a spend alert budget "is not a spending cap and does not limit how much you can use". An alert tells you after the money is spent; the destroy is what stops it.
This section is a tour. Nothing here is run: the project's stacks are locked, and every Spacelift change needs the maintainer's confirmation.
-
Stage directories.
stacks/00-foundation(project, baseline tag, VPC, baseline firewall),stacks/10-access(the connector droplets and the Twingate objects) andstacks/20-telemetry(the logging and metrics paths), plusstacks/aws/for the AWS twin. The numbers match the build stages in the Roadmap; the roadmap names50-edgefor a planned public edge, so the numbers30and40are free for the workload and dashboard stages that have no directory yet. Only 00 and 10 have ever been applied for real. -
One Spacelift stack per directory, one explicit stanza per stack in
.spacelift/config.ymllisting only the secrets that stack may load, and tests that fail closed on that file. See Spacelift. - Outputs flow by stack dependency, not remote state; see above.
-
The cost gate reads one
cost.jsonper stack and refuses a stack directory without one. -
Golden-address tests.
docs/golden-addresses/00-foundation.json,10-access.jsonand20-telemetry.jsonlist every resource address each stack plans, with the attributes that would force a replacement.scripts/plan_noop_check.pytakes a saved plan and fails if anything in it is not a no-op, or an address appears that is not in the golden file, or one is missing. That is the check for "I edited a live stack's code, and the default plan must change nothing". The checker is proven in CI against mocked plan JSON; the real gate is a human reading the real plan in Spacelift. -
A destroy guard. The plan policy
.spacelift/policies/plan.regodenies any plan that deletes or replaces a protected resource (a VPC, project, firewall, Twingate remote network, or anything taggedprotected), so a full teardown needs the exact addresses added to an allowlist in a reviewed pull request and removed again afterwards. Teardown checklist walks through it. -
Reverse destroy order, written down.
20-telemetry, then10-access, then00-foundation, then the Spacelift stacks themselves.
Everything above scales up, with a few more decisions to make early because they are painful to change later.
- Environments. Keep development, staging and production apart with separate state and separate accounts or projects, not only a variable that says which. A shared account means a shared blast radius and a shared bill. The same code, called with different inputs, builds each environment.
-
Naming. Pick one scheme and use it everywhere, such as
<org>-<env>-<region>-<service>. Short, lowercase, no personal names, no account numbers. A script that tears down or audits an environment depends on being able to match names. - Accounts and projects. Use the provider's boundary (a project, a team, an account, an organization) as an access boundary, and put resources in the right one at creation. This project puts everything in one DigitalOcean project created by stack 00.
- A shared network stack. Networks, shared DNS, logging and identity change rarely and many things depend on them. Give them their own stacks, early in the order, owned by the people who can answer for them, and publish only identifiers.
- Per-service stacks on top. Each service reads the shared outputs it needs and nothing else. Add services without touching the layers below.
-
A stack factory. When the stack count grows, creating each stack by hand in a console does not scale and invites mistakes. This project's design is one explicit stanza per stack, created from code by a factory repository, with tests that fail closed. The status of that work is in
docs/superpowers/specs/2026-10-22-stack-factory-status.md; it is a gap analysis, not a finished system. - Who may destroy what. Separate three powers and give them to different roles: plan (read-only, anyone on the team), apply (a reviewer or an approval step), destroy (fewest people, with a second pair of eyes for anything shared or production). Turn on the platform's deletion protection where it exists, mark long-lived resources so a policy can refuse to delete them, and keep destroy-capable credentials out of the places that run on every pull request.
- Tag policy and cost reports. Require tags at creation where the platform can enforce it (some cloud providers have organization-wide tag policies; check yours, est.), and read a cost report grouped by tag or by project at least monthly. Both are in the defend step below.
- Moving from one big stack to many. Do it in small steps, one service at a time, with a no-op plan as the acceptance test. This project's golden-address file is exactly that test.
- A dependency blocks the destroy. A network with a server still attached, a firewall still referenced, a stack whose producer is already gone. Fix: destroy in reverse build order, and read the plan.
-
A protected resource refuses. Deletion protection,
prevent_destroyin the code, a plan policy, or the provider itself. This project's policy denies deleting a VPC, project or firewall until the address is on a reviewed allowlist. Fix: remove the protection on purpose, in a reviewed change, then put it back. -
A default resource cannot be deleted. DigitalOcean refuses to delete a VPC that is a region's default (
403 Can not delete default VPCs; see Lessons learned 9). Fix: make another network the default, or forget the resource from state and leave it, and write down that you did. - Volumes, snapshots, reserved addresses and backups outlive the machine. DigitalOcean's documentation says destroying a droplet does not destroy its automated backups (they expire after four weeks) and that associated snapshots and volumes are only destroyed if you ask. Fix: list them after every destroy.
- State is left behind. A destroyed stack leaves a state file that is empty but present. Anything that reads that state, as the lab's consumers do, now sees outputs that are not there. Fix: destroy consumers first, and delete the state storage only after proving the stack is empty.
- Things made by hand are in no state. No destroy touches them and no cost gate saw them. Fix: a tag audit, an inventory of the whole account, and the discipline of not creating things in a console.
- A powered-off machine still bills. DigitalOcean's documentation says bundled-plan droplets that are powered off are still billed, because the compute resources stay reserved; billing ends when you destroy. Fix: destroy, do not stop.
- Billing outlives the resource on the page. Usage data lags, the invoice comes at month end, and a minimum charge applies even to a droplet destroyed within a minute. Fix: look again the next day.
- The credential was revoked too early. Revoking the token before the destroy leaves a run with nothing to authenticate with. Fix: destroy, prove zero, then revoke (the order in Teardown checklist).
- Destroying in the wrong account. The same code run with the wrong token tears down the wrong thing. Fix: before a destroy, check which account the token belongs to, and use a separate account or project for experiments.
-
Cost. Nothing. The lab uses the
localprovider, which writes small text files in a folder you choose. No account, no network except downloading the provider once from the OpenTofu registry. - Teardown. If you have never built anything, there is nothing to tear down. Once you have built, this is the whole teardown, and you can run it any time:
cd ~/iac4-lab && ./lab.sh destroy && ./lab.sh check
cd ~ && rm -rf ~/iac4-lab ~/.cache/iac4-pluginsSet-Location $HOME\iac4-lab; .\lab.ps1 destroy; .\lab.ps1 check
Set-Location $HOME; Remove-Item -Recurse -Force $HOME\iac4-lab, $HOME\.cache\iac4-plugins- Safety. The lab writes only below the folder you give it. Nothing is exposed to a network. Use a scratch folder, never a folder with data you care about.
Five stacks, one folder each, built in order and destroyed in reverse. Each stack manages one thing: a text file. The files stand in for cloud resources: a tag, a firewall, a network and two servers. Every file starts with tag=cah-lab, and every stack except the first reads its upstream stacks' outputs and writes what it saw into its file, so you can see the values travel. The state is local, one terraform.tfstate per stack folder. lab.sh builds all, destroys all, and proves emptiness two ways: every state is empty, and a second, independent look (proof.sh) finds no files. The cloud lab swaps in a different second look and keeps the rest.
Save this as setup.sh and run bash setup.sh. It writes everything into ~/iac4-lab (give a folder as an argument to use another) and runs no OpenTofu.
#!/usr/bin/env bash
# setup.sh: write the five-stack practice lab into a scratch folder. Usage: bash setup.sh [FOLDER]
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
LAB="${1:-$HOME/iac4-lab}"
mkdir -p "$LAB/stacks" "$LAB/world"
LAB="$(cd "$LAB" && pwd)"
# Stack 00 makes the tag every other stack will read.
mkdir -p "$LAB/stacks/00-tag"
cat > "$LAB/stacks/00-tag/main.tf" <<'EOF'
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "name" { type = string }
variable "world" { type = string }
locals {
tag = "cah-lab"
}
resource "local_file" "this" {
filename = "${var.world}/${var.name}.txt"
content = "tag=${local.tag}\nstack=${var.name}\n"
}
output "tag" {
value = local.tag
}
EOF
# Stacks 10 to 40 share one shape: read the stacks they need, write one file.
mkdir -p "$LAB/stacks/_consumer"
cat > "$LAB/stacks/_consumer/main.tf" <<'EOF'
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "name" { type = string }
variable "world" { type = string }
variable "root" { type = string }
variable "needs" {
type = list(string)
validation {
condition = contains(var.needs, "00-tag")
error_message = "Every stack needs 00-tag: it owns the tag."
}
}
# Read-only look at the OUTPUTS of the stacks this one depends on.
data "terraform_remote_state" "up" {
for_each = toset(var.needs)
backend = "local"
config = {
path = "${var.root}/stacks/${each.key}/terraform.tfstate"
}
}
locals {
tag = data.terraform_remote_state.up["00-tag"].outputs.tag
seen = [for k in sort(var.needs) : "${k}=${try(data.terraform_remote_state.up[k].outputs.id, data.terraform_remote_state.up[k].outputs.tag)}"]
}
resource "local_file" "this" {
filename = "${var.world}/${var.name}.txt"
content = "tag=${local.tag}\nstack=${var.name}\n${join("\n", local.seen)}\n"
}
output "id" {
value = local_file.this.id
}
EOF
mk() { # mk NAME NEEDS... (NEEDS are other stack names)
local name="$1"; shift
mkdir -p "$LAB/stacks/$name"
[ "$name" = "00-tag" ] || cp "$LAB/stacks/_consumer/main.tf" "$LAB/stacks/$name/main.tf"
{
printf 'name = "%s"\nworld = "%s"\n' "$name" "$LAB/world"
if [ "$name" != "00-tag" ]; then
printf 'root = "%s"\nneeds = [' "$LAB"
local first=1 n
for n in "$@"; do [ $first = 1 ] || printf ', '; printf '"%s"' "$n"; first=0; done
printf ']\n'
fi
} > "$LAB/stacks/$name/terraform.tfvars"
}
mk 00-tag
mk 10-firewall 00-tag
mk 20-network 00-tag 10-firewall
mk 30-server-a 00-tag 10-firewall 20-network
mk 40-server-b 00-tag 10-firewall 20-network
rm -r "$LAB/stacks/_consumer"
printf '%s\n' 00-tag 10-firewall 20-network 30-server-a 40-server-b > "$LAB/order.txt"
# proof.sh: the second, independent look. Here: is anything left in the world folder?
cat > "$LAB/proof.sh" <<'EOF'
#!/usr/bin/env bash
LAB="$(cd "$(dirname "$0")" && pwd)"
files=$(find "$LAB/world" -type f | wc -l)
printf '%-14s %s file(s) left\n' "world/" "$files"
[ "$files" = 0 ]
EOF
# audit.sh: list everything left, tagged or not.
cat > "$LAB/audit.sh" <<'EOF'
#!/usr/bin/env bash
LAB="$(cd "$(dirname "$0")" && pwd)"
left=0
while IFS= read -r f; do
left=1
if grep -q '^tag=cah-lab$' "$f"; then echo "TAGGED $(basename "$f")"; else echo "UNTAGGED $(basename "$f")"; fi
done < <(find "$LAB/world" -type f | sort)
[ $left = 0 ] && echo "audit: world is empty"
exit $left
EOF
cat > "$LAB/lab.sh" <<'EOF'
#!/usr/bin/env bash
# lab.sh: build all stacks in order.txt, destroy them in reverse, prove nothing remains.
# SPDX-License-Identifier: Apache-2.0
# Usage: ./lab.sh build | destroy | check | cycle N
set -uo pipefail
LAB="$(cd "$(dirname "$0")" && pwd)"
mapfile -t ORDER < "$LAB/order.txt"
export TF_PLUGIN_CACHE_DIR="${TF_PLUGIN_CACHE_DIR:-$HOME/.cache/iac4-plugins}" TF_IN_AUTOMATION=1
mkdir -p "$TF_PLUGIN_CACHE_DIR"
tf() { local s="$1"; shift; tofu -chdir="$LAB/stacks/$s" "$@" -no-color; }
build() {
local s out rc
for s in "${ORDER[@]}"; do
echo "== build $s"
tf "$s" init -input=false >/dev/null || return 1
out=$(tf "$s" apply -auto-approve -input=false 2>&1); rc=$?
echo "$out" | grep -E "^(Apply complete|Error)" || true
[ $rc = 0 ] || { echo "build failed at $s"; return 1; }
done
}
destroy() {
local i r out rc=0
for ((i=${#ORDER[@]}-1; i>=0; i--)); do
echo "== destroy ${ORDER[i]}"
tf "${ORDER[i]}" init -input=false >/dev/null || rc=1
out=$(tf "${ORDER[i]}" destroy -auto-approve -input=false 2>&1); r=$?
echo "$out" | grep -E "^(Destroy complete|Error)" || true
[ $r = 0 ] || { echo "destroy failed at ${ORDER[i]}"; rc=1; }
done
return $rc
}
# statecount STACK [managed]: items in one stack's state. No state file means nothing was ever applied: 0.
# A state file that exists but cannot be read is an error (return 1), never a zero.
statecount() {
local s="$1" out
[ -f "$LAB/stacks/$s/terraform.tfstate" ] || { echo 0; return 0; }
out=$(tf "$s" state list 2>&1) || return 1
if [ "${2:-}" = managed ]; then out=$(printf '%s\n' "$out" | grep -v '^data\.'); fi
printf '%s\n' "$out" | grep -c . || true
}
count() { # managed resources recorded in all states (data sources do not count)
local s total=0 n
for s in "${ORDER[@]}"; do
n=$(statecount "$s" managed) || { echo "state of $s is unreadable" >&2; return 1; }
total=$((total + n))
done
echo "$total"
}
check() { # every state empty, AND the second look (proof.sh) finds nothing
local s n bad=0
for s in "${ORDER[@]}"; do
if n=$(statecount "$s"); then
printf '%-14s %s item(s) in state\n' "$s" "$n"
[ "$n" = 0 ] || bad=1
else
printf '%-14s state UNREADABLE (this proves nothing)\n' "$s"; bad=1
fi
done
bash "$LAB/proof.sh" || bad=1
if [ $bad = 0 ]; then echo "CHECK PASSED: nothing remains"; else echo "CHECK FAILED: something remains"; fi
return $bad
}
cycle() {
local i n="${1:-5}"
for ((i=1; i<=n; i++)); do
echo "######## cycle $i of $n"
build || { echo "cycle $i: build failed"; return 1; }
echo "cycle $i: $(count) resources in state after build"
destroy || { echo "cycle $i: destroy failed"; return 1; }
check || { echo "cycle $i: NOT EMPTY"; return 1; }
done
echo "ALL $n CYCLES CLEAN"
}
case "${1:-}" in
build) build ;; destroy) destroy ;; check) check ;; cycle) cycle "${2:-5}" ;;
*) echo "usage: $0 build|destroy|check|cycle N"; exit 2 ;;
esac
EOF
chmod +x "$LAB/lab.sh" "$LAB/proof.sh" "$LAB/audit.sh"
echo "lab written to $LAB"The PowerShell version writes the same files (est.: not run). Save it as setup.ps1 and run it with .\setup.ps1. If PowerShell refuses to run a script, see your execution policy in Microsoft's documentation, and run it for one session only.
# setup.ps1: write the five-stack practice lab into a scratch folder. Usage: .\setup.ps1 [-Lab FOLDER]
# SPDX-License-Identifier: Apache-2.0
param([string]$Lab = "$HOME\iac4-lab")
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force "$Lab\stacks", "$Lab\world" | Out-Null
$Lab = (Resolve-Path $Lab).Path
$Fwd = $Lab -replace '\\', '/' # OpenTofu is happy with forward slashes on Windows
# Stack 00 makes the tag every other stack will read.
$TagTf = @'
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "name" { type = string }
variable "world" { type = string }
locals {
tag = "cah-lab"
}
resource "local_file" "this" {
filename = "${var.world}/${var.name}.txt"
content = "tag=${local.tag}\nstack=${var.name}\n"
}
output "tag" {
value = local.tag
}
'@
# Stacks 10 to 40 share one shape: read the stacks they need, write one file.
$ConsumerTf = @'
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "name" { type = string }
variable "world" { type = string }
variable "root" { type = string }
variable "needs" {
type = list(string)
validation {
condition = contains(var.needs, "00-tag")
error_message = "Every stack needs 00-tag: it owns the tag."
}
}
# Read-only look at the OUTPUTS of the stacks this one depends on.
data "terraform_remote_state" "up" {
for_each = toset(var.needs)
backend = "local"
config = {
path = "${var.root}/stacks/${each.key}/terraform.tfstate"
}
}
locals {
tag = data.terraform_remote_state.up["00-tag"].outputs.tag
seen = [for k in sort(var.needs) : "${k}=${try(data.terraform_remote_state.up[k].outputs.id, data.terraform_remote_state.up[k].outputs.tag)}"]
}
resource "local_file" "this" {
filename = "${var.world}/${var.name}.txt"
content = "tag=${local.tag}\nstack=${var.name}\n${join("\n", local.seen)}\n"
}
output "id" {
value = local_file.this.id
}
'@
function New-Stack([string]$Name, [string[]]$Needs) {
New-Item -ItemType Directory -Force "$Lab\stacks\$Name" | Out-Null
$tf = if ($Name -eq '00-tag') { $TagTf } else { $ConsumerTf }
Set-Content -Path "$Lab\stacks\$Name\main.tf" -Value $tf -Encoding ascii
$vars = @("name = `"$Name`"", "world = `"$Fwd/world`"")
if ($Name -ne '00-tag') {
$list = ($Needs | ForEach-Object { "`"$_`"" }) -join ', '
$vars += "root = `"$Fwd`""
$vars += "needs = [$list]"
}
Set-Content -Path "$Lab\stacks\$Name\terraform.tfvars" -Value $vars -Encoding ascii
}
New-Stack '00-tag' @()
New-Stack '10-firewall' @('00-tag')
New-Stack '20-network' @('00-tag', '10-firewall')
New-Stack '30-server-a' @('00-tag', '10-firewall', '20-network')
New-Stack '40-server-b' @('00-tag', '10-firewall', '20-network')
Set-Content "$Lab\order.txt" @('00-tag', '10-firewall', '20-network', '30-server-a', '40-server-b') -Encoding ascii
# proof.ps1: the second, independent look. Here: is anything left in the world folder?
Set-Content "$Lab\proof.ps1" -Encoding ascii -Value @'
$files = @(Get-ChildItem "$PSScriptRoot\world" -Recurse -File).Count
"{0,-14} {1} file(s) left" -f 'world/', $files
exit ([int]($files -ne 0))
'@
# audit.ps1: list everything left, tagged or not.
Set-Content "$Lab\audit.ps1" -Encoding ascii -Value @'
$left = 0
Get-ChildItem "$PSScriptRoot\world" -Recurse -File | Sort-Object Name | ForEach-Object {
$left = 1
if (Select-String -Path $_.FullName -Pattern '^tag=cah-lab$' -Quiet) { "TAGGED $($_.Name)" } else { "UNTAGGED $($_.Name)" }
}
if ($left -eq 0) { 'audit: world is empty' }
exit $left
'@
Set-Content "$Lab\lab.ps1" -Encoding ascii -Value @'
# lab.ps1: build all stacks in order.txt, destroy them in reverse, prove nothing remains.
# Usage: .\lab.ps1 build | destroy | check | cycle [N]
param([Parameter(Position = 0)][string]$Action = '', [Parameter(Position = 1)][int]$N = 5)
$Lab = $PSScriptRoot
$Order = @(Get-Content "$Lab\order.txt" | Where-Object { $_ })
if (-not $env:TF_PLUGIN_CACHE_DIR) { $env:TF_PLUGIN_CACHE_DIR = "$HOME\.cache\iac4-plugins" }
New-Item -ItemType Directory -Force $env:TF_PLUGIN_CACHE_DIR | Out-Null
$env:TF_IN_AUTOMATION = '1'
function Tf([string]$Stack, [string[]]$More) { & tofu "-chdir=$Lab/stacks/$Stack" @More '-no-color' }
function Build {
foreach ($s in $Order) {
"== build $s"
Tf $s @('init', '-input=false') | Out-Null
if ($LASTEXITCODE -ne 0) { "build failed at $s"; return $false }
$out = Tf $s @('apply', '-auto-approve', '-input=false') 2>&1
$out | Select-String '^(Apply complete|Error)' | Out-Host
if ($LASTEXITCODE -ne 0) { "build failed at $s"; return $false }
}
return $true
}
function Destroy {
$ok = $true
for ($i = $Order.Count - 1; $i -ge 0; $i--) {
$s = $Order[$i]
"== destroy $s"
Tf $s @('init', '-input=false') | Out-Null
$out = Tf $s @('destroy', '-auto-approve', '-input=false') 2>&1
$out | Select-String '^(Destroy complete|Error)' | Out-Host
if ($LASTEXITCODE -ne 0) { "destroy failed at $s"; $ok = $false }
}
return $ok
}
# State-Count STACK: items in one state. No state file means nothing was applied: 0.
# A state file that exists but cannot be read returns -1 (an error), never 0.
function State-Count([string]$Stack, [bool]$ManagedOnly = $false) {
if (-not (Test-Path "$Lab\stacks\$Stack\terraform.tfstate")) { return 0 }
$out = Tf $Stack @('state', 'list') 2>&1
if ($LASTEXITCODE -ne 0) { return -1 }
$items = @($out | Where-Object { $_ })
if ($ManagedOnly) { $items = @($items | Where-Object { $_ -notmatch '^data\.' }) }
return $items.Count
}
function Count-Managed { # managed resources in all states (data sources do not count)
$total = 0
foreach ($s in $Order) { $total += [Math]::Max(0, (State-Count $s $true)) }
return $total
}
function Check { # every state empty, AND the second look (proof.ps1) finds nothing
$bad = $false
foreach ($s in $Order) {
$n = State-Count $s
if ($n -lt 0) { "{0,-14} state UNREADABLE (this proves nothing)" -f $s; $bad = $true; continue }
"{0,-14} {1} item(s) in state" -f $s, $n
if ($n -ne 0) { $bad = $true }
}
& "$Lab\proof.ps1"
if ($LASTEXITCODE -ne 0) { $bad = $true }
if ($bad) { 'CHECK FAILED: something remains' } else { 'CHECK PASSED: nothing remains' }
return (-not $bad)
}
switch ($Action) {
'build' { if (Build) { exit 0 } else { exit 1 } }
'destroy' { if (Destroy) { exit 0 } else { exit 1 } }
'check' { if (Check) { exit 0 } else { exit 1 } }
'cycle' {
for ($i = 1; $i -le $N; $i++) {
"######## cycle $i of $N"
if (-not (Build)) { "cycle $i`: build failed"; exit 1 }
"cycle $i`: $(Count-Managed) resources in state after build"
if (-not (Destroy)) { "cycle $i`: destroy failed"; exit 1 }
if (-not (Check)) { "cycle $i`: NOT EMPTY"; exit 1 }
}
"ALL $N CYCLES CLEAN"
}
default { 'usage: .\lab.ps1 build|destroy|check|cycle [N]'; exit 2 }
}
'@
Write-Output "lab written to $Lab"Look at what you got. The producer stack is short, and the other four are one file copied four times with different terraform.tfvars:
cd ~/iac4-lab
cat stacks/30-server-a/terraform.tfvarsSet-Location $HOME\iac4-lab
Get-Content .\stacks\30-server-a\terraform.tfvarsYou will see the stack's name, the world folder, the root folder and needs = ["00-tag", "10-firewall", "20-network"]. The needs list is the dependency list from the picture, in data.
Predict. Without running anything, write down: (a) how many resources the five states will hold after a full build; (b) which stack's file will contain the line 10-firewall= and what comes after the equals sign; (c) what ./lab.sh check will say right now, before any build.
Do.
cd ~/iac4-lab
./lab.sh check; echo "exit=$?"Set-Location $HOME\iac4-lab
.\lab.ps1 check; "exit=$LASTEXITCODE"Check. Before any build there are no states yet, so each stack shows 0 item(s) in state, the world folder shows 0 file(s) left, and the check prints CHECK PASSED: nothing remains and exits 0. That is the baseline: a checker you have only ever seen pass proves nothing, so you will see it fail in Exercise 3 and Exercise 6. (a) is 5, one local file per stack; the remote-state lookups are data sources and are not counted. (b) The files for 20-network, 30-server-a and 40-server-b, each followed by the firewall file's ID (a long hexadecimal string; yours will differ).
Undo. Nothing was created.
Predict. Which stack must be built first, and what happens if you try to plan 30-server-a before 00-tag exists?
Do.
cd ~/iac4-lab
./lab.sh build
cat world/40-server-b.txtSet-Location $HOME\iac4-lab
.\lab.ps1 build
Get-Content .\world\40-server-b.txtCheck. Five lines of the form == build <stack> each followed by Apply complete! Resources: 1 added, 0 changed, 0 destroyed. (the first run also downloads the provider and takes longer). The file looks like this; the two IDs will differ:
tag=cah-lab
stack=40-server-b
00-tag=cah-lab
10-firewall=1d71d8f7a40d90e5f49f14de224b2b0133266fbd
20-network=c1e113a77d9a86f4de352c5581aa9745f28c0ac1
Those last three lines are the cross-stack values. 00-tag=cah-lab is the producer's output; the other two are IDs from the stacks it read. Run ./lab.sh build a second time: every stack says 0 added, because nothing changed. A second run that changes nothing is a property worth wanting from every stack. For the answer to the predict question: if you try to plan 30-server-a first, its terraform_remote_state looks for state files that are not there and the plan fails with Error: Unable to find remote state (what OpenTofu 1.12.6 printed on 2026-10-22).
Undo. The next exercise destroys it.
Predict. In what order will the stacks be destroyed? After the destroy, will the folders stacks/*/terraform.tfstate still exist?
Do.
./lab.sh destroy
./lab.sh check; echo "exit=$?"
ls stacks/00-tag.\lab.ps1 destroy
.\lab.ps1 check; "exit=$LASTEXITCODE"
Get-ChildItem .\stacks\00-tagCheck. The destroy goes 40-server-b, 30-server-a, 20-network, 10-firewall, 00-tag, each reporting Destroy complete! Resources: 1 destroyed. Then the check prints:
00-tag 0 item(s) in state
10-firewall 0 item(s) in state
20-network 0 item(s) in state
30-server-a 0 item(s) in state
40-server-b 0 item(s) in state
world/ 0 file(s) left
CHECK PASSED: nothing remains
and exits 0. The state files still exist (terraform.tfstate and a .backup): they are empty, which is the hazard in lessons-learned item 5. An empty state is not the same as no state; that is why the checker asks for resources, not for the absence of a file.
Undo. Nothing to undo; this was the undo. Build again when you need to.
A loop is where this becomes a habit. One cycle is: build all, count what is in state, destroy all, run the checker. Any red stops the loop.
Predict. How many resources will the count show after each build, and will the fifth cycle be any different from the first?
Do.
cd ~/iac4-lab
./lab.sh cycle 5Set-Location $HOME\iac4-lab
.\lab.ps1 cycle 5Check. For each of the five cycles you see cycle N: 5 resources in state after build, then the destroy, then CHECK PASSED: nothing remains, and the last line is ALL 5 CYCLES CLEAN. On the author's machine the five cycles took about 13 seconds in all once the provider was cached. If a cycle fails, the script stops there and tells you which one; do not start another until you can explain it.
Undo. The loop ends with everything destroyed. Run ./lab.sh check to see it for yourself.
Predict. If you destroy 00-tag first while the other four exist, what do you expect when you then run the normal destroy? Which stacks will fail, and which will not?
Do.
cd ~/iac4-lab
./lab.sh build > /dev/null
tofu -chdir=stacks/00-tag destroy -auto-approve -no-color | tail -1
./lab.sh destroy
./lab.sh check; echo "exit=$?"Set-Location $HOME\iac4-lab
.\lab.ps1 build | Out-Null
tofu -chdir=stacks/00-tag destroy -auto-approve -no-color | Select-Object -Last 1
.\lab.ps1 destroy
.\lab.ps1 check; "exit=$LASTEXITCODE"Check. The first command succeeds (Destroy complete! Resources: 1 destroyed.). Then the normal destroy fails for the four consumers, one after another:
== destroy 40-server-b
Error: Unsupported attribute
Error: Error in function call
destroy failed at 40-server-b
...
== destroy 00-tag
Destroy complete! Resources: 0 destroyed.
and the check ends CHECK FAILED: something remains with exit 1, showing 2, 3, 4 and 4 items still in the four consumer states and 4 files left. The error, in full, is that data.terraform_remote_state.up["00-tag"].outputs is object with no attributes: the producer no longer publishes the tag, and the consumers cannot plan anything, including their own destruction. In a real cloud, the failure would usually come earlier and from the provider, and the cure is the same.
Undo (recovery). Put the producer back, then destroy properly. The build is safe to repeat; it recreates 00-tag and leaves the rest as they are:
./lab.sh build > /dev/null
./lab.sh destroy
./lab.sh check; echo "exit=$?".\lab.ps1 build | Out-Null
.\lab.ps1 destroy
.\lab.ps1 check; "exit=$LASTEXITCODE"The check passes again. Note what you did: you rebuilt in order so you could destroy in order. That is the standard recovery.
Predict. Suppose a destroy stops early and leaves 40-server-b built, and somebody also creates one file by hand with no tag. Which of the two does a tag-based search find? Which does the whole-folder listing find?
Do.
cd ~/iac4-lab
./lab.sh build > /dev/null
tofu -chdir=stacks/30-server-a destroy -auto-approve -no-color | tail -1 # 30 only; 40 is "forgotten"
printf 'owner=someone\n' > world/handmade.txt # a resource nobody tagged
echo "--- tag search:"; grep -l '^tag=cah-lab$' world/*
echo "--- whole-folder audit:"; ./audit.sh; echo "audit exit=$?"
./lab.sh check | tail -3Set-Location $HOME\iac4-lab
.\lab.ps1 build | Out-Null
tofu -chdir=stacks/30-server-a destroy -auto-approve -no-color | Select-Object -Last 1
Set-Content .\world\handmade.txt 'owner=someone'
"--- tag search:"; Select-String -Path .\world\* -Pattern '^tag=cah-lab$' -List | ForEach-Object Path
"--- whole-folder audit:"; .\audit.ps1; "audit exit=$LASTEXITCODE"
.\lab.ps1 check | Select-Object -Last 3Check. The tag search lists the four tagged files and not handmade.txt. The audit lists all five, and marks the last as UNTAGGED handmade.txt; it exits 1 because anything left at all is a failure. check fails too. The lesson: a tag search proves "everything I tagged is gone" and nothing more. The untagged file was invisible to it.
Undo.
rm world/handmade.txt
./lab.sh destroy && ./lab.sh checkRemove-Item .\world\handmade.txt
.\lab.ps1 destroy; .\lab.ps1 checkYou need a clone of the repository for this one. It reads files and changes nothing.
Predict. If a pull request added one new resource to stacks/00-foundation and forgot to touch docs/golden-addresses/00-foundation.json, what would scripts/plan_noop_check.py say about a plan from that code?
Do. From the root of a clone:
python3 -I -c "import json; g=json.load(open('docs/golden-addresses/00-foundation.json')); print(len(g['addresses']), 'addresses'); print(*sorted(g['addresses']), sep='\n')"
python3 -I scripts/plan_noop_check.py --help | head -20python -I -c "import json; g=json.load(open('docs/golden-addresses/00-foundation.json')); print(len(g['addresses']), 'addresses'); print(*sorted(g['addresses']), sep='\n')"
python -I scripts/plan_noop_check.py --help | Select-Object -First 20Check. You get the list of resource addresses stack 00 plans. The checker's header says it fails on any change that is not a no-op, on an address missing from the golden list, and on a golden address missing from the plan. So the answer to the predict question is: it would name the new address as not in the golden file. The pull request has to add it on purpose, and a reviewer sees that edit.
Undo. Nothing changed.
Status of this path: the files validate against the provider's schema; nothing here was ever applied. Run it expecting to fix something.
This path builds the same five boxes on DigitalOcean: a tag, a firewall attached by that tag, a private network, and two of the smallest droplets. It uses your own account and your own bill. It is optional, and the free path above teaches the same lesson.
Teardown first, in this order. Write these down before you build anything.
cd ~/iac4-cloud && ./lab.sh destroy && ./lab.sh check # the normal route
# If the stacks' state is lost and the check shows leftovers, use the tag (flags from the doctl reference):
doctl compute droplet delete --tag-name cah-lab-iac4 --force
doctl compute firewall list --format ID,Name # note the cah-lab-iac4 ID, then:
doctl compute firewall delete FIREWALL_ID --force
doctl vpcs list --format ID,Name # note the cah-lab-iac4-net ID, then:
doctl vpcs delete VPC_ID --force
doctl compute tag delete cah-lab-iac4 --force# est.: the cloud lab is Bash-first. On Windows, run lab.sh from WSL or Git Bash; these doctl lines are the same in PowerShell.
doctl compute droplet delete --tag-name cah-lab-iac4 --force
doctl compute firewall list --format ID,Name
doctl compute firewall delete FIREWALL_ID --force
doctl vpcs list --format ID,Name
doctl vpcs delete VPC_ID --force
doctl compute tag delete cah-lab-iac4 --force(FIREWALL_ID and VPC_ID are placeholders for the IDs you read. Whether --force is accepted by the firewall delete, vpcs delete and tag delete commands should be confirmed with --help on your doctl version; est.)
Prove nothing remains. Use a read-only token. The proof.sh the setup script writes lists droplets, firewalls, VPCs and tags whose names start with cah-lab-, and also lists your volumes, snapshots and reserved addresses, which do not carry the lab's name; use a dedicated account or project, because any item in those three lists fails the proof until you explain or delete it. It fails closed: if doctl is missing or the token is rejected, it says the listing failed and exits 1, instead of printing zero. An API alternative, read-only: GET https://api.digitalocean.com/v2/droplets?tag_name=cah-lab-iac4, which per DigitalOcean's published API description needs the tag:read scope.
Cost. Computed from docs/cost-prices.json only (retrieved 2026-10-21 from DigitalOcean's sizes API) and DigitalOcean's pricing page (read 2026-10-22):
| Fact | Value | Where it comes from |
|---|---|---|
| Smallest droplet size in the file |
s-1vcpu-512mb-10gb, $4 a month |
docs/cost-prices.json |
| Next size |
s-1vcpu-1gb, $6 a month, verified available in tor1 on 2026-10-21 |
docs/cost-prices.json, docs/cost.md
|
| Billing unit | per second, minimum charge of 60 seconds or $0.01, whichever is higher | DigitalOcean pricing page |
| Monthly cap | usage capped at 672 hours (28 days) a month | DigitalOcean pricing page |
| Powered off | still billed; destroy to stop billing | DigitalOcean pricing page |
| Hourly rate | about monthly price divided by 672, so $4 gives about $0.006 an hour (est.: the hourly rate itself was not read) | derived |
| Firewall, tag, VPC | no line in docs/cost.md or the gate's price list; DigitalOcean's firewall page calls cloud firewalls "at no additional cost" |
repository and DigitalOcean docs |
Region and size availability differ by account: the file lists s-1vcpu-512mb-10gb, but docs/cost.md only re-read five other sizes for tor1, so check yours first with doctl compute size list (read-only) and fall back to s-1vcpu-1gb if it is missing.
Hard budget: $1.00 for the whole path. The planned run is 2 droplets x 3 cycles x 30 minutes. Each droplet-run is billed at least the $0.01 minimum. The budget covers that run and one forgotten day (about $0.29, est.). A forgotten month would be $8.00 at $4 each, which is eight times the budget: the script prints it but does not count it as covered, because only the destroy, your calendar reminder and a spend alert cover that. Do not trust my arithmetic: this script recomputes it from the file in your clone, and refuses to say OK if the covered number is over budget or the size is not in the file.
# budget.py: worst-case cost of the cloud lab, from the repository's own price file only.
# SPDX-License-Identifier: Apache-2.0
# Usage: python3 -I budget.py PRICES_JSON SIZE DROPLETS HOURS_EACH CYCLES BUDGET_USD
import json
import sys
path, size = sys.argv[1], sys.argv[2]
droplets, hours, cycles, budget = int(sys.argv[3]), float(sys.argv[4]), int(sys.argv[5]), float(sys.argv[6])
with open(path, encoding="utf-8") as fh:
prices = json.load(fh)["droplets"]
if size not in prices:
sys.exit(f"STOP: {size} is not in {path}; no price read, no run")
monthly = float(prices[size])
hourly = monthly / 672 # est.: DigitalOcean caps bundled droplets at 672 hours; the hourly rate itself was not read
one_run = max(hours * hourly, 0.01) # DigitalOcean's minimum charge is 60 seconds or $0.01, whichever is higher
planned = droplets * cycles * one_run
forgotten_day = droplets * 24 * hourly
forgotten_month = droplets * monthly
# The guard covers the planned run and one forgotten day. A forgotten month is shown, not allowed.
print(f"size {size}: ${monthly:.2f} a month (file), about ${hourly:.5f} an hour (est.)")
print(f"planned: {droplets} droplet(s) x {cycles} cycle(s) x {hours:g} h each: about ${planned:.3f} (est.)")
print(f"if you forget them for a day: about ${forgotten_day:.3f} (est.)")
print(f"if you forget them for a month: ${forgotten_month:.2f} (NOT covered by the budget: only destroying covers that)")
ok = max(planned, forgotten_day) <= budget
print(f"budget ${budget:.2f}:", "OK" if ok else "OVER, do not run")
sys.exit(0 if ok else 1)python3 -I budget.py path/to/Clouds-Against-Humanity/docs/cost-prices.json s-1vcpu-512mb-10gb 2 0.5 3 1.00python -I budget.py path\to\Clouds-Against-Humanity\docs\cost-prices.json s-1vcpu-512mb-10gb 2 0.5 3 1.00The output the author got on 2026-10-22:
size s-1vcpu-512mb-10gb: $4.00 a month (file), about $0.00595 an hour (est.)
planned: 2 droplet(s) x 3 cycle(s) x 0.5 h each: about $0.060 (est.)
if you forget them for a day: about $0.286 (est.)
if you forget them for a month: $8.00 (NOT covered by the budget: only destroying covers that)
budget $1.00: OK
Set a spend alert, and know what it is. In the billing area, create a spend alert for a small monthly budget (say $5) with thresholds. DigitalOcean's page is explicit that this is not a cap. It emails you; it does not stop anything. The reminder to destroy is the control.
Data and credentials. No real data goes on these machines. Keep the API token in your shell session or password manager, not in a file in the folder; the provider reads DIGITALOCEAN_TOKEN from the environment. Use a separate account or project for experiments, and revoke the token when you finish. This lab never logs in to the droplets.
The setup script copies lab.sh from your free lab, writes the five cloud stacks, and replaces order.txt and proof.sh. It calls no API. Save it as setup-cloud.sh. Run it only after the budget script said OK, passing the size the budget approved.
#!/usr/bin/env bash
# setup-cloud.sh: write the same five-stack shape for DigitalOcean. It writes files only; it calls no API.
# SPDX-License-Identifier: Apache-2.0
# Usage: SIZE=<slug from budget.py> bash setup-cloud.sh [FOLDER] (needs ~/iac4-lab/lab.sh from the free tier)
set -euo pipefail
: "${SIZE:?set SIZE to a droplet size slug that budget.py approved}"
REGION="${REGION:-tor1}"
LAB="${1:-$HOME/iac4-cloud}"
mkdir -p "$LAB/stacks"
LAB="$(cd "$LAB" && pwd)"
cp "${FREE_LAB:-$HOME/iac4-lab}/lab.sh" "$LAB/lab.sh"
hdr() { cat <<'EOF'
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
}
}
}
# The token comes from the environment (DIGITALOCEAN_TOKEN), never from a file.
provider "digitalocean" {}
EOF
}
up() { cat <<'EOF'
variable "root" { type = string }
data "terraform_remote_state" "tag" {
backend = "local"
config = { path = "${var.root}/stacks/00-tag/terraform.tfstate" }
}
locals { tag = data.terraform_remote_state.tag.outputs.tag }
EOF
}
mkdir -p "$LAB/stacks/00-tag"
{ hdr; cat <<'EOF'
variable "tag" { type = string }
resource "digitalocean_tag" "this" { name = var.tag }
output "tag" { value = digitalocean_tag.this.name }
EOF
} > "$LAB/stacks/00-tag/main.tf"
printf 'tag = "cah-lab-iac4"\n' > "$LAB/stacks/00-tag/terraform.tfvars"
mkdir -p "$LAB/stacks/10-firewall"
{ hdr; up; cat <<'EOF'
# No inbound and no outbound rules: DigitalOcean's cloud firewalls block what no rule permits.
resource "digitalocean_firewall" "this" {
name = "cah-lab-iac4-baseline"
tags = [local.tag]
}
output "id" { value = digitalocean_firewall.this.id }
EOF
} > "$LAB/stacks/10-firewall/main.tf"
printf 'root = "%s"\n' "$LAB" > "$LAB/stacks/10-firewall/terraform.tfvars"
mkdir -p "$LAB/stacks/20-network"
{ hdr; up; cat <<'EOF'
variable "region" { type = string }
data "terraform_remote_state" "fw" {
backend = "local"
config = { path = "${var.root}/stacks/10-firewall/terraform.tfstate" }
}
resource "digitalocean_vpc" "this" {
name = "cah-lab-iac4-net"
region = var.region
ip_range = "10.99.0.0/24"
# Reading the firewall's id makes the order real: the network cannot even be planned without it.
lifecycle {
precondition {
condition = data.terraform_remote_state.fw.outputs.id != ""
error_message = "Build 10-firewall first."
}
}
}
output "id" { value = digitalocean_vpc.this.id }
EOF
} > "$LAB/stacks/20-network/main.tf"
printf 'root = "%s"\nregion = "%s"\n' "$LAB" "$REGION" > "$LAB/stacks/20-network/terraform.tfvars"
for d in 30-server-a 40-server-b; do
mkdir -p "$LAB/stacks/$d"
{ hdr; up; cat <<'EOF'
variable "name" { type = string }
variable "region" { type = string }
variable "size" { type = string }
data "terraform_remote_state" "fw" {
backend = "local"
config = { path = "${var.root}/stacks/10-firewall/terraform.tfstate" }
}
data "terraform_remote_state" "net" {
backend = "local"
config = { path = "${var.root}/stacks/20-network/terraform.tfstate" }
}
resource "digitalocean_droplet" "this" {
name = var.name
image = "ubuntu-24-04-x64"
region = var.region
size = var.size
vpc_uuid = data.terraform_remote_state.net.outputs.id
tags = [local.tag]
backups = false
monitoring = false
ipv6 = false
# The firewall stack ran first. Reading its id here makes the order explicit.
lifecycle {
precondition {
condition = data.terraform_remote_state.fw.outputs.id != ""
error_message = "Build 10-firewall first: a droplet must never exist without it."
}
}
}
output "id" { value = digitalocean_droplet.this.id }
EOF
} > "$LAB/stacks/$d/main.tf"
printf 'root = "%s"\nname = "cah-lab-%s"\nregion = "%s"\nsize = "%s"\n' "$LAB" "$d" "$REGION" "$SIZE" > "$LAB/stacks/$d/terraform.tfvars"
done
printf '%s\n' 00-tag 10-firewall 20-network 30-server-a 40-server-b > "$LAB/order.txt"
# proof.sh: the second look, from DigitalOcean's side. Read-only doctl listings.
cat > "$LAB/proof.sh" <<'EOF'
#!/usr/bin/env bash
# Needs doctl logged in with a READ-ONLY token. Lists only; changes nothing.
bad=0
show() { # show LABEL COMMAND... : count lines whose name starts with cah-lab-
local label="$1" out n; shift
out=$("$@" 2>&1) || { echo "$label: the listing FAILED, so this proves nothing: $out"; bad=1; return; }
n=$(printf '%s\n' "$out" | grep -c '^cah-lab-' || true)
printf '%-10s %s left named cah-lab-*\n' "$label" "$n"
[ "$n" = 0 ] || bad=1
}
show droplets doctl compute droplet list --no-header --format Name
show firewalls doctl compute firewall list --no-header --format Name
show vpcs doctl vpcs list --no-header --format Name
show tags doctl compute tag list --no-header --format Name
for kind in "volume list" "snapshot list" "reserved-ip list"; do
out=$(doctl compute $kind --no-header 2>&1) || { echo "$kind: the listing FAILED: $out"; bad=1; continue; }
n=$(printf '%s\n' "$out" | grep -c . || true)
printf '%-16s %s item(s)\n' "$kind" "$n"
# These do not carry the lab's name, so use a dedicated account or project: any item fails the proof.
[ "$n" = 0 ] || { echo " ^ not empty: explain it or delete it, then run the proof again"; bad=1; }
done
exit $bad
EOF
chmod +x "$LAB/lab.sh" "$LAB/proof.sh"
echo "cloud lab written to $LAB (no API was called)"cd ~
SIZE=s-1vcpu-512mb-10gb bash setup-cloud.sh# est.: the generator is a Bash script. On Windows run it from WSL or Git Bash; the files it writes are identical.
wsl bash -c 'cd ~ && SIZE=s-1vcpu-512mb-10gb bash setup-cloud.sh'Read before you run. Open each stack and answer: what does it read, what does it create, what does it output? Notice that the firewall has no rules: DigitalOcean's documentation says cloud firewalls block all traffic that is not expressly permitted, so the droplets are closed from the first second, which is why the firewall stack is built before the servers. Whether the tag-attached firewall is in effect before a new droplet finishes booting is est.
Predict. How many billable resources will exist after the build, and what is the worst case if you walk away right now?
Do.
read -rsp 'token: ' DIGITALOCEAN_TOKEN; export DIGITALOCEAN_TOKEN; echo # typed at the prompt; not echoed, not saved
cd ~/iac4-cloud
tofu -chdir=stacks/00-tag init -input=false -no-color > /dev/null
tofu -chdir=stacks/00-tag plan -no-color | tail -5 # read it; it should show 1 to add
./lab.sh build
bash proof.sh; echo "exit=$?" # EXPECTED to fail: things exist
./lab.sh destroy
bash proof.sh; echo "exit=$?" # now 0# est.: not run. The cloud lab's scripts are Bash, so on Windows run the Bash lines above inside WSL or Git Bash.
# The only PowerShell-specific part is setting and clearing the token for a session that calls doctl or tofu directly:
$sec = Read-Host -AsSecureString 'token'
$env:DIGITALOCEAN_TOKEN = [System.Net.NetworkCredential]::new('', $sec).Password
# ... work ...
Remove-Item Env:DIGITALOCEAN_TOKENCheck. After the build, proof.sh reports droplets and firewalls and VPCs and a tag with a count above zero, and exits 1: the proof can fail, which is what makes it a proof. After the destroy all four show 0 left named cah-lab-* and the script exits 0. Look at the billing page the next day: usage for the hours you ran, and nothing continuing. Then run unset DIGITALOCEAN_TOKEN (PowerShell: Remove-Item Env:DIGITALOCEAN_TOKEN), revoke the token, and delete the folder.
Undo. The destroy is the undo. If anything is left, use the tag-based teardown at the top of this section.
The same five stacks, with the dependencies as Spacelift stack dependencies and the outputs mapped to TF_VAR_ variables, run through Spacelift's plan, approve and apply steps instead of from your terminal. That is the third lesson of the track: Spacelift from zero (a planned lesson; the link works once it merges). Read it for the setup. Do not point this project's own Spacelift stacks at any of it: those stacks are locked and only the maintainer changes them.
The same loop as the labs, applied to a resource nobody remembers. Everything is in the free lab or on paper; you need no account.
Build. The five-stack lab: every file tagged cah-lab, every stack destroyable, a checker that proves emptiness two ways.
Break. A forgotten resource: a destroy that stops early, leaving 40-server-b built. In a cloud this is a droplet nobody is looking at, billing every hour. You did it in Exercise 6.
Detect. A tag audit. grep -l '^tag=cah-lab$' world/* in the lab; doctl compute droplet list --tag-name <tag> in the cloud. It finds the forgotten stack and the tagged files, and a scheduled run of it, with a person who gets the result, turns "I should check" into a habit. Detection also has a second source: the billing page and cost analysis, which show spend by product whether or not you remembered the resource. That is why the teardown checklist insists on looking again the next day.
Evade. A resource that does not carry the tag: handmade.txt. In a cloud it is a droplet made in the console, a resource whose provider does not support tags, a resource a script created with a typo in the tag, or a resource somebody deliberately left untagged. The tag audit does not find it. This is not an attack technique in any sense that needs a target; it is the ordinary way a clean-looking account is not clean, and an attacker who creates infrastructure in an account they have taken over has the same preference for not carrying your tags. Nothing here shows how to do that to anyone else's account.
Defend. Three layers, none sufficient alone.
- Make untagged creation hard. Where the platform has an organization-wide tag policy or a policy-as-code check, require the tag at creation (check whether your provider offers one; I did not verify DigitalOcean here, est.). In this project the equivalent is CI: the cost gate refuses a stack with no manifest and an unpriced billable resource type, and the plan policy refuses to delete a protected resource.
-
Inventory the whole account, not only the tag. List everything and explain each line. The lab's
audit.shdoes this for the folder. For a cloud account, the read-only listings in Teardown checklist. - A cost report and a spend alert, read by a person. The report shows spend by product and by day; the alert tells you when you cross a line. Neither is a cap.
- Separate accounts for experiments, so there is a small, known set of things that can be wrong.
Now be the person who has to run an account for a team, not the person who made one mistake.
- You inherit an account with 60 resources and no stack for 20 of them. What is the first read-only thing you do, and what do you not do until you have it?
- A teammate says "just tag it by hand afterwards." What is the argument against, and what is the argument for?
- Production's network stack and a service's stack are both yours. Which one may a new hire destroy, and which approvals does the other need?
- A cost report shows $40 for a product nobody on the team uses. Walk through finding the resource without clicking around: which listing, which filter, and which token scope?
- Your destroy script reported success. Name three independent places you would look before you believed it.
Write your answers down. Question 5 has the answer in lessons-learned item 8: resource listings, the provider's console, and tomorrow's billing page.
| Symptom | Most likely cause | What to check |
|---|---|---|
Unsupported attribute or Error in function call on a destroy of a consumer |
The producer was destroyed first | Rebuild the producer, then destroy consumers first (Exercise 5). |
No such file or an empty output on the first plan of a consumer |
The producer was never applied | Build in order. |
Error: Failed to load plugin schemas or provider download fails |
No network to the registry, or a stale cache | Run tofu init again; set TF_PLUGIN_CACHE_DIR to a folder you own. |
| The checker passes but the cloud still bills | The checker listed the wrong account, region or tag | Check the token's account, list without filters, look at the billing page tomorrow. |
proof.sh says "the listing FAILED" |
doctl not installed, not logged in, or a token without read scope |
Fix the credential; do not read a failed listing as zero. |
| The loop stops at cycle 3 | A real failure, not noise | Read the first Error line; fix it; start the loop again from cycle 1. |
All reads were read-only requests on 2026-10-22 unless a different date is shown.
| Claim | Source | Status |
|---|---|---|
| Bundled-plan CPU droplets are billed per second, minimum 60 seconds or $0.01 (whichever is higher), usage capped at 672 hours (28 days) a month; billing starts at create and ends at destroy; powered-off bundled droplets are still billed because compute stays reserved | Droplet pricing (page marked "Last verified 25 Aug 2026") | Read |
The hourly rate of s-1vcpu-512mb-10gb
|
Not read from any vendor page. The script divides the monthly price by 672 | est. |
s-1vcpu-512mb-10gb is $4 and s-1vcpu-1gb is $6 a month |
docs/cost-prices.json, retrieved 2026-10-21 |
Read in this repository. Availability of the $4 size is not recorded for tor1 in docs/cost.md
|
| A spend alert budget "is not a spending cap" | Spend alerts ("Last verified 19 Oct 2026") | Read |
| Destroying a droplet does not destroy its automated backups (they stay four weeks); snapshots, volumes and volume snapshots are only destroyed if selected | Destroy a Droplet ("Last verified 13 Jul 2026") | Read |
| Tags are custom labels for droplets and other resources; cloud firewalls are free and block all traffic not expressly permitted | Tag Droplets and Create a firewall | Read. Which resource types take tags: not checked, est. |
doctl flags used: compute droplet list --tag-name --format --no-header, compute droplet delete --tag-name --force, compute tag list/delete, compute firewall list/delete, vpcs list/delete, compute volume list, compute snapshot list, compute reserved-ip list, compute size list, account get
|
doctl reference pages for each (generated 15 Oct 2026, doctl v2.0.0) | Flag names read; never run (no doctl on the author's machine). --force on firewall, vpcs and tag delete not confirmed, est.
|
GET /v2/droplets?tag_name= filters by tag and needs the tag:read scope |
DigitalOcean OpenAPI source | Read; not called |
terraform_remote_state reads root module outputs, needs access to the entire state snapshot, and does not respect the sensitivity of the source state; publishing explicitly to a separate location is recommended |
OpenTofu: terraform_remote_state | Read |
| Local state is plain-text JSON; state may contain sensitive values | OpenTofu: sensitive data in state | Read |
Spacelift stack dependencies respect tracked runs only; output references use outputs of a successful run that included an apply and map to environment variables (TF_VAR_ for OpenTofu); sensitive-output transfer must be enabled; dependencies do not manage creating or deleting stacks |
Spacelift stack dependencies | Read. Whether a destroy run is ordered by a dependency was not tested |
| The lab behaves as shown (five cycles clean; the wrong-order error; the tag search missing an untagged file) | Run on Linux, OpenTofu 1.12.6, hashicorp/local 2.9.1, 2026-10-22 |
Run |
| The cloud stacks are valid for the provider's schema |
tofu validate with digitalocean/digitalocean 2.106.0, 2026-10-22 |
Validated; never planned or applied |
| This project's stack layout, cost gate, golden addresses, destroy guard and stack-factory status | Files in this repository at the commit this page was written against | Read |
| Everything PowerShell; WSL as a way to run the generator; tag policies on other providers | Written from documentation or general knowledge | est., not verified |
Not verified, plainly: the whole PowerShell track; the cloud path from start to finish (including whether a new droplet is covered by a tag-attached firewall from its first second, and whether the $4 size is offered to your account and region); --force on three doctl delete commands; which DigitalOcean resource types accept tags; whether an organization-wide tag policy exists on DigitalOcean; whether a Spacelift destroy run is ordered by stack dependencies.
Next: Teardown checklist, the project's own version of the destroy order and the proof, with the destroy guard in front of it.
Source for these pages lives in docs/wiki/ and is published by the Wiki workflow on every merge to main. Corrections are welcome as pull requests; edits made in the wiki editor are overwritten on the next publish. Apache-2.0 licensed, see LICENSE and NOTICE in the repository.
Support this project: support.chiefgyk3d.com, or see Home.
Home | Reading order | Why this exists | Give feedback
Core path: read in this order
- Start here if you are new
- What is the cloud
-
Networking basics
- Side page: How a web request works
- Foundations side page: DNS, Cloudflare and hosting your own website
- Foundations side page: Subnets, NAT and firewalls by hand
- Foundations side page: Behind a CDN: real client IPs, why an IP is a weak identifier, and OSI basics
- Foundations side page: Packet filters compared: iptables, nftables, UFW, pf
- Foundations side page: Rate limiting at every layer
- Foundations side page: IPv6: why adoption is slow, why this project is IPv4-only, and how to handle it safely
-
Identity, secrets, zero trust
- Foundations side page: Plan for failure: blast radius and assuming breach
- Side page: APIs and tokens from zero
- Side page: Hashing, encryption and keys
- Side page: Cross-tenant data flows, governance, retention and DLP
- Foundations side page: Enterprise identity: SSO, SAML and OIDC
-
Infrastructure as code
- Foundations side page: Data formats and config files: JSON, YAML, HCL
- Foundations side page: Build and destroy a bunch: one stack per service
- Foundations side page: OpenTofu from zero: init, plan, apply, destroy
-
Git and pull requests
- Side page: Git from zero: your first change
- Foundations side page: The terminal and your first script
-
Containers and Docker (optional)
- Side page: Linux for enterprise beginners
- Foundations side page: Windows, Active Directory and macOS in the enterprise
- Foundations side page: Virtual machines, containers and cloud service models
- Foundations side page: LAMP and LEMP from zero: web server, PHP runtime, database
- How money works
-
Enterprise habits
- Foundations side page: Working in a company: tickets, change, on-call, documentation
- Architecture overview
-
Why ZTNA and not a VPN
- Side page: Zero trust at home: two connectors and failover
- Foundations side page: Threats in plain words
- Foundations side page: Email security: DNS records, gateways, API-driven protection, and running your own server
- Foundations side page: Red and blue as yin and yang, and choosing your lab OS
-
Twingate
- Side page: Twingate service accounts in CAH
-
DigitalOcean network and firewalls
- Side page: Why not private droplets (yet)
-
Droplet hardening
- Foundations side page: Backups, recovery and patching
- Foundations side page: First hour on a new system: updates and a baseline
- Foundations side page: WordPress and PHP security, WAFs and static sites
-
OpenTofu, stacks and modules
- Foundations side page: OpenTofu plans, state, drift and recovery
-
Spacelift
- Foundations side page: Spacelift from zero
-
Doppler and secrets
- Foundations side page: Secrets from day one: a password manager, Doppler for every asset, and the .env rules
- CI, tests and the cost gate
- Threat model
- Lessons learned
- Roadmap
Track: build it yourself
Track: understand the design
The rest of this track is on the core path; see Reading order.
Track: security and threat modeling
- Threat model exercise
- Threat Dragon exercise
- Threat frameworks: STRIDE, ATT&CK, ATLAS
- Troubleshooting index
The Threat model is core step 19.
Track: detection and monitoring
- A small example: the enterprise landscape
-
Detection, Wazuh and the landscape
- Foundations side page: Logs, SIEM and detection basics
- Side page: File integrity and alerting: Tripwire, AIDE, Wazuh FIM, auditd, fail2ban
- Foundations side page: IDS and IPS: Snort and Suricata
- Side page: Deception and honeypots: start here
- Cloud telemetry: what and why (unverified draft)
- Multiple sites and identity (unverified draft)
- Telemetry crosses the trust boundary (unverified draft)
- Reading a dashboard as an operator (unverified draft)
- Build your own mini SOC (unverified draft)
- Stage 2 home-side runbook (unverified draft)
- Grafana dashboards (unverified draft)
- Lab: synthetic multi-site monitoring stack