Case study · Our own infrastructure
How we run our own infrastructure as code
We rebuilt the pipeline for our own Terraform estate so that a merge only ever produces a plan, and every apply, destroy check included, works on the plan file we reviewed.
At a glance
- Sector
- Our own infrastructure
- Duration
- July 2026 to October 2026 (the repository dates from 2022).
- Our role
- design, build and operation.
- Team
- TQM Invest, one engineer, one repository.
- Terraform
- GitHub Actions
- AWS (OIDC)
- Cloudflare
- New Relic
- jq
Headline results
- Four near-duplicate CI workflows (about 1,240 lines) replaced by one caller and one reusable stack workflow, and no stack applies on merge.
- A destroy gate that refused 23 deletions until they were approved, and since October 13, 2026 counts them in the reviewed plan file that is about to be applied.
- An alerting audit found 3 of 7 conditions watching data that never existed and the one real alarm reaching no one; both fixed, with a test notification delivered.
The situation
Our estate is small: four Terraform stacks (the cloud account, DNS, the hypervisor and the observability account) plus configuration management for the hosts, in one repository, run by one engineer. Most of it had been code for years. By the summer of 2026 that was not the same as being run as code. Each stack had its own CI workflow, written to the same shape on purpose, and our monitoring dashboard was green.
Two questions shaped the work. Can a merge change infrastructure? And does an alert that looks healthy reach anyone? Both answers turned out to be different from what we believed.
What we did
A merge that applied infrastructure
On September 22, 2026 a pull request added one comment to a hypervisor module. Three minutes after the merge, a pending change ran two configuration playbooks. Both playbooks are idempotent and the run ended with failed=0, so nothing broke. But the rule we believed, that merging never applies, was false: one of the three per-stack workflows applied on push to main. We had read all three files that day. Compared line by line, they differed in six ways.
One pipeline: plan on the pull request, apply by dispatch
We replaced the four workflows (about 1,240 lines of near-duplicate YAML) with one caller and one reusable stack workflow (296 and 459 lines), so the plan and apply logic exists once. The caller lists each stack as a literal job, which keeps the dependency graph visible in the Actions UI.
- Pull requests plan every stack with read-only cloud credentials and post one comment per stack.
- A push to
mainplans again, uploads each plan as an artifact, and stops. - Applying is a manual dispatch. An apply never applies a fresh plan; it applies the reviewed artifact built on
main. The dispatch finds the newest unexpired plan artifact frommain, downloads it and applies that file. If state has moved since the plan was made, Terraform refuses the saved plan, and we treat that refusal as intended.
Cloud access uses OIDC with two roles: a read-only one that pull requests can assume for plans, and a write role that trusts main only. A dispatch with the apply box unticked doubles as a drift survey: it plans every stack and fails if any of the four would change.
A destroy gate on the plan that gets applied
Any plan that deletes or replaces a resource needs that stack's own checkbox ticked on the dispatch. A replace counts, because a replaced VM is a rebuilt VM. The first version took a typed list of stack names, and a typo (aws-platform for aws/platform) read as "no permission" and skipped the stack. A checkbox cannot be mistyped.
We tested that first version against a plan that really deleted things before relying on it: 23 unused cloud resources we had adopted into Terraform in order to remove them. Without approval, the stack failed with zero destructions while the other stacks applied normally. With the box ticked, all 23 were destroyed, and we checked in the console that everything meant to survive had survived.
Writing the companion article, we found that the gate counted destroys in the fresh plan the apply job makes to decide whether there is anything to do, and then applied the downloaded artifact. A reviewed plan with a destroy could pass on a day when the fresh plan happened to be clean. Since October 13, 2026 the gate runs after the download, on the same file the apply step uses. An illustration:
# After the reviewed artifact is downloaded, before it is applied.
terraform show -json tfplan > reviewed.json
count() { jq "[.resource_changes[]? | select($1)] | length" reviewed.json; }
del=$(count '.change.actions == ["delete"]')
rep=$(count '.change.actions == ["delete","create"] or .change.actions == ["create","delete"]')
if { [ "$del" != "0" ] || [ "$rep" != "0" ]; } && [ "$CONFIRMED" != "true" ]; then
echo "::error::$STACK reviewed plan destroys or replaces. Tick 'Allow DESTROYS in $STACK' to apply."
exit 1
fi
terraform apply -input=false tfplan # the same file the gate just read
The new gate passed on the reviewed plan in its first two real applies the same day.
An alerting audit
The first inventory of our observability account read 4 policies, 7 enabled conditions and 3 uptime checks, and looked healthy. The same morning, September 23, 2026, a host CPU alert had fired. So we ran a second pass that asked two questions of everything: does each condition's query return data, and does any notification workflow include each policy?
- Three conditions queried application-performance metrics. A 30-day count of any such metric returned 0, and the account had no instrumented applications at all. Those conditions could never fire.
- The CPU condition was real and had fired CRITICAL. Its policy was in none of the workflows' filters, so it reached no one.
We first brought the three working uptime checks under Terraform with a module that builds the monitor, policy, condition and workflow as one unit, so a policy always has a delivery path. Our rule for adoption is that the plan must read zero changes. The first plan showed two (a different aggregation window on one check, a different name on one condition), and both became explicit inputs; the final plan imported 12 resources with 0 added, 0 changed and 0 destroyed.
On October 13, 2026 we finished the fix. We deleted the policy that held the three dead conditions and the unrouted CPU condition, rebuilt the CPU alert through a module, routed it to the chat channel we read, and sent a test notification, which was delivered. One detail cost us an apply: New Relic binds a notification channel to a single workflow, so reusing an existing channel fails with INVALID_PARAMETER. The module now creates its own channel on a shared destination.
Results
| Area | Before | After |
|---|---|---|
| Pipelines | 4 workflows, about 1,240 lines, 6 differences, one applied on merge | 1 caller and 1 reusable workflow; applies by dispatch only |
| Destroy gate | counted a fresh plan, applied the reviewed one | counts and applies the same reviewed file |
| First gate test | unapproved: 0 destroyed; approved: 23 destroyed | |
| Drift survey | 3 of 4 stacks could fail it | all 4 stacks, verified October 13, 2026 |
| Alerting | 3 of 7 conditions dead; CPU alarm unrouted | dead conditions deleted; CPU alarm routed; test delivered |
| Uptime checks | hand-made | 12 resources imported, 0 changed |
Related reading
- A destroy gate for Terraform on GitHub's free plan
- Alerts that can never fire: judge by the evaluator, not the preview
Go deeper
Standalone write-ups of the methods in this study.
Related case studies
An events and entertainment brand
Moving an events brand off WordPress, with one gated path to production
We moved an events and entertainment brand off WordPress onto one application whose only road to production is a test-and-audit gate, and resolved every legacy URL that search engines reported as missing.
One gated path to production since July 2026. Every production deploy passes a dependency audit and the unit suite; lint joined the gate on September 6, 2026.
- Next.js
- React
- TypeScript
- Prisma
- Postgres (Neon)
A North American medical-device company
Infrastructure for a medical-device company's clinical platform
Since December 2024 we have run the infrastructure under a medical-device company's clinical platform, and today no deploy opens SSH from CI to any of its hosts.
No deploy opens SSH from CI: host addresses, ports and private keys left the CI secrets in January 2026.
- AWS EC2
- VPC
- RDS
- Docker Swarm
- Docker Compose
A global industrial manufacturer
Three years running a manufacturer's hybrid estate
We measured that 95% of an industrial manufacturer's monitoring tickets closed themselves, then moved its monitoring to New Relic in a way that cut redundant alerting by about 80%, by our records.
About 650 monitoring tickets in 30 days, 95% closing themselves and none resolved at first line: the baseline we measured before the move.
- New Relic
- Zabbix
- ServiceNow
- AWS
- Azure
Running into something like this?
Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.