Infrastructure as Code for small teams: a minimal, honest Terraform setup for 2 3 engineers covering state, secrets, drift, blast radius, and when to delete IaC.
Our entire production infrastructure is three Terraform files and a shell script, and a client once asked me, with genuine concern, where the rest of it was. He'd come from a company with a platform team, forty repos of reusable modules, a golden-path CLI, and a Slack channel called #terraform-help that never slept. He assumed we had a smaller version of all that hiding somewhere. We didn't. We deleted most of it, on purpose, because it was solving problems we didn't have.
Small teams get sold a version of infrastructure-as-code that was designed for organizations of two hundred engineers. The tooling is real and the patterns are sound, but the assumptions underneath them — many teams, shared platforms, blast radius measured in customers — don't hold when it's you and two other people shipping a product. Copy those patterns wholesale and you get all the ceremony with none of the payoff. This post is the minimal, honest version of IaC I actually run: enough discipline to sleep at night, not so much that maintaining the infra eats the week you needed for the product. It's Terraform on Google Cloud, but the judgment calls port cleanly to AWS, Azure, or Pulumi — the tool matters far less than the philosophy behind how much you automate.
The industrial-complex version of Terraform looks like this: a monorepo of versioned modules, a terragrunt wrapper generating configs, remote state per micro-environment, a CI pipeline that plans on every PR, an Atlantis bot, drift detection cron jobs, and a naming convention document longer than the actual infra. Every one of those exists to solve a coordination problem — many people touching the same infra without stepping on each other.
You don't have a coordination problem. You have three people who talk to each other every day. When the thing IaC is designed to coordinate is a conversation you could have over coffee, the tooling becomes pure overhead. This is the core misread: infrastructure-as-code for large teams is mostly a communication technology — a way to make one team's changes legible to another team that will never be in the same meeting. At three people, you already have that legibility for free.
The specific traps I fell into early:
terraform plan on every push and posted the diff, which nobody read because the person who pushed already knew what they changed. Automation that produces an artifact no human consumes is just a slower way to burn CI minutes.The rule I landed on: your IaC should be proportional to the number of people who need to not surprise each other, not to how impressive the setup looks in a diagram. When you're the whole platform team, the platform is allowed to be small.
Not everything belongs in Terraform. The zealot position — "if it's not in code it doesn't exist" — costs more than it saves at this scale. The real question is: if this thing disappeared, how much would it hurt, and could I recreate it from memory?
My split, roughly:
| Put it in code | Fine to click once |
| --- | --- |
| Databases, buckets, and anything holding data | The GCP project itself |
| IAM roles and service accounts | Billing account and budget alerts |
| Cloud Run / Cloud Functions services | OAuth consent screen |
| DNS records and load balancer config | The org-level "turn on this API" toggles |
| Anything with a blast radius bigger than one service | One-off debugging permissions you'll revoke tomorrow |
The logic: code the things that are easy to get wrong, painful to lose, or that you'll change more than twice. Click the things you set up once during onboarding and never touch again. Nobody is impressed that your billing account is in Terraform, and the day you need to recreate it, the console walks you through it anyway.
A useful tiebreaker for the ambiguous middle: how reproducible does this need to be, and how often does it change? A resource that's high-reproducibility-need but low-change-frequency (a Cloud SQL instance) absolutely belongs in code, because when you do need to recreate it you want it exact. A resource that's low-reproducibility-need and low-change (the OAuth consent screen) is a click. Frequency alone isn't the deciding factor — consequence-of-getting-it-wrong is.
The one addition I make: a plain README in the infra folder that lists the clicked-once things, so a new teammate knows the boundary. That README is doing more work than half the code would, because it's the only place the "this was set up by hand, on purpose" decision is written down. Undocumented manual setup is how a three-person team accumulates the mysteries that a departing founder takes with them.
Multiple environments are where small teams lose the most time to no benefit. Every environment is a copy you have to keep honest. Drift between them is the single most common way I've seen a "works on staging" deploy blow up in prod.
For most small products, you need two: a shared dev/staging where you can break things, and prod. Not four. And you drive both from one variable file per environment, not scattered defaults.
# environments/prod.tfvarsproject_id = "shpper-prod"region = "me-central1"min_instances = 1db_tier = "db-custom-2-8192"domain = "app.example.com"
# environments/staging.tfvarsproject_id = "shpper-staging"region = "me-central1"min_instances = 0db_tier = "db-f1-micro"domain = "staging.example.com"
Same main.tf, same modules, only the .tfvars differs. Note what's varying and what isn't: the shape of the infrastructure is identical, only the knobs move — instance count, machine size, domain. That's the whole point. Staging with min_instances = 0 scales to zero and costs nothing when idle; prod keeps one warm instance to dodge cold starts. Same code path, different dial.
If staging and prod need structurally different resources — not just different sizes — that's a smell. It usually means someone hand-edited prod in the console and never brought it back into code. Which brings up the real enemy: drift.
Drift is when reality and your code disagree. Someone bumps a memory limit in the console at 2am during an incident and forgets to codify it. Now your Terraform is lying. The next apply silently reverts the fix, and you're debugging the same outage twice — except the second time it's a self-inflicted regression, which is a worse feeling.
My cheap defense, no drift-detection service required:
terraform plan before every apply and actually read it. If it shows changes you didn't make, someone drifted the infra. The plan is a diff against reality, not against your last commit — that's exactly why it catches console edits.terraform plan in CI on the main branch weekly. Not on every PR (theater), just a heartbeat that catches drift before it compounds. A weekly plan that comes back clean is a genuinely reassuring signal; one that comes back dirty is a five-minute investigation instead of a mystery six months later.Two things reliably wreck a small-team IaC setup, and neither is glamorous.
State. Do not keep Terraform state on your laptop. I did this for exactly one project, until the day two of us applied from different machines and the state forked. Remote state with locking is fifteen minutes of setup and saves you a genuinely awful afternoon. A forked state file is one of the few IaC failures with no clean recovery — you're hand-reconciling two versions of what Terraform thinks exists against what actually exists.
terraform { backend "gcs" { bucket = "shpper-tf-state" prefix = "prod" }}GCS gives you locking for free — it takes a lock on the state object during an apply, so a second concurrent apply waits instead of racing. S3 historically needed a DynamoDB table for locks (newer Terraform versions support S3-native locking, but check your version) — fine, but that's one more thing to reason about. Whatever you pick: remote, versioned, locked. Turn on object versioning on the bucket so a corrupted state is recoverable instead of terminal. Versioning is the difference between "restore yesterday's state file" and "reconstruct our entire infrastructure from what I can find in the console."
Secrets. The cardinal sin is a plaintext secret in a .tfvars file that lands in git. Terraform state also stores secret values in plaintext, which surprises people — so a public state bucket is a data breach, not a config mistake. This is the one that catches teams off guard: they lock down .tfvars and forget that the resolved value gets written into state anyway.
My rules:
.tfvars with anything sensitive is gitignored, and the values live in a password manager the three of us share.data "google_secret_manager_secret_version" "db_password" { secret = "prod-db-password"}resource "google_cloud_run_v2_service" "api" { # ... template { containers { env { name = "DB_PASSWORD" value_source { secret_key_ref { secret = data.google_secret_manager_secret_version.db_password.secret version = "latest" } } } } }}The key detail here is value_source / secret_key_ref: Cloud Run pulls the secret at deploy time from Secret Manager, so the value is never baked into the service definition or exposed in a terraform show. Compare that to a plain value = var.db_password, which would land the secret in state in the clear. Same env var, wildly different security posture.
None of this is clever. It's the boring hygiene that decides whether a bad Friday stays a bad Friday or becomes a bad quarter. The unglamorous stuff — state backends, secret handling, bucket permissions — is precisely the stuff that has no product deadline attached, which is exactly why it gets skipped until it bites.
You don't have a platform team to gatekeep infra changes. You still want a second pair of eyes on the thing that can delete your database. The trick is making review lightweight enough that it happens, because a heavy process just gets bypassed. A review policy nobody follows is worse than no policy, because it lets you believe you're covered when you aren't.
What works for us:
terraform plan output into the description. The reviewer reads the plan, not the HCL diff. HCL diffs hide impact; a plan says "1 to add, 0 to change, 1 to destroy" in plain English, and that word destroy is where all the attention should go. A three-line HCL change can produce a plan that recreates your database — the diff won't tell you that, the plan will.destroy or a replace gets a second person, full stop. That's the entire policy. No approval matrix, no CODEOWNERS gymnastics. The policy is short enough to hold in your head, which is the only kind of policy a small team reliably follows.The point isn't process for its own sake. It's that the review question is scoped to what actually hurts. On a normal service tweak, review is thirty seconds. On something that recreates a database, it's a real conversation. Same lightweight mechanism, attention proportional to blast radius.
Here's the rule I care most about: before every apply, know what can't come back.
Terraform's plan output sorts everything into add, change, destroy, and replace. The dangerous words are destroy and, sneakier, replace — because a replace is a destroy-then-create, and for stateful resources that means the data is gone in between. replace is the one that gets people, because it reads like a modification when it's actually a deletion wearing a modification's clothes.
The classic footgun: you rename a database resource, or change an attribute Terraform can't modify in place (like a Cloud SQL instance's region). Terraform cheerfully plans to destroy the old one and create a new one. On a Cloud Run service, who cares — it's stateless, it comes back in thirty seconds. On a database, that's your production data, deleted, to satisfy a diff. Terraform is doing exactly what you asked; the problem is that "make reality match this config" and "don't lose my data" are two different goals, and Terraform only knows about the first one.
Why does a rename trigger a replace? Because Terraform tracks resources by their address in state, not by any real-world identity. Rename google_sql_database_instance.db to google_sql_database_instance.main and Terraform sees the old address vanish and a new one appear — it has no idea they're the same instance. That's what terraform state mv is for: it renames the resource in state so Terraform understands it's the same object, no destroy required.
My blast-radius checklist before hitting yes:
0 to destroy, relax.terraform state mv to rename in state, or add a lifecycle { prevent_destroy = true } block that turns the footgun into a hard error.resource "google_sql_database_instance" "main" { # ... lifecycle { prevent_destroy = true }}prevent_destroy on every stateful resource is the single highest-leverage line in my whole setup. It has saved me from myself more than once. It turns "oops, apply ran" into "Terraform refused, go think about it." The failure mode it prevents isn't malice or incompetence — it's a tired person running apply at the end of a long day and skimming the plan. That person is you, eventually, and this line is you looking out for that version of yourself.
Not everything wants to be declarative. Terraform is a graph of desired state; some tasks are just a sequence of steps. Forcing a sequence into HCL with null_resource and local-exec is how you build something nobody can debug — you've smuggled imperative logic into a declarative tool and lost both the readability of a script and the guarantees of real Terraform resources.
I reach for a plain shell script when the task is:
#!/usr/bin/env bashset -euo pipefail# deploy.sh — build, push, and roll out. Boring on purpose.IMAGE="me-central1-docker.pkg.dev/shpper-prod/app/api:$(git rev-parse --short HEAD)"docker build -t "$IMAGE" .docker push "$IMAGE"terraform -chdir=infra apply \ -var-file=environments/prod.tfvars \ -var="image=$IMAGE" \ -auto-approveecho "Deployed $IMAGE"
Two details in there earn their keep. First, tagging the image with git rev-parse --short HEAD means every deploy is traceable to an exact commit — no :latest ambiguity about what's actually running in prod. Second, set -euo pipefail matters more than any module: -e stops on the first error, -u treats an unset variable as an error instead of an empty string, and -o pipefail makes a failure anywhere in a pipe fail the whole command. Without it, a script plows ahead pretending everything worked, and you find out at the worst possible moment.
The line I draw: Terraform owns what exists. Shell scripts own what happens. When I catch myself writing procedural logic inside HCL, that's the signal it belongs in a .sh file that Terraform never knows about. Declarative and imperative are both fine — mixing them inside one tool is where the pain lives.
The last piece of discipline is the one nobody talks about: being willing to remove IaC when it's costing more than it returns.
I had a Terraform config managing our staging environment's DNS through a provider that broke on every other release. Maintaining it cost an hour a month in provider-upgrade yak-shaving. The DNS changed maybe twice a year. I deleted the config, terraform state rm'd the records so Terraform would stop trying to manage them, and now we change them by hand in the console — with a note in the README. Net time saved: real. Net risk added: a DNS change I make twice a year and can see in the console. Easy trade.
Note the mechanism: terraform state rm removes the resource from state without destroying it. The DNS records keep existing; Terraform just forgets it was ever responsible for them. That's the safe way to hand a resource back to manual management — never delete the resource block and apply, which would tear the real thing down.
Signs a piece of IaC has stopped earning its place:
Deleting IaC isn't failure. It's the same judgment call as adding it, run in reverse. The goal was never "everything in code." The goal was fewer bad surprises for the fewest hours spent. Sometimes a console click and a README line beat a hundred lines of HCL, and pretending otherwise is just ideology. The teams that stay fast are the ones that treat their own automation as something that has to keep justifying its existence — not as a monument.
README so the manual boundary is documented, not tribal knowledge..tfvars each, one source of truth. Same shape, different dials. Fight drift by reading every plan and codifying emergency console edits before the incident ticket closes.secret_key_ref, never pass the value through a variable.destroy or replace. A three-line HCL change can quietly recreate your database; only the plan reveals it.prevent_destroy on every stateful resource — it's the cheapest insurance you'll ever buy, and it protects you from the tired version of yourself skimming a plan at 6pm.set -euo pipefail, commit-pinned image tags) and keep procedural logic out of HCL.terraform state rm to safely hand a resource back to manual management. Removing code is a valid engineering decision, not a retreat.