Building a NIS2-Compliant AWS Landing Zone — Week 1: Foundations
Last week I laid out the plan: an open-source AWS landing zone in Terraform, mapped to NIS2 and ISO 27001, built on LocalStack so it costs nothing to run. This week the foundation goes in.
By the end of Week 1 the repo had two Terraform modules, nine tests, seven CI checks on every pull request, and branch protection that refuses any direct push to main. None of that is exciting to look at. But it's the part most portfolio projects skip, and it's the first thing I'd check if I were reviewing someone else's repo — so it's where I started.
Here's how it came together, and the one rule that ended up shaping everything after it.
Start with the rule: track the risk, don't hide it
Most "secure infrastructure" demos go for a clean scanner dashboard — every finding suppressed, every badge green, nothing left to look at. I don't think that proves anything. Real infrastructure has open findings. What matters is whether each one was seen, judged, and written down with a reason.
So before I wrote a single resource, I set a rule for myself: every scanner finding gets one of four labels — fixed, deferred (with a target date), accepted (out of scope, with a written reason), or suppressed (with the reason inline in the code). The little decision log that records those calls is the thing I'd actually hand an auditor. The green badge is just what you get once you've done that honestly.
That rule is why the project has a decision log at all, and it's why the later weeks stayed calm instead of turning into a scramble to silence warnings.
Two modules, one clean seam
Week 1 ships two modules: a KMS key and a secure S3 bucket. The interesting bit isn't either module on its own — it's how they connect.

The KMS module hands out a key_arn. The S3 module takes a kms_key_arn. The root config wires one into the other:
module "kms_s3_baseline" {
source = "../../modules/kms"
# ... CMK with rotation, alias, regulatory tags
}
module "s3_baseline_logs" {
source = "../../modules/s3-baseline"
kms_key_arn = module.kms_s3_baseline.key_arn # this is the seam
# ...
}
That one line — the KMS key's ARN passed into the bucket — is the whole idea the project rests on. Small modules with clear inputs and outputs, wired together at the root, beat one giant do-everything module. Week 2 leans on this hard, so it was worth getting right early.
The S3 baseline: secure unless you go out of your way
The bucket module isn't just "a bucket." It won't come up in an insecure state:
- encrypted with the customer-managed KMS key, not the AWS-managed default
- all four public-access-block settings turned on
- a bucket policy that denies any request not using HTTPS
- versioning on by default
- lifecycle rules available behind a variable
The TLS-only deny is worth showing, because it's the kind of control NIS2 expects and most tutorials leave out:
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [bucket_arn, "${bucket_arn}/*"]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
}
A Principal = "*" inside a Deny is actually the safest possible scope — it denies everyone who isn't on TLS, including future callers I haven't thought of yet. A scanner will flag that wildcard anyway, and that's fine: it goes in the decision log as accepted, with the reason, rather than being "fixed" into something weaker just to clear the warning.
Testing the things you can't see
Two modules deserve more than "it applied without errors." Native terraform test lets you assert against the plan and against a real apply on LocalStack, and there are two kinds of test that matter here.
The positive ones check the happy path: rotation is on, all four public-access settings are true, encryption uses the key I passed in.
The negative ones are the ones I care about more — they check that the guardrails actually stop bad input:
run "rejects_invalid_bucket_name" {
command = plan
variables { bucket_name = "Invalid_Name_With_Caps" }
expect_failures = [var.bucket_name]
}
A test that proves your validation blocks a bad name is worth more than three that prove the good name works. Anyone can make the happy path pass.
The CI pipeline is the real source of truth
"Works on my machine" doesn't count. Every check that matters runs in CI on every PR, and branch protection makes it impossible to merge around them:
| Stage | What it does |
|---|---|
terraform fmt | Formatting, enforced rather than asked for |
tflint | Catches provider-specific mistakes plain parsing misses |
tfsec | Security scan — findings go to the decision log |
checkov | Second security scan, different rules |
terraform test | The module and integration suites actually run |
infracost | Posts a cost estimate on the PR |
terraform-docs | Regenerates module READMEs on merge |
The two security scanners run in soft-fail mode — they report findings but don't block the merge. That's the same track-the-risk idea, just built into the pipeline: the judgment I record in the decision log is the gate, not a scanner's pass/fail exit code.
What broke
No foundation goes in cleanly, and mine didn't either. The problems all lived in the gap between "works locally" and "works in CI":
terraform testwouldn't reliably pick up a separateprovider.tfin the test directory — the provider config had to go in the test's own wiring file instead.- CI runs its containers as a different user than the surrounding steps, so the auto-generated docs couldn't be committed until I sorted out the file ownership.
- Branch protection broke the docs automation the moment I turned it on — the workflow had been pushing straight to
main, which protection now (correctly) rejected, so I had to move it to a pull-request flow.
Each of these turned into a short decision record in the repo. Honestly, the failures are the part worth reading — they're where I actually learned something, as opposed to copying a working snippet.
NIS2 coverage after Week 1
| Resource | NIS2 Art. 21(2) | ISO 27001:2022 |
|---|---|---|
| KMS CMK + rotation | (h) cryptography | A.8.24 |
| S3 SSE-KMS encryption | (h) cryptography | A.8.24 |
| S3 versioning | (c) business continuity | A.8.13 |
| TLS-only bucket policy | (h) cryptography | A.8.24 |
| Public access block | (i) access control | A.8.3 |
Five control mappings from two modules. Every resource carries tags naming the NIS2 measure and ISO control it satisfies, so the compliance mapping comes out of the infrastructure itself instead of living in a separate doc that drifts out of date.
Where this leaves things
Two modules, connected through one clean seam. Tests that prove the guardrails bite, not just that the happy path works. A CI pipeline that's the real source of truth, with a decision log instead of a for-show dashboard. A foundation — which is exactly what Week 1 was supposed to be.
Next week: the logging layer. CloudTrail with log file validation, AWS Config for continuous compliance, and VPC Flow Logs for network audit — plus the moment the project hit its first "that's a paid feature" wall, and what I did about it.
Repo Link: github.com/olanak/aws-nis2-baseline