IaC AWS

Terraform Module: AWS CloudTrail — Tamper-Evident Audit Trails You Can Stamp Out Per Account

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

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 configlive/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 configlive/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

TerraformAWSCloudTrailModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading