IaC AWS

Terraform Module: AWS Config — Continuous Compliance Recording in One Reusable Block

Quick take — A production-ready Terraform module for hashicorp/aws ~> 5.0 that provisions an AWS Config configuration recorder, S3 delivery channel, and recorder status with sane defaults and validations. 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 "config" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-config?ref=v1.0.0"

  name_prefix    = "..."  # Prefix for recorder, channel, and IAM role names (e.g. …
  s3_bucket_name = "..."  # S3 bucket that receives Config snapshots and history.
  s3_bucket_arn  = "..."  # ARN of the delivery bucket, used to scope the IAM polic…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS Config continuously records the configuration of your AWS resources and lets you evaluate that state against compliance rules. The engine behind it is the configuration recorder (aws_config_configuration_recorder): it decides which resource types are tracked, how often, and under which IAM role. On its own a recorder is inert — it must be paired with a delivery channel that ships configuration snapshots and history to S3, and then explicitly started via a recorder status resource. Miss any one of those three and you get a recorder that exists but silently records nothing.

This module wraps all three resources — recorder, delivery channel, and recorder status — into a single, var-driven block so every account and region enables Config the same way. It bakes in the gotchas teams hit repeatedly: a recorder can only be is_started once a delivery channel exists, all_supported is mutually exclusive with an explicit resource_types list, and global resources (IAM users, roles, policies) should be recorded in exactly one region to avoid duplicate-cost noise. Wrapping it in a module turns “did someone remember to wire up the delivery channel?” into a reviewed, versioned, copy-paste-free decision.

When to use it

Module structure

terraform-module-aws-config/
├── versions.tf      # provider + terraform version constraints
├── main.tf          # recorder + delivery channel + status
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # recorder/channel ids + names
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  recorder_name = coalesce(var.recorder_name, "${var.name_prefix}-config-recorder")
  channel_name  = coalesce(var.delivery_channel_name, "${var.name_prefix}-config-channel")
}

# IAM service-linked-style role that Config assumes to read resource configs.
data "aws_iam_policy_document" "assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["config.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "config" {
  count = var.create_iam_role ? 1 : 0

  name                 = "${var.name_prefix}-config-role"
  assume_role_policy   = data.aws_iam_policy_document.assume.json
  permissions_boundary = var.permissions_boundary_arn
  tags                 = var.tags
}

# AWS-managed policy granting Config read access to resource configurations.
resource "aws_iam_role_policy_attachment" "config" {
  count = var.create_iam_role ? 1 : 0

  role       = aws_iam_role.config[0].name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}

# Inline policy allowing the recorder to write snapshots to the delivery bucket.
data "aws_iam_policy_document" "delivery" {
  count = var.create_iam_role ? 1 : 0

  statement {
    sid       = "AllowDeliveryToBucket"
    effect    = "Allow"
    actions   = ["s3:PutObject", "s3:GetBucketAcl"]
    resources = [
      var.s3_bucket_arn,
      "${var.s3_bucket_arn}/*",
    ]
  }

  dynamic "statement" {
    for_each = var.s3_kms_key_arn != null ? [1] : []
    content {
      sid       = "AllowKmsForDelivery"
      effect    = "Allow"
      actions   = ["kms:GenerateDataKey", "kms:Decrypt"]
      resources = [var.s3_kms_key_arn]
    }
  }
}

resource "aws_iam_role_policy" "delivery" {
  count = var.create_iam_role ? 1 : 0

  name   = "config-delivery"
  role   = aws_iam_role.config[0].id
  policy = data.aws_iam_policy_document.delivery[0].json
}

resource "aws_config_configuration_recorder" "this" {
  name     = local.recorder_name
  role_arn = var.create_iam_role ? aws_iam_role.config[0].arn : var.iam_role_arn

  recording_group {
    # When recording everything, all_supported=true and an explicit list are mutually exclusive.
    all_supported                 = var.record_all_supported
    include_global_resource_types = var.record_all_supported ? var.record_global_resource_types : false
    resource_types                = var.record_all_supported ? null : var.resource_types
  }

  dynamic "recording_mode" {
    for_each = var.recording_frequency != null ? [1] : []
    content {
      recording_frequency = var.recording_frequency
    }
  }
}

resource "aws_config_delivery_channel" "this" {
  name           = local.channel_name
  s3_bucket_name = var.s3_bucket_name
  s3_key_prefix  = var.s3_key_prefix
  s3_kms_key_arn = var.s3_kms_key_arn
  sns_topic_arn  = var.sns_topic_arn

  snapshot_delivery_properties {
    delivery_frequency = var.snapshot_delivery_frequency
  }

  # The delivery channel cannot be created before the recorder exists.
  depends_on = [aws_config_configuration_recorder.this]
}

