Introduction
In Part 1, I got both platforms to a working organization with accounts and SSO. Now comes the part most people buy a landing zone for: central logging and security services.
Here it gets honest. On NTC, the log archive and the security tooling are the two phases that send you back to the earlier ones. On LZA,, the native security configuration didn't cover what I needed, so I ended up in CDK. On both platforms, the first Security Hub export are lots of findings you have to clean first to get a clear picture.
I'll start with logging, because everything else depends on it, then the security services, and then the cleanup I did in October 2026, which taught me more than the initial deployment. Let's dive in.
Logging comes first
LZA
With LZA, logging is mostly built into the foundation. Control Tower creates the Log Archive account, and global-config.yaml tells LZA to use it for central logs and access logs, with their retention settings. Control Tower also owns organization CloudTrail, so I have cloudtrail.enable: false in LZA to avoid a second trail.
logging:
account: LogArchive
# disable Cloudtrail here since it's already deployed and managed by Control Tower
cloudtrail:
enable: false
organizationTrail: false
organizationTrailSettings:
multiRegionTrail: true
globalServiceEvents: true
managementEvents: true
s3DataEvents: false
lambdaDataEvents: false
sendToCloudWatchLogs: true
apiErrorRateInsight: false
apiCallRateInsight: false
# expire logs after one year
# using the same retention as the Control Tower defaults here
centralLogBucket:
lifecycleRules:
- enabled: true
id: RetentionRule
expiration: 365
noncurrentVersionExpiration: 365
abortIncompleteMultipartUpload: 1
# expire s3 access logs after 10 years
# using the same retention as the Control Tower defaults here
accessLogBucket:
lifecycleRules:
- enabled: true
id: RetentionRule
expiration: 3650
noncurrentVersionExpiration: 3650
abortIncompleteMultipartUpload: 1NTC
NTC treats the log archive as its own phase (Phase 4), deployed into the Log Archive account with its own state bucket. The module creates separate buckets for organization CloudTrail, AWS Config, GuardDuty findings, VPC flow logs, Transit Gateway flow logs, and DNS query logs, plus an access logging bucket that the others point to. Every bucket gets its own KMS key, versioning, a lifecycle rule (Glacier after a year, expiry after two in my setup), TLS enforcement, and a public access block. NTC Parameters publish the bucket and key ARNs for the other phases.
Once the log archive was applied, I went back to Phase 1 to switch on the organization trail and to Phase 2 to activate the AWS Config baseline, both on the same day. Without the buckets, there is nothing to deliver to.
A few things the module documentation puts in small print, and which I would put in bold:
- Object Lock in compliance mode is available for the CloudTrail bucket. Once enabled, objects cannot be deleted until the retention period expires, and the only way to remove them earlier is to close the account. Switch it on only after you finish testing.
- GuardDuty cannot export findings to an opt-in region. If your home region is one (for example,
eu-central-2), you need an exception module in a standard region. - Do not enable access logging on the access logging bucket itself. It logs to itself forever.
If I started again, this is the first phase I would deploy after the account factory. I waited almost two months, and every phase that wanted CloudTrail or Config had to wait with me.
module "ntc_log_archive" {
source = "github.com/nuvibit-terraform-collection/terraform-aws-ntc-log-archive?ref=2.1.1"
log_archive_buckets = [
{
region = "eu-central-1"
bucket_name = "company-cloudtrail-archive"
archive_type = "org_cloudtrail" # CloudTrail organization trail
lifecycle_configuration_rules = local.default_lifecycle_configuration_rules
enable_access_logging = true # Track bucket access
access_logging_target_bucket_name = module.ntc_log_archive_access_logging.log_archive_bucket_ids["s3_access_logging"]
access_logging_target_prefix = "org_cloudtrail/"
},
{
region = "eu-central-1"
bucket_name = "company-vpc-flow-logs-archive"
archive_type = "vpc_flow_logs" # VPC Flow Logs
lifecycle_configuration_rules = local.default_lifecycle_configuration_rules
enable_access_logging = true
access_logging_target_bucket_name = module.ntc_log_archive_access_logging.log_archive_bucket_ids["s3_access_logging"]
access_logging_target_prefix = "vpc_flow_logs/"
},
// ...
]
}Security services
LZA
LZA configures security services in security-config.yaml, and the documentation examples show everything switched on. My real file looks different:
centralSecurityServices:
delegatedAdminAccount: Audit
ebsDefaultVolumeEncryption:
enable: true
s3PublicAccessBlock:
enable: true
macie:
enable: false
guardduty:
enable: false
securityHub:
# configured in customizations-config.yaml, the native
# config could not do central configuration
enable: false
accessAnalyzer:
enable: trueGuardDuty and Macie are off for cost reasons in a small organization. Security Hub is off in the native config because LZA did not support Security Hub central configuration when I set it up (check the current release notes before you copy this). I did it with three custom CDK stacks in customizations-config.yaml, ordered by runOrder:
customizations:
cloudFormationStacks:
- name: LzaCustomization-SecurityHubDelegatedAdmin
deploymentTargets:
accounts: [Management]
template: customizations/cdk.out/SecurityHubDelegatedAdmin.template.json
- name: LzaCustomization-SecurityHubCentralConfiguration
deploymentTargets:
accounts: [Audit]
runOrder: 2
template: customizations/cdk.out/SecurityHubCentralConfiguration.template.json
- name: LzaCustomization-SecurityHubAutomationRules
deploymentTargets:
accounts: [Audit]
regions: ["us-east-1", "eu-central-1"]
runOrder: 3
template: customizations/cdk.out/SecurityHubAutomationRules.template.jsonThe workflow behind it: edit the TypeScript, run cdk synth, commit the generated JSON, push, wait for the pipeline. If the order is wrong, you wait another 45 minutes. A feature as basic as Security Hub central configuration should be a flag, not a project, and that is why I call LZA two platforms in one: the YAML layer and the CDK layer.
Where LZA shines is declarative compliance monitoring. Without any custom code, security-config.yaml gave me 14 CloudWatch metric filters with alarms for the CIS benchmark items (root usage, IAM policy changes, console login failures, CMK deletion, security group and route table changes, and so on), a metric filter for the breakglass user, and 17 AWS Config rules on the root OU. If CIS monitoring is your top priority, LZA gets you there faster than anything else I have used.
NTC
Security tooling is Phase 5 and runs in the Security account as delegated administrator for Security Hub, GuardDuty, Inspector, Config aggregation, and IAM Access Analyzer. It was the last phase I deployed, and it only worked after Phase 1 delegated those administrators and created the service-linked role for Access Analyzer.
What the module sets up in my organization: Security Hub CSPM in all four core accounts with the AWS Foundational Security Best Practices and CIS 5.0.0 standards in two regions, a central configuration policy that decides which controls are on, GuardDuty, Inspector, and SNS e-mail subscriptions for findings. Two small things: confirm the subscription emails, because unconfirmed subscriptions seem to disappear on the AWS side (my best explanation for two subscriptions that showed up as plan drift later), and the new unified Security Hub (V2) is not managed by the module, only CSPM. The module also documents an enrichment, rule, and notification pipeline for findings. I didn't build automation rules on it, so I left that to the docs.
What 316 Security Hub findings taught me
I took the time to look at what Security Hub actually reported. This is the part I wish somebody had told me about on Day 1.
The first look is not the truth
A quick query returned about 20 findings. The full, paginated result was 316 active failed findings across 41 controls. Count the whole result set before you size a cleanup.
I sorted them into three groups:
- Controls I don't want: services I don't use, controls with little value for a small organization, or controls that cost money to keep evaluating.
- Real gaps: account hardening, default VPCs, Inspector enrollment.
- Controls I had already disabled, but which kept reporting.
A disabled control that keeps reporting
The third group was the strangest. Some controls were in the central policy's disabled_control_ids, and still had findings. All four policy associations had been in status FAILED since February. The message said the standards couldn't be enabled and asked me to check that AWS Config was enabled, which it was by then. Nothing ever retried, and I never confirmed what exactly failed on the first run:
aws securityhub get-configuration-policy-association \
--target AccountId=<member-account-id> \
--query AssociationStatusThe status only refreshed when I edited the policy. Within seconds, the associations went to PENDING, and after about two minutes, to SUCCESS. So check the association status after every policy change, and do not assume a green pipeline means a working policy. I still haven't confirmed how long Security Hub needs to archive findings for newly disabled controls, they were still there five minutes later.
The blueprint defaults need a review
The blueprint I started from had defaults that I had accepted without a second look:
- An
IAM.7override with a password max age of 60 days and numbers not required. This weakens the control and contradicts the password policy ty account hardening baseline applies (90 days, numbers required). When a baseline setting remediates a control, check that the custom parameters in the central policy agree with it. Config.1andS3.1disabled by default.Config.1checks that Config uses the service-linked role, which passes after the baseline upgrade, so disabling it hid a real check.- A comment on
EC2.10that described a completely different control.
After the cleanup, my disabled_control_ids contains only controls I decided on, one by one:
disabled_control_ids = [
"Macie.1", # Macie is not used
"StepFunctions.1", # no state machines in this organization
# ... only controls with a reason
]Which then ends up in a clean Security Hub:
Hardening is a baseline, not a Security Hub setting
Most of the gap group closed with one account baseline template, account_hardening, which bundles seven account-level settings: public sharing of SSM documents blocked, a log destination for SSM automation, IMDSv2 as default, EBS encryption by default, EBS snapshot block public access, an IAM password policy, and the account-level S3 public access block. I added them to both baseline scopes in plan-only mode first. Before applying, I checked read-only in every account that nothing public, snapshots, or console users would be affected. The plan said 12 additions per account and nothing changed or was destroyed, and I applied it.
Default VPCs exist where lifecycle never ran
Security Hub flagged the default VPCs (EC2.2, EC2.6, EC2.15 and the VPC endpoint controls). The account factory has a lifecycle step delete_default_vpc, but it is event-driven and only runs when an account gets created. My management account was imported, and as far as I can tell, the step never ran for the other core accounts either (the state machine history only goes back 90 days). The result was a default VPC in every enabled region of every core account. I deleted 67 of them with a one-off, dependency-checked script. NTC has account_lifecycle_customization_on_demand_triggers for this, but it re-runs the whole lifecycle sequence.
LZA does this declaratively. defaultVpc.delete: true in network-config.yaml covers all accounts and regions, including the existing ones. It is one of the few things where the LZA approach is better.
Inspector costs depend on what exists
Auto-enable in the organization configuration only covers new member accounts. To enroll the existing core accounts, I invited them by account ID, and they came in with Lambda code scanning enabled, even though my organization config said it was off. I had to disable it manually from the Security account, which is not in Terraform:
aws inspector2 disable --resource-types LAMBDA_CODE
aws inspector2 batch-get-account-status --account-ids <member-account-id>
aws inspector2 list-usage-totalsIn my organization,, Inspector scans the seven Lambda functions in the Security account; there are no EC2 instances or ECR images, and the bill is about USD 7 per month, two-thirds of it from is Lambda code scanning. The usage estimate is worth a look before you enroll everything.
Controls that cannot see organization setups
Two controls keep failing for good reasons, not misconfiguration. IAM.28 only counts an access analyzer inside the same account, so my organization analyzers in the Security account leave the other accounts failing. Account.1 wants a security contact on the management account, which the account factory cannot set, because the Account API rejects the management account's own ID. I disabled the first, and I solved the second with an aws_account_alternate_contact resource in Phase 1. Both needed a decision outside the module defaults.
LZA and NTC side by side
| LZA | NTC | |
|---|---|---|
| Logging | Control Tower log archive, retention in global-config.yaml | Dedicated phase, a bucket and KMS key per log type |
| Security service config | security-config.yaml, gaps via CDK customizations | Security tooling module as delegated admin |
| Security Hub central config | Custom CDK stacks in my setup | Central configuration policy in the module |
| CIS monitoring | 14 metric filters and alarms, declarative | No equivalent in my setup |
| Default VPC cleanup | defaultVpc.delete: true, all accounts | Lifecycle step, new accounts only |
| Ordering | Pipeline and runOrder | Phases re-run by hand, back-edges to Phase 1 and 2 |
| Where it hurt | Writing and ordering CDK stacks | Dependencies between phases |
Conclusion
A rule of thumb that applies to both platforms: treat the first Security Hub export as a triage list, not as a score. Disable what you don't use, fix what is real, and verify the policy actually applied. The deployment gets you to a running Security Hub in a day or two. The cleanup is what makes it useful.
Personally, I would take LZA's declarative CIS monitoring and default VPC handling, and NTC's central policy and delegated-admin setup, if I could mix them. Since I can't, the decision comes down to what hurts you less: CDK for the gaps, or ordering phases by hand.
In Part 3 I look at what happens after the deployment: pipelines, upgrades, and the conclusion.
This post was written with AI assistance and verified plus enhanced by a human.



