AWS

AWS LZA vs NTC Deep Dive Part 2: Logging, Security and Compliance

Log archive, Security Hub, GuardDuty and Inspector on LZA and NTC, and what happened when I finally triaged 316 findings.

Oct 23, 2026 · 11 min read · 2,267 words

Photo generated by gemini

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.

global-config.yaml
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: 1

NTC

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.

ntc_log_archive.tf
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:

security-config.yaml
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: true

GuardDuty 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-config.yaml
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.json

The 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:

  1. 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.
  2. Real gaps: account hardening, default VPCs, Inspector enrollment.
  3. 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 AssociationStatus

The 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.7 override 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.1 and S3.1 disabled by default. Config.1 checks that Config uses the service-linked role, which passes after the baseline upgrade, so disabling it hid a real check.
  • A comment on EC2.10 that 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-totals

In 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

LZANTC
LoggingControl Tower log archive, retention in global-config.yamlDedicated phase, a bucket and KMS key per log type
Security service configsecurity-config.yaml, gaps via CDK customizationsSecurity tooling module as delegated admin
Security Hub central configCustom CDK stacks in my setupCentral configuration policy in the module
CIS monitoring14 metric filters and alarms, declarativeNo equivalent in my setup
Default VPC cleanupdefaultVpc.delete: true, all accountsLifecycle step, new accounts only
OrderingPipeline and runOrderPhases re-run by hand, back-edges to Phase 1 and 2
Where it hurtWriting and ordering CDK stacksDependencies 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.

More from the blog

Keep reading - related notes from recent engagements.

Ready to ship faster on AWS?

Tell us what you are building. We will map the fastest safe path to production and the platform to keep it there.

Notes from production

Occasional, no-fluff writing on AWS, DevOps, and running platforms that stay up. No spam, unsubscribe anytime.

Practical, not promotional
Real lessons from real engagements - architecture, automation, and incident post-mortems.
No spam
A few emails a year at most. Your address is never shared or sold.