← Back to writing
2026-06-29

Building a NIS2-Compliant AWS Landing Zone — Documentation

AWSTerraformCI/CD

Four weeks of building left me with eleven modules, nine of ten NIS2 measures, and a pile of decisions recorded in a workspace nobody but me can see. Week 5 was about turning that into something a reviewer could actually read — and closing the last measure, which turned out not to be a module at all.

The build weeks had a rhythm: probe, code, test, merge. Week 5 broke it. No new modules, no LocalStack probing, no apply-mode-versus-plan-mode calls. The deliverable was a documentation layer someone in a regulated-industry hiring loop — or an auditor — could open and trust top to bottom. Different work, different risk. The danger here isn't a failing apply; it's drift — docs that quietly contradict the code, or restate something in a way that goes stale the moment the next commit lands. So the rule for the week was single source of truth: every fact lives in one place, and the repo docs get consolidated from the canonical record, not hand-copied to rot later.

What I wrote

Five documents, all under docs/:

  • architecture.md — the full module composition, data flows, and the dev/prod environment structure, current as of the detection layer.
  • nis2-control-mapping.md — every resource mapped to its NIS2 measure and ISO control, with an evidence column naming what an auditor would actually inspect.
  • iso27001-crosswalk.md — the bridge from NIS2 Article 21 to ISO 27001:2022 Annex A.
  • supply-chain.md — the last measure.
  • learning-log.md — a curated version of the decisions worth keeping.

The one I'd point at first is the crosswalk.

The crosswalk is the part that matters

Most NIS2 material maps to NIS2 and stops there. But almost every organization that cares about NIS2 already runs — or is building — an ISO 27001 ISMS, and the two frameworks overlap heavily. NIS2 Article 21 says what your security program must address; ISO 27001 Annex A gives you controls that implement it. Map them against each other and the same Terraform, the same tests, the same evidence serve both frameworks at once.

So for each of the ten NIS2 measures I wrote out the corresponding Annex A controls and where the repo implements them. Incident handling (b) maps to A.5.24 through A.5.26 plus the logging and monitoring controls; cryptography (h) to A.8.24; access control (i) to the A.5.15/A.8.2/A.8.3 cluster. I was careful to flag it as interpretive — NIS2 doesn't mandate ISO, and this is a defensible reading, not a certified equivalence. But that framing is the differentiator: it turns "here's a NIS2 demo" into "here's infrastructure whose control evidence you can reuse across both regimes."

The last measure isn't a module

Nine measures mapped cleanly to deployed resources. The tenth — (d), supply chain security — doesn't, and forcing it into a module would have been theater. Supply-chain security for an infrastructure-as-code project is about how you source, pin, verify, and trust your dependencies, not about a resource you deploy.

So I wrote it down as posture instead, honestly: the AWS provider pinned and checksum-locked, first-party modules only (no third-party registry pulls), a pinned LocalStack image, CI scanners gating every PR, and the AWS shared-responsibility relationship with region-lock enforcing EU data residency. And I drew the line plainly between what's enforced and what's stance — there's no formal SBOM generation, no automated dependency-bump bot. Saying that out loud is itself part of the measure: knowing the boundary of what you actually enforce.

The doc that lied, and the gitignore that caused it

Here's the part I'm gladest I caught. The supply-chain doc claimed the provider was "locked in a committed .terraform.lock.hcl with cryptographic checksums." Before shipping, I ran a quick check on whether each claim in the doc was actually true — and the lockfile one wasn't. There was no committed lockfile.

The cause was one line in .gitignore excluding .terraform.lock.hcl. That's a genuinely common mistake — people lump the lockfile in with the .terraform/ directory and state files — but it's exactly backwards. The lockfile is the thing you want committed; it's what pins provider versions and verifies their checksums across every machine and CI run. So I had written a doc asserting a control I'd accidentally configured the repo to throw away.

The fix was to make the claim true rather than soften it: removed the gitignore line, generated lockfiles for both the dev and prod roots with multi-platform hashes, and committed them. Now the doc describes real infrastructure. A document that asserts a control you don't have is worse than no document — the whole point of writing this down is that someone can trust it. Verifying my own claims before publishing them is the small discipline that keeps the compliance story honest instead of aspirational.

Where this leaves things

The repository now reads top to bottom: architecture, control mapping with audit evidence, the ISO crosswalk, the supply-chain posture, and a curated log of why things are the way they are. With (d) documented, all ten NIS2 Article 21 measures are addressed — nine as deployed Terraform, one as honest posture.

NIS2 Article 21 coverage: ten of ten. The build is, functionally, complete.

One week left, and it's the one the whole project was pointing at: the real-AWS run. Everything has lived on LocalStack at zero cost. Week 6 spins it up on actual AWS — the plan-mode modules finally apply for real, the KMS-to-SNS permission chain gets confirmed against an enforcing cloud, a single bounded run under budget alarms, then torn down. Plus the capstone: what it cost, what held up, what I'd do differently. That's the finale.

Repo Link: github.com/olanak/aws-nis2-baseline