Building a NIS2-Compliant AWS Landing Zone - The Logging Layer
Last week was the foundation — a KMS key and a locked-down S3 bucket, wired together, with tests and a CI pipeline behind branch protection. This week is about the thing every incident investigation depends on: the logs.
You can't investigate what you never recorded. NIS2 Article 21 leans on this in two places — (b) incident handling and (f) checking whether your controls actually work — and neither is possible without solid logging. So Week 2 builds the three kinds of audit trail: API, configuration, and network. It's also the week I hit the first "wait, that's behind a paywall" wall, and the week the CI setup got properly tested.
Three kinds of audit trail

Three services, one shared shape: each writes KMS-encrypted logs, CloudTrail and Config both deliver to the same S3 bucket, and CloudTrail and Flow Logs also stream to CloudWatch Logs — which sets up the real-time detection work coming in Week 4.
The wall: CloudTrail is a paid feature
The first terraform apply of the CloudTrail module failed with a flat HTTP 501: the CloudTrail API wasn't implemented, or was a Pro feature. LocalStack's free tier doesn't emulate it.
My first instinct was to work around it — add a flag so the module stays plan-only when running locally. Then I checked the GitHub Student Developer Pack and found that LocalStack offers a Student plan with the full Pro feature set, free while you're enrolled. So the "limitation" was really just a tier I hadn't claimed yet, and the better tier was sitting there for nothing.
Claiming it instead of coding around it raised the ceiling for the whole project. Everything after this — Config, GuardDuty, Security Hub, Organizations — now applies for real, both locally and in CI, without any environment-specific flags cluttering the code. The lesson I wrote down: get your local environment to match production rather than building workarounds you'll forget to rip out later.
The refactor: let callers add, never remove
CloudTrail needs to write to an S3 bucket — the same logs bucket the Week 1 module creates. But that bucket already had a locked-down policy, and CloudTrail needs a couple of extra statements layered on top. Two tempting-but-bad options: bake CloudTrail's needs into the generic bucket module, or let callers swap out the whole policy and risk dropping the baseline protections by accident.
The cleaner answer was to keep the baseline statements fixed and give callers a slot to add to:
locals {
baseline_policy_statements = [
# TLS-only deny + encryption-required deny — always there
]
all_policy_statements = concat(
local.baseline_policy_statements,
var.additional_policy_statements, # callers add; they can't remove
)
}
Callers compose what they need, and the baseline can't be weakened. The payoff showed up a day later: when AWS Config arrived needing its own delivery permissions on the same bucket, wiring it in was a twelve-line concat instead of a refactor.
CloudTrail, past the defaults
The trail isn't just switched on — it's set up the way an auditor would want:
resource "aws_cloudtrail" "this" {
is_multi_region_trail = true # every region, not just the home one
enable_log_file_validation = true # SHA-256 + RSA tamper detection
kms_key_id = var.kms_key_arn
cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.trail.arn}:*"
}
Log file validation is the part that's easy to overlook. It produces signed digests, so an auditor can prove the logs weren't edited after the fact — which is basically what NIS2 (f), "assess whether your measures are effective," needs in practice. No clause names the feature, but the requirement effectively assumes it. Retention defaults to 365 days, because Article 23's reporting timeline (24-hour early warning, 72-hour notification, one-month final report) means an investigator needs logs going back well before the incident started.
AWS Config: checking the running resources
CloudTrail records what happened. AWS Config records what is — the actual state of every resource, checked continuously against rules. I set up a recorder, a delivery channel, and six managed rules, each one tied to a specific NIS2 measure:
| Config rule | NIS2 21(2) |
|---|---|
S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED | (h) cryptography |
S3_BUCKET_PUBLIC_READ_PROHIBITED | (i) access control |
CLOUD_TRAIL_ENABLED | (b) incident handling |
CLOUD_TRAIL_LOG_FILE_VALIDATION_ENABLED | (f) effectiveness |
ENCRYPTED_VOLUMES | (h) cryptography |
IAM_USER_MFA_ENABLED | (i) access control |
The difference that matters: the static scanners (Checkov, tfsec) check my code at PR time, while Config checks the running resources, continuously, every time something changes. Both belong in a real setup. And six separate rules, each carrying its own NIS2 tag, tell a clearer story to an auditor than one bundled conformance pack would — every rule points back to a specific clause.
VPC Flow Logs, scoped honestly
The third trail is network traffic: a two-AZ VPC with public and private subnets, NAT for outbound, and Flow Logs capturing all traffic to a KMS-encrypted log group.
One thing worth being precise about, because overstating it is a quick way to lose a reviewer's trust: flow logs capture metadata, not contents. The 5-tuple — source and destination IP, ports, protocol — plus the action and byte counts. Not the packets themselves. So "network visibility" here means I can reconstruct who talked to whom and how much, which is what you need to trace lateral movement or data leaving the network. It does not mean I can read what was sent.
A small trap I nearly fell into: the flow-logs IAM role has to trust vpc-flow-logs.amazonaws.com, not ec2.amazonaws.com. Flow logs are delivered by the flow-logs service, not by EC2 — get the principal wrong and the log shows up as active while quietly delivering nothing.

What broke: the CI cascade
The hard part of Week 2 wasn't the modules — it was the CI plumbing, which had quietly been broken since I turned on branch protection. It came apart in five connected pieces, which I had to unwind one at a time:
- Branch protection blocked the docs bot from pushing to
main, so I moved the auto-docs workflow to open a pull request instead of pushing directly. - A container was running as the wrong user, so the bot couldn't write the generated files — fixed by sorting out the workspace ownership.
- The docs tool silently skipped a module, because it wasn't parsing the comma-separated directory list the way I assumed — switched to a recursive scan.
- A closed bot PR broke the loop without saying anything: the action kept force-pushing updated content to the branch, but since the PR was closed, it never reopened. The content just sat there until I reopened it.
- The bot PR's checks didn't run at all — because GitHub deliberately won't trigger workflows on PRs opened with the default token, to stop infinite loops. The fix is a scoped personal access token.
Every one of these became a decision record in the repo. None of them are glamorous, and most aren't in any tutorial — but they're the difference between automation that works in a demo and automation you'd trust on a team. That's the part I'd actually want to read about in someone else's write-up.
A note on the scanner findings
The scanners flagged a few things this week — a CloudTrail SNS-topic check, an IAM wildcard, an EIP-attachment check. I didn't quietly dismiss any of them. Each got a line in the decision log: the SNS notification was deferred to the Week 4 detection layer, where alert routing actually belongs; the wildcards were reviewed and accepted, because they live in Deny statements (the strictest scope there is) or in a standard KMS key policy; the EIP check was a false positive, since the IP attaches to a NAT gateway by design. Each call written down. That's the difference between a clean dashboard and an honest one.
Where this leaves things
Three audit trails in place — API, configuration, network — all KMS-encrypted, all mapped to NIS2 measures, all tested together through one integration test. The add-don't-remove pattern proved itself under a second consumer. And the CI automation is finally something I'd trust, even if getting there was the messy part.
NIS2 Article 21 coverage after Week 2: measures (a), (b), (c), (f), (h), (i) — six of ten.
Next week: identity. AWS Organizations, Service Control Policies as account-wide guardrails, and IAM Identity Center with MFA — deciding who's actually allowed to touch everything built so far.
Repo Link: github.com/olanak/aws-nis2-baseline