Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS CloudTrail: multi-region trails, log-file validation, KMS-encrypted S3 delivery, optional CloudWatch Logs, and data event selectors. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "cloudtrail" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudtrail?ref=v1.0.0"
name = "..." # Trail name; also derives bucket, log group, and role na…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS CloudTrail is the service that records API activity across your account — every ec2:RunInstances, every iam:DeleteRole, every s3:PutBucketPolicy — and delivers those records as immutable JSON event files to S3 (and optionally to CloudWatch Logs for near-real-time alerting). It is the backbone of every security investigation, every compliance audit (PCI-DSS, SOC 2, HIPAA, FedRAMP), and every “who deleted the production database at 2am?” post-mortem.
The problem is that a correctly configured trail has a lot of moving parts that are easy to get wrong: the S3 bucket needs a precise bucket policy granting cloudtrail.amazonaws.com write access with an aws:SourceArn condition, log-file integrity validation must be explicitly enabled, the trail should be multi-region and include global service events, and a KMS key with the right key policy is needed if you want the logs encrypted at rest. Click-ops or copy-pasted HCL drifts quickly, and a half-configured trail gives a false sense of security — auditors will fail you for a trail that exists but doesn’t validate its own log files.
This module wraps aws_cloudtrail together with its hard dependencies (the delivery S3 bucket, its bucket policy, and an optional CloudWatch Logs group + IAM role) into a single var-driven unit. You instantiate it once per account (or once in your org-management account for an organization trail), pass a name and a KMS key, and get back a fully wired, tamper-evident audit trail with sane production defaults.
When to use it
- You are bootstrapping a new AWS account in a landing zone and every account must ship with a baseline trail before any workload lands.
- You need an organization trail in the management account that automatically captures every member account without per-account configuration.
- You want to capture data events (S3 object-level reads/writes, Lambda invocations, DynamoDB item activity) for a sensitive bucket or function, not just management events.
- You are standardising audit logging across many accounts and want one reviewed, validated module instead of bespoke trail HCL in every repo.
- You need encrypted, integrity-validated logs to satisfy a compliance control and want the S3 + KMS + bucket-policy wiring done correctly and consistently.
If you only need to read existing CloudTrail events ad-hoc, use CloudTrail Lake or Athena instead — this module is for provisioning the trail itself.
Module structure
terraform-module-aws-cloudtrail/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Bucket that receives the log files. Either created here or supplied by the caller.
bucket_name = var.create_s3_bucket ? "${var.name}-cloudtrail-logs-${data.aws_caller_identity.current.account_id}" : var.s3_bucket_name
bucket_arn = var.create_s3_bucket ? aws_s3_bucket.trail[0].arn : "arn:${data.aws_partition.current.partition}:s3:::${var.s3_bucket_name}"
# CloudTrail's source ARN for the bucket-policy condition. For an org trail the
# service writes from the org-trail ARN; otherwise from this account's trail ARN.
trail_arn = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${var.name}"
enable_cloudwatch = var.cloudwatch_logs_group_arn != null || var.create_cloudwatch_logs_group
log_group_arn = var.create_cloudwatch_logs_group ? "${aws_cloudwatch_log_group.trail[0].arn}:*" : var.cloudwatch_logs_group_arn
tags = merge(var.tags, { ManagedBy = "terraform", Module = "terraform-module-aws-cloudtrail" })
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
data "aws_region" "current" {}
# ---------------------------------------------------------------------------
# Delivery S3 bucket (optional — skip when reusing a central logging bucket)
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "trail" {
count = var.create_s3_bucket ? 1 : 0
bucket = local.bucket_name
force_destroy = var.s3_force_destroy
tags = local.tags
}
resource "aws_s3_bucket_public_access_block" "trail" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.trail[0].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "trail" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.trail[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "trail" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.trail[0].id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = var.kms_key_arn != null ? "aws:kms" : "AES256"
kms_master_key_id = var.kms_key_arn
}
bucket_key_enabled = var.kms_key_arn != null
}
}
resource "aws_s3_bucket_lifecycle_configuration" "trail" {
count = var.create_s3_bucket && var.log_retention_days > 0 ? 1 : 0
bucket = aws_s3_bucket.trail[0].id
rule {
id = "expire-cloudtrail-logs"
status = "Enabled"
filter {
prefix = "AWSLogs/"
}
transition {
days = var.log_glacier_transition_days
storage_class = "GLACIER"
}
expiration {
days = var.log_retention_days
}
}
}
# Bucket policy that lets CloudTrail check the ACL and write objects, scoped to
# this trail via aws:SourceArn so other accounts cannot dump logs in our bucket.
data "aws_iam_policy_document" "bucket" {
count = var.create_s3_bucket ? 1 : 0
statement {
sid = "AWSCloudTrailAclCheck"
effect = "Allow"
actions = ["s3:GetBucketAcl"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
resources = [aws_s3_bucket.trail[0].arn]
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
statement {
sid = "AWSCloudTrailWrite"
effect = "Allow"
actions = ["s3:PutObject"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
resources = ["${aws_s3_bucket.trail[0].arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl"
values = ["bucket-owner-full-control"]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [local.trail_arn]
}
}
# Deny any non-TLS access to the audit bucket.
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
principals {
type = "AWS"
identifiers = ["*"]
}
resources = [
aws_s3_bucket.trail[0].arn,
"${aws_s3_bucket.trail[0].arn}/*",
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "trail" {
count = var.create_s3_bucket ? 1 : 0
bucket = aws_s3_bucket.trail[0].id
policy = data.aws_iam_policy_document.bucket[0].json
}
# ---------------------------------------------------------------------------
# Optional CloudWatch Logs delivery (enables metric filters / real-time alarms)
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_log_group" "trail" {
count = var.create_cloudwatch_logs_group ? 1 : 0
name = "/aws/cloudtrail/${var.name}"
retention_in_days = var.cloudwatch_logs_retention_days
kms_key_id = var.kms_key_arn
tags = local.tags
}
data "aws_iam_policy_document" "cw_assume" {
count = var.create_cloudwatch_logs_group ? 1 : 0
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["cloudtrail.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "cw_delivery" {
count = var.create_cloudwatch_logs_group ? 1 : 0
statement {
effect = "Allow"
actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
resources = ["${aws_cloudwatch_log_group.trail[0].arn}:*"]
}
}
resource "aws_iam_role" "cw" {
count = var.create_cloudwatch_logs_group ? 1 : 0
name = "${var.name}-cloudtrail-cw-role"
assume_role_policy = data.aws_iam_policy_document.cw_assume[0].json
tags = local.tags
}
resource "aws_iam_role_policy" "cw" {
count = var.create_cloudwatch_logs_group ? 1 : 0
name = "${var.name}-cloudtrail-cw-delivery"
role = aws_iam_role.cw[0].id
policy = data.aws_iam_policy_document.cw_delivery[0].json
}
# ---------------------------------------------------------------------------
# The trail itself
# ---------------------------------------------------------------------------
resource "aws_cloudtrail" "this" {
name = var.name
s3_bucket_name = local.bucket_name
s3_key_prefix = var.s3_key_prefix
is_multi_region_trail = var.is_multi_region_trail
is_organization_trail = var.is_organization_trail
include_global_service_events = var.include_global_service_events
enable_log_file_validation = var.enable_log_file_validation
enable_logging = var.enable_logging
kms_key_id = var.kms_key_arn
cloud_watch_logs_group_arn = local.enable_cloudwatch ? local.log_group_arn : null
cloud_watch_logs_role_arn = var.create_cloudwatch_logs_group ? aws_iam_role.cw[0].arn : var.cloudwatch_logs_role_arn
# Advanced event selectors capture management + optional data events.
dynamic "advanced_event_selector" {
for_each = var.advanced_event_selectors
content {
name = advanced_event_selector.value.name
dynamic "field_selector" {
for_each = advanced_event_selector.value.field_selectors
content {
field = field_selector.value.field
equals = lookup(field_selector.value, "equals", null)
not_equals = lookup(field_selector.value, "not_equals", null)
starts_with = lookup(field_selector.value, "starts_with", null)
ends_with = lookup(field_selector.value, "ends_with", null)
not_starts_with = lookup(field_selector.value, "not_starts_with", null)
not_ends_with = lookup(field_selector.value, "not_ends_with", null)
}
}
}
}
tags = local.tags
depends_on = [
aws_s3_bucket_policy.trail,
aws_iam_role_policy.cw,
]
}
variables.tf
variable "name" {
description = "Name of the CloudTrail trail; also used to derive the bucket, log group, and IAM role names."
type = string
validation {
condition = can(regex("^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$", var.name))
error_message = "name must be 3-128 chars: letters, numbers, periods, underscores or hyphens, starting alphanumeric."
}
}
variable "is_multi_region_trail" {
description = "Whether the trail captures events from all regions. Strongly recommended true."
type = bool
default = true
}
variable "is_organization_trail" {
description = "Create an organization trail that logs all member accounts. Requires running in the org management/delegated-admin account."
type = bool
default = false
}
variable "include_global_service_events" {
description = "Include events from global services such as IAM, STS, and CloudFront."
type = bool
default = true
}
variable "enable_log_file_validation" {
description = "Produce signed digest files so log tampering can be detected. Required by most compliance frameworks."
type = bool
default = true
}
variable "enable_logging" {
description = "Whether the trail is actively logging when created. Set false to provision a paused trail."
type = bool
default = true
}
variable "kms_key_arn" {
description = "KMS key ARN used to encrypt log files (and the CloudWatch log group). The key policy must grant cloudtrail.amazonaws.com kms:GenerateDataKey*. Null = SSE-S3."
type = string
default = null
validation {
condition = var.kms_key_arn == null || can(regex("^arn:aws[a-z-]*:kms:", var.kms_key_arn))
error_message = "kms_key_arn must be a valid KMS key ARN or null."
}
}
# --- S3 delivery bucket ----------------------------------------------------
variable "create_s3_bucket" {
description = "Create a dedicated delivery bucket. Set false to reuse a central logging bucket via s3_bucket_name."
type = bool
default = true
}
variable "s3_bucket_name" {
description = "Name of an existing delivery bucket to write logs to when create_s3_bucket = false."
type = string
default = null
validation {
condition = var.create_s3_bucket || (var.s3_bucket_name != null && var.s3_bucket_name != "")
error_message = "s3_bucket_name is required when create_s3_bucket = false."
}
}
variable "s3_key_prefix" {
description = "Optional key prefix inside the bucket for the delivered log files."
type = string
default = null
}
variable "s3_force_destroy" {
description = "Allow Terraform to delete the bucket even when it still contains log objects. Keep false in production."
type = bool
default = false
}
variable "log_retention_days" {
description = "Days to retain log objects in the created bucket before expiry. 0 disables the lifecycle rule (keep forever)."
type = number
default = 365
validation {
condition = var.log_retention_days >= 0
error_message = "log_retention_days must be 0 or a positive number of days."
}
}
variable "log_glacier_transition_days" {
description = "Days before transitioning log objects to GLACIER storage. Must be less than log_retention_days when expiry is set."
type = number
default = 90
}
# --- CloudWatch Logs -------------------------------------------------------
variable "create_cloudwatch_logs_group" {
description = "Create a CloudWatch Logs group + delivery IAM role so events can drive metric filters and alarms."
type = bool
default = false
}
variable "cloudwatch_logs_group_arn" {
description = "ARN of an existing CloudWatch Logs group to deliver to (must end with :*). Ignored when create_cloudwatch_logs_group = true."
type = string
default = null
}
variable "cloudwatch_logs_role_arn" {
description = "ARN of an existing IAM role CloudTrail assumes to write to the existing log group."
type = string
default = null
}
variable "cloudwatch_logs_retention_days" {
description = "Retention for the created CloudWatch Logs group."
type = number
default = 90
}
# --- Event selectors -------------------------------------------------------
variable "advanced_event_selectors" {
description = "List of advanced event selectors. Empty list logs all management events by default. Use to add S3/Lambda/DynamoDB data events."
type = list(object({
name = optional(string)
field_selectors = list(object({
field = string
equals = optional(list(string))
not_equals = optional(list(string))
starts_with = optional(list(string))
ends_with = optional(list(string))
not_starts_with = optional(list(string))
not_ends_with = optional(list(string))
}))
}))
default = [
{
name = "log-all-management-events"
field_selectors = [
{ field = "eventCategory", equals = ["Management"] }
]
}
]
}
variable "tags" {
description = "Tags applied to all resources created by the module."
type = map(string)
default = {}
}
outputs.tf
output "trail_id" {
description = "The name (ID) of the CloudTrail trail."
value = aws_cloudtrail.this.id
}
output "trail_arn" {
description = "The ARN of the CloudTrail trail."
value = aws_cloudtrail.this.arn
}
output "trail_name" {
description = "The name of the CloudTrail trail."
value = aws_cloudtrail.this.name
}
output "home_region" {
description = "The home region in which the trail was created."
value = aws_cloudtrail.this.home_region
}
output "s3_bucket_name" {
description = "Name of the S3 bucket receiving the log files."
value = local.bucket_name
}
output "s3_bucket_arn" {
description = "ARN of the S3 delivery bucket (created or referenced)."
value = local.bucket_arn
}
output "cloudwatch_log_group_arn" {
description = "ARN of the CloudWatch Logs group receiving events, if enabled."
value = var.create_cloudwatch_logs_group ? aws_cloudwatch_log_group.trail[0].arn : var.cloudwatch_logs_group_arn
}
output "cloudwatch_logs_role_arn" {
description = "ARN of the IAM role CloudTrail uses to deliver to CloudWatch Logs, if created."
value = var.create_cloudwatch_logs_group ? aws_iam_role.cw[0].arn : var.cloudwatch_logs_role_arn
}
How to use it
This example provisions an organization trail in the management account, encrypts logs with a dedicated KMS key, streams to CloudWatch Logs for alerting, and captures S3 data events on a sensitive bucket. A downstream metric filter + alarm consumes the module’s cloudwatch_log_group_arn to page on root-account usage.
module "cloudtrail" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudtrail?ref=v1.0.0"
name = "org-audit"
is_multi_region_trail = true
is_organization_trail = true
enable_log_file_validation = true
kms_key_arn = aws_kms_key.cloudtrail.arn
create_cloudwatch_logs_group = true
log_retention_days = 2555 # ~7 years for compliance
log_glacier_transition_days = 90
cloudwatch_logs_retention_days = 90
advanced_event_selectors = [
{
name = "log-all-management-events"
field_selectors = [
{ field = "eventCategory", equals = ["Management"] }
]
},
{
name = "s3-data-events-on-sensitive-bucket"
field_selectors = [
{ field = "eventCategory", equals = ["Data"] },
{ field = "resources.type", equals = ["AWS::S3::Object"] },
{ field = "resources.ARN", starts_with = ["arn:aws:s3:::acme-pii-store/"] }
]
}
]
tags = {
Environment = "shared"
Owner = "security-platform"
Compliance = "soc2"
}
}
# Downstream: alarm on any AWS root account activity using the module's log group.
resource "aws_cloudwatch_log_metric_filter" "root_usage" {
name = "root-account-usage"
log_group_name = element(split(":", module.cloudtrail.cloudwatch_log_group_arn), 6)
pattern = "{ $.userIdentity.type = \"Root\" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != \"AwsServiceEvent\" }"
metric_transformation {
name = "RootAccountUsageCount"
namespace = "Security/CloudTrail"
value = "1"
}
}
resource "aws_cloudwatch_metric_alarm" "root_usage" {
alarm_name = "root-account-usage"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 1
metric_name = aws_cloudwatch_log_metric_filter.root_usage.metric_transformation[0].name
namespace = "Security/CloudTrail"
period = 300
statistic = "Sum"
threshold = 1
alarm_actions = [aws_sns_topic.security_alerts.arn]
treat_missing_data = "notBreaching"
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/cloudtrail/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-cloudtrail?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/cloudtrail && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Trail name; also derives bucket, log group, and role names. |
is_multi_region_trail |
bool |
true |
No | Capture events from all regions. |
is_organization_trail |
bool |
false |
No | Log all member accounts (run in org management/delegated-admin account). |
include_global_service_events |
bool |
true |
No | Include IAM, STS, CloudFront and other global events. |
enable_log_file_validation |
bool |
true |
No | Emit signed digests for tamper detection. |
enable_logging |
bool |
true |
No | Start logging immediately on create. |
kms_key_arn |
string |
null |
No | KMS key ARN for log + log-group encryption; null uses SSE-S3. |
create_s3_bucket |
bool |
true |
No | Create a dedicated delivery bucket. |
s3_bucket_name |
string |
null |
Conditional | Existing delivery bucket; required when create_s3_bucket = false. |
s3_key_prefix |
string |
null |
No | Key prefix for delivered log files. |
s3_force_destroy |
bool |
false |
No | Allow deleting a non-empty delivery bucket. |
log_retention_days |
number |
365 |
No | Days before bucket log objects expire; 0 disables expiry. |
log_glacier_transition_days |
number |
90 |
No | Days before transitioning log objects to GLACIER. |
create_cloudwatch_logs_group |
bool |
false |
No | Create CW Logs group + delivery role for real-time alerting. |
cloudwatch_logs_group_arn |
string |
null |
No | Existing CW Logs group ARN (ending :*) to deliver to. |
cloudwatch_logs_role_arn |
string |
null |
No | Existing IAM role ARN CloudTrail assumes to write logs. |
cloudwatch_logs_retention_days |
number |
90 |
No | Retention for the created CW Logs group. |
advanced_event_selectors |
list(object) |
all management events | No | Selectors for management and/or data events (S3, Lambda, DynamoDB). |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
trail_id |
The name (ID) of the CloudTrail trail. |
trail_arn |
The ARN of the trail. |
trail_name |
The name of the trail. |
home_region |
The home region in which the trail was created. |
s3_bucket_name |
Name of the S3 bucket receiving log files. |
s3_bucket_arn |
ARN of the S3 delivery bucket (created or referenced). |
cloudwatch_log_group_arn |
ARN of the CloudWatch Logs group receiving events, if enabled. |
cloudwatch_logs_role_arn |
ARN of the IAM role used for CloudWatch Logs delivery, if created. |
Enterprise scenario
A fintech running a 60-account AWS Organization deploys this module once in the security-tooling account (a delegated CloudTrail administrator) as an is_organization_trail = true, multi-region trail with enable_log_file_validation = true and a customer-managed KMS key. Every member account — existing and newly vended through the account factory — is captured automatically with zero per-account Terraform, logs land in a single locked-down S3 bucket with a 7-year (log_retention_days = 2555) lifecycle for SOC 2 and PCI evidence, and the CloudWatch Logs stream feeds metric-filter alarms that page the SOC on root usage, console logins without MFA, and security-group changes to internet-facing CIDRs.
Best practices
- Always enable log-file validation and a customer-managed KMS key. Validation lets you prove logs weren’t altered between delivery and audit; a CMK with a scoped key policy (granting
cloudtrail.amazonaws.comonlykms:GenerateDataKey*andkms:DescribeKey) means a compromised account credential can’t silently read the audit trail. Auditors treat both as table stakes. - Prefer one organization trail over N per-account trails. An org trail in the management or delegated-admin account guarantees coverage of every member — including newly created ones — and removes the failure mode where someone vends an account without a trail. It also consolidates billing into a single delivery pipeline.
- Lock down the delivery bucket and keep it in a separate account. Enable versioning, full public-access block, a TLS-only deny statement, and ideally S3 Object Lock; store logs in a dedicated log-archive account so an attacker who owns a workload account still can’t delete the evidence.
- Scope data events deliberately — they dominate cost. The first management-events copy is free, but S3/Lambda/DynamoDB data events bill per event and high-traffic buckets generate millions. Use
advanced_event_selectorswithresources.ARN starts_withto target only sensitive prefixes rather than logging every object access account-wide. - Tier storage with lifecycle rules. Transition log objects to GLACIER after ~90 days and expire them at your compliance horizon (
log_retention_days). Long-lived audit data sitting in S3 Standard is pure waste; for ad-hoc query needs, point Athena or CloudTrail Lake at the archived prefixes instead of keeping everything hot. - Name and tag for fleet operations. Derive bucket, log group, and role names from a single
nameinput (this module does) so a trail, its bucket, and its alarms are greppable as one unit across 60 accounts, and tag every trail withOwner/Complianceso config drift and cost can be attributed.