resource "aws_config_configuration_recorder_status" "this" {
  name       = aws_config_configuration_recorder.this.name
  is_enabled = var.recorder_enabled

  # Starting the recorder requires a delivery channel to be present first.
  depends_on = [aws_config_delivery_channel.this]
}
# variables.tf

variable "name_prefix" {
  description = "Prefix applied to recorder, channel, and IAM role names (e.g. \"prod-euw1\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,40}$", var.name_prefix))
    error_message = "name_prefix must be 2-41 chars, lowercase alphanumeric and hyphens, starting with an alphanumeric."
  }
}

variable "recorder_name" {
  description = "Explicit recorder name. Defaults to \"<name_prefix>-config-recorder\" when null."
  type        = string
  default     = null
}

variable "delivery_channel_name" {
  description = "Explicit delivery channel name. Defaults to \"<name_prefix>-config-channel\" when null."
  type        = string
  default     = null
}

variable "create_iam_role" {
  description = "Create the IAM role Config assumes. Set false to supply an existing role via iam_role_arn."
  type        = bool
  default     = true
}

variable "iam_role_arn" {
  description = "ARN of a pre-existing IAM role for Config. Required when create_iam_role is false."
  type        = string
  default     = null

  validation {
    condition     = var.iam_role_arn == null || can(regex("^arn:aws[a-z-]*:iam::[0-9]{12}:role/.+$", var.iam_role_arn))
    error_message = "iam_role_arn must be a valid IAM role ARN."
  }
}

variable "permissions_boundary_arn" {
  description = "Optional permissions boundary applied to the created IAM role."
  type        = string
  default     = null
}

variable "record_all_supported" {
  description = "Record all supported resource types. When false, only resource_types are recorded."
  type        = bool
  default     = true
}

variable "record_global_resource_types" {
  description = "Include global resources (IAM, etc.). Enable in ONE region only to avoid duplicate cost."
  type        = bool
  default     = false
}

variable "resource_types" {
  description = "Explicit resource types to record when record_all_supported is false (e.g. [\"AWS::EC2::Instance\"])."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for t in var.resource_types : can(regex("^AWS::[A-Za-z0-9]+::[A-Za-z0-9]+$", t))])
    error_message = "Each resource type must look like \"AWS::Service::Resource\" (e.g. AWS::S3::Bucket)."
  }
}

variable "recording_frequency" {
  description = "Default recording mode: CONTINUOUS or DAILY. Null keeps the AWS default (CONTINUOUS)."
  type        = string
  default     = null

  validation {
    condition     = var.recording_frequency == null || contains(["CONTINUOUS", "DAILY"], var.recording_frequency)
    error_message = "recording_frequency must be CONTINUOUS or DAILY."
  }
}

variable "recorder_enabled" {
  description = "Whether the configuration recorder is started (recording) after creation."
  type        = bool
  default     = true
}

variable "s3_bucket_name" {
  description = "Name of the S3 bucket that receives Config snapshots and history."
  type        = string
}

variable "s3_bucket_arn" {
  description = "ARN of the delivery S3 bucket, used to scope the IAM delivery policy."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-z-]*:s3:::.+$", var.s3_bucket_arn))
    error_message = "s3_bucket_arn must be a valid S3 bucket ARN (arn:aws:s3:::bucket-name)."
  }
}

variable "s3_key_prefix" {
  description = "Optional key prefix under which Config writes objects in the bucket."
  type        = string
  default     = null
}

variable "s3_kms_key_arn" {
  description = "Optional KMS key ARN used to encrypt delivered Config objects (SSE-KMS)."
  type        = string
  default     = null
}

variable "sns_topic_arn" {
  description = "Optional SNS topic ARN for Config configuration/compliance change notifications."
  type        = string
  default     = null
}

variable "snapshot_delivery_frequency" {
  description = "How often Config delivers a configuration snapshot to S3."
  type        = string
  default     = "TwentyFour_Hours"

  validation {
    condition = contains([
      "One_Hour", "Three_Hours", "Six_Hours", "Twelve_Hours", "TwentyFour_Hours",
    ], var.snapshot_delivery_frequency)
    error_message = "snapshot_delivery_frequency must be one of One_Hour, Three_Hours, Six_Hours, Twelve_Hours, TwentyFour_Hours."
  }
}

