Provisions a secure-by-default, account-wide CloudTrail — a multi-region, tamper-evident trail with optional CloudWatch Logs delivery, SNS notifications, event/insight selectors, and CloudTrail Lake event data stores — from a single module call. Built for the AWS provider v6.x.
- 🛡️ Tamper-evident by default. Multi-region coverage, log-file integrity validation, active logging, and global-service events are all ON out of the box — the (regulated data-privacy) audit baseline, not an opt-in.
- 🔐 Encryption you control. Supply a customer-managed CMK via
kms_key_arnfor SSE-KMS so audit-log key access is independently auditable and revocable; omit it to fall back to CloudTrail-managed SSE-S3. - 📡 Delivery, wired your way. Optional CloudWatch Logs delivery (for metric filters / alarms) and SNS notifications are off until you supply the ARNs — and the module enforces that the CloudWatch pair is set together.
- 🎯 Precise event capture. Basic
event_selectorsor finer-grainedadvanced_event_selectors(mutually exclusive, validated), plus CloudTrail Insights for unusual API-rate/error-rate activity. - 🗄️ CloudTrail Lake, optional. A
for_eachmap of event data stores — multi-region, termination-protected, seven-year retention, CMK-encrypted by default. - 🏷️ Tags everywhere.
var.tagsflows to the trail and every event data store and merges with providerdefault_tags; the merged set is surfaced astags_all. - 🌐 Global, not us-east-1-bound. A multi-region trail is created in one home Region and captures all Regions — no
regionvariable, and not a us-east-1 global-service module.
💡 Why it matters: in a regulated FI the blast radius of a missing or tampered audit trail is enormous. This module makes the complete, tamper-evident, account-wide posture the default and forces an explicit opt-out to weaken it.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
terraform-aws-cloudtrail is an observability sink — it consumes a destination bucket, an encryption key, and (optionally) a log group + delivery role from foundation modules, and emits a trail ARN that governance and security modules reference.
flowchart LR
s3["terraform-aws-s3-bucket<br/>destination + bucket policy"]
kms["terraform-aws-kms<br/>CMK (SSE-KMS)"]
cwlg["terraform-aws-cloudwatch-log-group<br/>log group (:*)"]
role["terraform-aws-iam-role<br/>CloudWatch delivery role"]
ct["terraform-aws-cloudtrail"]
cfg["terraform-aws-config"]
shub["terraform-aws-security-hub"]
eb["terraform-aws-eventbridge"]
s3 -->|"s3_bucket_name"| ct
kms -->|"kms_key_arn"| ct
cwlg -->|"cloud_watch_logs_group_arn"| ct
role -->|"cloud_watch_logs_role_arn"| ct
ct -->|"trail arn"| cfg
ct -->|"trail arn"| shub
ct -->|"log events"| eb
style ct fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
flowchart TD
subgraph mod["terraform-aws-cloudtrail"]
trail["aws_cloudtrail.this<br/>(keystone)<br/>multi-region · log-file validation<br/>SSE-KMS · global events"]
es["event_selector / advanced_event_selector<br/>(mutually exclusive, dynamic)"]
ins["insight_selector<br/>(dynamic, optional)"]
eds["aws_cloudtrail_event_data_store.this<br/>for_each event_data_stores<br/>(CloudTrail Lake, optional)"]
end
ext_s3[("S3 bucket + policy")]
ext_kms[("KMS CMK")]
ext_cw[("CloudWatch Logs + role")]
ext_sns[("SNS topic")]
trail --- es
trail --- ins
trail -->|delivers logs| ext_s3
trail -.->|SSE-KMS| ext_kms
trail -.->|optional| ext_cw
trail -.->|optional| ext_sns
eds -.->|SSE-KMS| ext_kms
style trail fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
style eds stroke-dasharray: 5 5
| Resource | Count | Created when |
|---|---|---|
aws_cloudtrail.this |
1 | always (keystone) |
aws_cloudtrail_event_data_store.this |
0..N | one per event_data_stores entry |
event_selector,advanced_event_selector,insight_selector, and the CloudWatch/SNS wiring are blocks/arguments on the keystone, rendered bydynamicblocks and absent unless configured.
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 |
The module declares only a required_providers block (providers.tf) and inherits the configured provider. There is no provider {} block and no credential variable — credentials resolve through the standard AWS chain at the root/pipeline level (env vars → SSO/shared credentials → assume_role → instance profile / IRSA → OIDC web identity).
Least-privilege actions the Terraform execution identity needs to manage this module.
| Action | Required for | Notes |
|---|---|---|
cloudtrail:CreateTrail, cloudtrail:DeleteTrail, cloudtrail:UpdateTrail |
Trail lifecycle | Core CRUD |
cloudtrail:StartLogging, cloudtrail:StopLogging |
Enable/disable logging | Driven by enable_logging |
cloudtrail:PutEventSelectors, cloudtrail:PutInsightSelectors |
Data / insight selectors | Applied when event_selectors / advanced_event_selectors / insight_selectors are set |
cloudtrail:GetTrail, cloudtrail:GetTrailStatus, cloudtrail:GetEventSelectors, cloudtrail:GetInsightSelectors, cloudtrail:ListTags |
Read / refresh | Plan & state refresh |
cloudtrail:AddTags, cloudtrail:RemoveTags |
Tagging | Trail + event data stores |
cloudtrail:CreateEventDataStore, cloudtrail:UpdateEventDataStore, cloudtrail:DeleteEventDataStore, cloudtrail:GetEventDataStore |
CloudTrail Lake | Only when event_data_stores is non-empty |
iam:PassRole (on cloud_watch_logs_role_arn) |
Pass the CloudWatch delivery role | Required only when CloudWatch Logs delivery is configured |
iam:GetRole, iam:CreateServiceLinkedRole |
Org trail / Lake SLR | Auto-creates AWSServiceRoleForCloudTrail |
kms:DescribeKey (on kms_key_arn) |
Validate the CMK at create | Only when a CMK is supplied |
s3:PutObject, s3:GetBucketAcl |
AWS-side create-time validation | Exercised by the CloudTrail service principal via the bucket policy — not by the Terraform identity |
⚠️ The Terraform identity does NOT needs3:PutObject/kms:GenerateDataKey*itself. Those are exercised at runtime by thecloudtrail.amazonaws.comservice principal through the destination bucket policy and the CMK key policy.iam:PassRoleis a module permission — but only because the module wirescloud_watch_logs_role_arninto the trail.
🔒 Scope
cloudtrail:*to the trail ARN pattern (arn:aws:cloudtrail:*:<account>:trail/*) andiam:PassRoleto the specific delivery role ARN with aniam:PassedToService = cloudtrail.amazonaws.comcondition.
- Destination S3 bucket policy (mandatory).
CreateTrailfails AWS-side validation unless the destination bucket grantscloudtrail.amazonaws.coms3:GetBucketAclon the bucket ands3:PutObjectonAWSLogs/<account-id>/*with thes3:x-amz-acl = bucket-owner-full-controlandaws:SourceArn/aws:SourceAccountconditions.terraform-aws-s3-bucketcan emit this policy. The bucket policy is intentionally out of scope here — it lives with the bucket. - KMS key policy (when
kms_key_arnset). The CMK policy must allowcloudtrail.amazonaws.comkms:GenerateDataKey*(encrypt, scoped withaws:SourceArnto the trail ARN) and grant the log consumerskms:Decrypt. - CloudWatch Logs integration (optional). Requires a log group (
terraform-aws-cloudwatch-log-group, ARN with the trailing:*) and an IAM role assumable bycloudtrail.amazonaws.comwithlogs:CreateLogStream/logs:PutLogEventson the group. Both must be supplied together. - Service-linked role. Organization trails and CloudTrail Lake auto-create
AWSServiceRoleForCloudTrail(iam:CreateServiceLinkedRole). - Organization trail.
is_organization_trail = truecan only be created from the management account or a registered delegated administrator. - Region. A multi-region trail is created in one home Region and captures all enabled Regions — no
regionvariable. CloudTrail is not a us-east-1 global-service module (unlike CloudFront / WAFv2-CLOUDFRONT / ACM-for-CloudFront). - Quotas (per Quotas in AWS CloudTrail — none are adjustable):
- 5 trails per Region. Capture management events with one multi-region trail per account; additional trails re-log the same events and are billed.
- 10 event data stores per Region (counts single-region, multi-region, and org stores in any lifecycle stage).
- 5 event selectors per trail; 250 data resources across all event selectors; 500 condition values across all advanced event selectors.
terraform-aws-cloudtrail/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name → required → delivery → secure posture → selectors → event_data_stores → tags
├── main.tf # aws_cloudtrail.this + aws_cloudtrail_event_data_store.this (for_each)
├── outputs.tf # id + arn + name + home_region + sns_topic_arn + EDS maps + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas
Smallest working call — a secure multi-region trail wired to a bucket and a CMK from sibling modules:
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-org-audit"
s3_bucket_name = module.audit_bucket.id # bucket policy owned by terraform-aws-s3-bucket
kms_key_arn = module.audit_kms.arn # SSE-KMS; -preferred for PII/privacy-regulation logs
# Secure defaults are already ON: multi-region, log-file validation,
# logging, global-service events. No opt-out needed for the baseline.
tags = {
Environment = "prod"
DataClass = "restricted"
Compliance = "glba"
}
}Sequence the bucket policy / CMK policy before the trail (sibling modules that own those policies handle this; otherwise use
depends_onat the root) — see Architecture Notes.
| Input | Type | Source module |
|---|---|---|
s3_bucket_name |
string (bucket id) |
terraform-aws-s3-bucket (also owns the CloudTrail bucket policy) |
kms_key_arn |
string (KMS key ARN / alias / id) |
terraform-aws-kms |
cloud_watch_logs_group_arn |
string (log group ARN, :* suffix) |
terraform-aws-cloudwatch-log-group |
cloud_watch_logs_role_arn |
string (IAM role ARN) |
terraform-aws-iam-role |
sns_topic_name |
string (topic name, or ARN if cross-Region) |
app-integration modules |
| Output | Description | Consumed by |
|---|---|---|
id |
Trail id (its ARN — CloudTrail uses the ARN as the resource id) | references / CLI |
arn |
Trail ARN arn:aws:cloudtrail:<region>:<account>:trail/<name> — the cross-resource reference type |
Config, Security Hub, CloudWatch metric filters, KMS/SNS aws:SourceArn conditions |
name |
Trail name | metric filters / CLI |
home_region |
Home Region the multi-region trail was created in | governance / audit |
sns_topic_arn |
SNS topic ARN for delivery notifications; null when sns_topic_name unset |
event routing |
event_data_store_arns |
Map of CloudTrail Lake event data store ARNs keyed by map key | Lake queries |
event_data_store_ids |
Map of event data store ids keyed by map key | references |
tags_all |
All trail tags incl. provider default_tags (resource tags win) |
governance / audit |
event_data_store_tags_all |
Map of computed tags_all per event data store |
governance / audit |
1 · Minimal secure trail (SSE-S3 fallback)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
# kms_key_arn omitted → CloudTrail-managed SSE-S3 encryption
}2 · Customer-managed KMS (SSE-KMS) wired from terraform-aws-kms
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn # CMK policy must allow cloudtrail.amazonaws.com kms:GenerateDataKey*
}
# -preferred for PII/privacy-regulation audit logs — key access is independently auditable and revocable.3 · CloudWatch Logs delivery (group + role set together)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn
# MUST be set together — the module validates the pair.
cloud_watch_logs_group_arn = "${module.ct_log_group.arn}:*" # CloudTrail requires the:* suffix
cloud_watch_logs_role_arn = module.ct_delivery_role.arn # trusts cloudtrail.amazonaws.com
}4 · SNS delivery notifications
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
sns_topic_name = "casey-cloudtrail-delivery" # topic policy must allow cloudtrail.amazonaws.com sns:Publish
}5 · Tags (merge with provider default_tags)
# Caller's provider block owns default_tags; the module never sets it.
provider "aws" {
default_tags { tags = { Owner = "secops", ManagedBy = "terraform" } }
}
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
tags = {
Environment = "prod" # resource tag — wins over default_tags on key conflict
DataClass = "restricted"
}
}
# module.trail.tags_all == { Owner, ManagedBy, Environment, DataClass }6 · S3 data-event logging via basic event selectors
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-data-events"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn
event_selectors = [{
read_write_type = "All"
include_management_events = true
data_resource = [{
type = "AWS::S3::Object"
values = ["${module.sensitive_bucket.arn}/"] # scope tightly — data events are billed per event
}]
}]
}7 · Exclude noisy management event sources
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
event_selectors = [{
read_write_type = "All"
include_management_events = true
exclude_management_event_sources = ["kms.amazonaws.com"] # drop high-volume KMS events
}]
}8 · Advanced event selectors (Lambda data events)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-advanced"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn
# Mutually exclusive with event_selectors — set at most one.
advanced_event_selectors = [{
name = "log-lambda-invokes"
field_selector = [
{ field = "eventCategory", equals = ["Data"] },
{ field = "resources.type", equals = ["AWS::Lambda::Function"] },
]
}]
}9 · CloudTrail Insights (API-rate + error-rate)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
insight_selectors = [
{ insight_type = "ApiCallRateInsight" },
{ insight_type = "ApiErrorRateInsight" },
]
}
# Insights require management-event logging and incur additional cost.10 · CloudTrail Lake event data store (secure defaults)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn # inherited by the store unless it sets its own kms_key_id
event_data_stores = {
security-lake = {
# multi-region, termination protection ON, 7-year (2555d) retention — all default
advanced_event_selector = [{
field_selector = [{ field = "eventCategory", equals = ["Management"] }]
}]
}
}
}11 · Event data store with per-store CMK + extended retention
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-audit"
s3_bucket_name = module.audit_bucket.id
event_data_stores = {
long-retention = {
retention_period = 3653 # ~10y (EXTENDABLE_RETENTION_PRICING)
kms_key_id = module.lake_kms.arn
tags = { Retention = "10y" } # merges over (and wins against) module tags
}
}
}
# ⚠️ Reducing retention_period on an existing store deletes events older than the new window.12 · Single-Region trail (deliberate exception)
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-regional-audit"
s3_bucket_name = module.audit_bucket.id
is_multi_region_trail = false # opt-out of the secure baseline — document the exception
include_global_service_events = false # avoid duplicating global events captured by the org trail
}13 · Provision in a stopped state
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-staged-audit"
s3_bucket_name = module.audit_bucket.id
enable_logging = false # provision the trail but do not StartLogging yet
}14 · Organization trail (management / delegated-admin account)
module "org_trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-org-audit"
s3_bucket_name = module.org_audit_bucket.id
kms_key_arn = module.org_audit_kms.arn
is_organization_trail = true # only from the org management account or a delegated administrator
}
# Auto-creates the AWSServiceRoleForCloudTrail service-linked role.15 · End-to-end composition — full audit pipeline (bucket + CMK + log group + role + trail)
# Customer-managed CMK for audit-log encryption
module "audit_kms" {
source = "git::https://github.com/microsoftexpert/terraform-aws-kms?ref=v1.0.0"
alias = "casey/cloudtrail"
# key policy allows cloudtrail.amazonaws.com kms:GenerateDataKey* (aws:SourceArn → trail ARN)
}
# Destination bucket — owns the CloudTrail bucket policy
module "audit_bucket" {
source = "git::https://github.com/microsoftexpert/terraform-aws-s3-bucket?ref=v1.0.0"
bucket = "casey-cloudtrail-logs-prod"
cloudtrail_log_writer = true # emit the cloudtrail.amazonaws.com bucket policy
kms_key_arn = module.audit_kms.arn
}
# CloudWatch Logs target for near-real-time monitoring
module "ct_log_group" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudwatch-log-group?ref=v1.0.0"
name = "/aws/cloudtrail/casey-org-audit"
kms_key_arn = module.audit_kms.arn
retention_in_days = 365
}
# Role CloudTrail assumes to write to CloudWatch Logs
module "ct_delivery_role" {
source = "git::https://github.com/microsoftexpert/terraform-aws-iam-role?ref=v1.0.0"
name = "casey-cloudtrail-cw-delivery"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "cloudtrail.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
inline_policies = {
deliver = {
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${module.ct_log_group.arn}:*"
}]
})
}
}
}
# This module — the trail wired to everything above
module "trail" {
source = "git::https://github.com/microsoftexpert/terraform-aws-cloudtrail?ref=v1.0.0"
name = "casey-org-audit"
s3_bucket_name = module.audit_bucket.id
kms_key_arn = module.audit_kms.arn
cloud_watch_logs_group_arn = "${module.ct_log_group.arn}:*"
cloud_watch_logs_role_arn = module.ct_delivery_role.arn
insight_selectors = [{ insight_type = "ApiCallRateInsight" }]
event_data_stores = {
security-lake = {
advanced_event_selector = [{
field_selector = [{ field = "eventCategory", equals = ["Management"] }]
}]
}
}
tags = { Environment = "prod", DataClass = "restricted", Compliance = "glba" }
# Bucket/KMS policies are owned by their modules, so ordering is implicit via the
# references above. If you author those policies separately, add:
# depends_on = [module.audit_bucket, module.audit_kms]
}| Name | Type | Default | Description |
|---|---|---|---|
name |
string |
— required | Trail name. FORCE-NEW. 3–128 chars, unique per account/Region. |
s3_bucket_name |
string |
— required | Destination bucket id (its policy must pre-grant CloudTrail). |
s3_key_prefix |
string |
null |
Key prefix for delivered log files. |
kms_key_arn |
string |
null |
CMK for SSE-KMS; null → CloudTrail-managed SSE-S3. Also default key for event data stores. |
sns_topic_name |
string |
null |
SNS topic for delivery notifications. |
cloud_watch_logs_group_arn |
string |
null |
Log group ARN (must end :*). Set with the role ARN. |
cloud_watch_logs_role_arn |
string |
null |
Delivery role ARN. Set with the group ARN. |
is_multi_region_trail |
bool |
true |
Capture all Regions (secure baseline). |
enable_log_file_validation |
bool |
true |
Tamper-evident log signing (secure baseline). |
enable_logging |
bool |
true |
Start logging on create. |
include_global_service_events |
bool |
true |
Record IAM/STS/CloudFront global events. |
is_organization_trail |
bool |
false |
Org-wide trail (management/delegated-admin only). |
event_selectors |
list(object(...)) |
[] |
Basic data-event selectors. Conflicts with advanced_event_selectors. |
advanced_event_selectors |
list(object(...)) |
[] |
Advanced field-match selectors. Conflicts with event_selectors. |
insight_selectors |
list(object({ insight_type })) |
[] |
CloudTrail Insights (ApiCallRateInsight / ApiErrorRateInsight). |
event_data_stores |
map(object(...)) |
{} |
CloudTrail Lake event data stores, keyed by stable name. |
tags |
map(string) |
{} |
Tags for the trail and every event data store (merge with default_tags). |
See variables.tf for full heredoc schemas and validation rules.
| Name | Description |
|---|---|
id |
Trail id (its ARN). |
arn |
Trail ARN (cross-resource reference type). |
name |
Trail name. |
home_region |
Home Region of the multi-region trail. |
sns_topic_arn |
SNS topic ARN; null when unset. |
event_data_store_arns |
Map of Lake event data store ARNs keyed by map key. |
event_data_store_ids |
Map of Lake event data store ids keyed by map key. |
tags_all |
All trail tags incl. provider default_tags. |
event_data_store_tags_all |
Map of computed tags_all per event data store. |
- ARN format:
arn:aws:cloudtrail:<region>:<account-id>:trail/<name>. CloudTrail uses the ARN as the resourceid—output.idandoutput.arnare the same value. Event data store ARN:arn:aws:cloudtrail:<region>:<account-id>:eventdatastore/<uuid>. - Multi-region but home-Region-anchored. A multi-region trail is created in (and named for) one home Region and captures all enabled Regions;
home_regionreports that Region. This is not a us-east-1 global-service module — create it in your normal operating Region. - Force-new fields:
nameforces replacement (the ARN embeds the name). Renaming destroys and recreates the trail and anything referencing it by name. tags↔tags_all↔default_tags:var.tagsis applied to the trail and each event data store;tags_allis the provider-computed merge of resource tags over providerdefault_tags, with resource tags winning on key conflict. Per-storetagsmerge over (and win against) moduletags.default_tagsis configured in the caller's provider block — never inside this module.- Bucket / KMS policy ordering (the #1 create-time failure).
CreateTrailperforms AWS-side validation: the destination bucket policy (and the CMK key policy, ifkms_key_arnis set) must already grant thecloudtrail.amazonaws.comprincipal. Wire the bucket/key from sibling modules that own those policies (implicit ordering via references) or add explicitdepends_onat the root. - CloudWatch Logs
:*suffix.cloud_watch_logs_group_arnmust carry the trailing:*— CloudTrail writes to log streams under the group. The module validates this and that the group/role pair is set together. - Eventual consistency.
StartLogginglags:GetTrailStatus.IsLoggingmay briefly readfalseimmediately after apply even though logging is enabled. This is propagation latency, not a module defect. - Event selectors are mutually exclusive. Set at most one of
event_selectors/advanced_event_selectors; the trail cannot use both (validated invariables.tf). - Event data store retention is destructive on shrink. Reducing
retention_periodon an existing store deletes events older than the new window. Termination protection (termination_protection_enabled = trueby default) must be disabled before a store can be deleted. - Destroy ordering. No ENI/NAT-style dependencies here. Event data stores with termination protection on cannot be destroyed until it is turned off; the trail itself deletes cleanly once logging stops.
Secure-by-default posture and every opt-out, explicitly:
| Posture | Default | Opt-out |
|---|---|---|
| Multi-region coverage | is_multi_region_trail = true |
is_multi_region_trail = false (document the exception) |
| Log-file integrity | enable_log_file_validation = true |
enable_log_file_validation = false (strongly discouraged) |
| Encryption at rest | SSE-KMS when kms_key_arn set; SSE-S3 otherwise |
supply / omit kms_key_arn |
| Logging active | enable_logging = true |
enable_logging = false |
| Global-service events | include_global_service_events = true |
set false |
| Event data store integrity | multi_region_enabled = true + termination_protection_enabled = true + 7y retention |
per-store override |
Other principles:
- One composite, one keystone. The trail owns only what is meaningless without it (its selectors, insights, and optional Lake event data stores). The destination bucket, CMK, log group, delivery role, and SNS topic are referenced by
arn/name so blast radius stays on the trail itself. - The bucket policy is deliberately out of scope. It lives with the bucket (
terraform-aws-s3-bucket) so the bucket module owns its own access policy. for_each, nevercount, for event data stores — keyed by stable caller strings so reorders don't churn the plan.- Optional everything stays absent. CloudWatch delivery, SNS, selectors, insights, and Lake stores default empty and render via
dynamicblocks — nothing is created unless configured. - Primary outputs
id+arn, plusname,home_region, the event-data-store maps, andtags_all.
# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check
plan/applyrequire valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain, plus a configured Region and the IAM actions listed above. The destination bucket policy (and CMK policy, if used) must already grant the CloudTrail service principal orCreateTrailfails AWS-side validation.
terraform init -backend=false && terraform validate— schema + reference integrity.terraform fmt -check— canonical formatting.terraform planagainst a sandbox account with a pre-policied bucket to confirm the trail, selectors, and any event data stores materialize.- Assert
module.<name>.arn,home_region,event_data_store_arns, andtags_allin your root-module test harness. - Validate the conflict guard: setting both
event_selectorsandadvanced_event_selectorsmust fail at plan.
module.trail.aws_cloudtrail.this: Creation complete after 4s [id=arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit]
module.trail.aws_cloudtrail_event_data_store.this["security-lake"]: Creation complete after 9s
Outputs:
arn = "arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit"
id = "arn:aws:cloudtrail:us-east-1:123456789012:trail/casey-org-audit"
home_region = "us-east-1"
name = "casey-org-audit"
event_data_store_arns = { "security-lake" = "arn:aws:cloudtrail:us-east-1:123456789012:eventdatastore/EXAMPLE-uuid" }
tags_all = { "Compliance" = "glba", "DataClass" = "restricted", "Environment" = "prod" }
| Symptom | Likely cause | Fix |
|---|---|---|
InsufficientS3BucketPolicyException on apply |
Destination bucket policy doesn't grant cloudtrail.amazonaws.com |
Add the CloudTrail bucket policy (terraform-aws-s3-bucket can emit it); ensure it exists before the trail |
InsufficientEncryptionPolicyException |
CMK key policy missing kms:GenerateDataKey* for CloudTrail |
Grant cloudtrail.amazonaws.com kms:GenerateDataKey* scoped with aws:SourceArn to the trail ARN |
CloudWatchLogsDeliveryUnavailableException |
Delivery role doesn't trust CloudTrail or lacks logs:* |
Role must trust cloudtrail.amazonaws.com and allow logs:CreateLogStream / logs:PutLogEvents |
InvalidCloudWatchLogsLogGroupArnException |
Group ARN missing the :* suffix |
Append :* to cloud_watch_logs_group_arn |
| Plan error: set at most one of event_selectors or advanced_event_selectors | Both selector variables set | Use one; advanced selectors are the finer-grained superset |
AccessDenied: iam:PassRole |
TF identity can't pass the delivery role | Grant iam:PassRole on the role ARN with iam:PassedToService = cloudtrail.amazonaws.com |
IsLogging = false right after apply |
StartLogging eventual consistency |
Expected propagation lag; re-check status shortly after |
MaximumNumberOfTrailsExceededException |
More than 5 trails in the Region | Consolidate to one multi-region management-event trail (quota is non-adjustable) |
| Can't destroy an event data store | Termination protection on | Set termination_protection_enabled = false, apply, then destroy |
| Lost old Lake events after a change | retention_period reduced on an existing store |
Shrinking retention deletes events older than the new window — raise, don't lower, in place |
| Tag drift on every plan | A tag also set by provider default_tags with a different value |
Let resource tags win, or remove the overlap from default_tags |
AccessDenied creating an org trail |
Not the management / delegated-admin account | Create from the org management account or register a delegated administrator |
- What Is AWS CloudTrail?
- Quotas in AWS CloudTrail
- Understanding multi-Region trails
- Amazon S3 bucket policy for CloudTrail
- Encrypting CloudTrail log files with SSE-KMS
- Terraform:
aws_cloudtrail·aws_cloudtrail_event_data_store - Sibling modules:
terraform-aws-s3-bucket,terraform-aws-kms,terraform-aws-cloudwatch-log-group,terraform-aws-iam-role,terraform-aws-config,terraform-aws-security-hub - Module internals:
SCOPE.md
🧡 "Infrastructure as Code should be standardized, consistent, and secure."