variable "tags" {
  description = "Tags applied to taggable resources (the IAM role)."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "recorder_id" {
  description = "The name/ID of the AWS Config configuration recorder."
  value       = aws_config_configuration_recorder.this.id
}

output "recorder_name" {
  description = "The name of the configuration recorder."
  value       = aws_config_configuration_recorder.this.name
}

output "recorder_role_arn" {
  description = "ARN of the IAM role the recorder uses (created or supplied)."
  value       = var.create_iam_role ? aws_iam_role.config[0].arn : var.iam_role_arn
}

output "delivery_channel_id" {
  description = "The ID of the Config delivery channel."
  value       = aws_config_delivery_channel.this.id
}

output "delivery_channel_name" {
  description = "The name of the Config delivery channel."
  value       = aws_config_delivery_channel.this.name
}

output "recorder_enabled" {
  description = "Whether the recorder is currently started (recording)."
  value       = aws_config_configuration_recorder_status.this.is_enabled
}

How to use it

Consume the module against a central, encrypted delivery bucket. This example records everything and pins global resource types on in this single home region (eu-west-1); peer regions would set record_global_resource_types = false.

module "config" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-config?ref=v1.0.0"

  name_prefix = "prod-euw1"

  # Delivery target (created elsewhere — bucket policy must allow config.amazonaws.com).
  s3_bucket_name = aws_s3_bucket.config.id
  s3_bucket_arn  = aws_s3_bucket.config.arn
  s3_kms_key_arn = aws_kms_key.config.arn
  s3_key_prefix  = "config"

  # Record all supported types; global resources only in this home region.
  record_all_supported         = true
  record_global_resource_types = true
  recording_frequency          = "CONTINUOUS"

  snapshot_delivery_frequency = "Six_Hours"
  sns_topic_arn               = aws_sns_topic.config_alerts.arn

  tags = {
    Environment = "prod"
    Compliance  = "cis-1.4"
    ManagedBy   = "terraform"
  }
}

# Downstream: a Config managed rule attaches to the recorder by name, ensuring it
# only evaluates once recording is actually live.
resource "aws_config_config_rule" "s3_encryption" {
  name = "s3-bucket-server-side-encryption-enabled"

  source {
    owner             = "AWS"
    source_identifier = "S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED"
  }

  # Reference an output so the rule depends on the recorder being provisioned.
  depends_on = [module.config]
}

output "active_config_recorder" {
  value = module.config.recorder_name
}

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/config/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-config?ref=v1.0.0"
}

inputs = {
  name_prefix = "..."
  s3_bucket_name = "..."
  s3_bucket_arn = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/config && 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_prefix string Yes Prefix for recorder, channel, and IAM role names (e.g. prod-euw1).
recorder_name string null No Explicit recorder name; defaults to <name_prefix>-config-recorder.
delivery_channel_name string null No Explicit delivery channel name; defaults to <name_prefix>-config-channel.
create_iam_role bool true No Create the IAM role Config assumes; set false to bring your own.
iam_role_arn string null No ARN of a pre-existing Config IAM role (required when create_iam_role = false).
permissions_boundary_arn string null No Optional permissions boundary for the created IAM role.
record_all_supported bool true No Record all supported types; when false, only resource_types are recorded.
record_global_resource_types bool false No Include global resources (IAM, etc.); enable in one region only.
resource_types list(string) [] No Explicit resource types when record_all_supported = false.
recording_frequency string null No Recording mode: CONTINUOUS or DAILY; null keeps AWS default.
recorder_enabled bool true No Whether the recorder is started after creation.
s3_bucket_name string Yes S3 bucket that receives Config snapshots and history.
s3_bucket_arn string Yes ARN of the delivery bucket, used to scope the IAM policy.
s3_key_prefix string null No Optional key prefix for delivered objects.
s3_kms_key_arn string null No Optional KMS key ARN for SSE-KMS on delivered objects.
sns_topic_arn string null No Optional SNS topic for Config change notifications.
snapshot_delivery_frequency string "TwentyFour_Hours" No Snapshot cadence: One_HourTwentyFour_Hours.
tags map(string) {} No Tags applied to the IAM role.

Outputs

Name Description
recorder_id The name/ID of the configuration recorder.
recorder_name The name of the configuration recorder.
recorder_role_arn ARN of the IAM role the recorder uses.
delivery_channel_id The ID of the Config delivery channel.
delivery_channel_name The name of the Config delivery channel.
recorder_enabled Whether the recorder is currently started (recording).

Enterprise scenario

A financial-services platform team runs a 60-account AWS Organization and must prove CIS-aligned continuous configuration recording for an upcoming audit. They deploy this module from their account-baseline Terraform across every account and region, pointing each delivery channel at a KMS-encrypted central bucket in the log-archive account, with record_global_resource_types = true only in us-east-1 to keep IAM history single-sourced. A downstream Config aggregator in the audit account then rolls every recorder’s findings into one compliance dashboard, and because the recorder name is exported, their Conformance Pack pipeline asserts the recorder is enabled in each account before publishing rules.

Best practices

TerraformAWSConfigModuleIaC
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