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
- You are rolling out a multi-account landing zone and need Config enabled identically across every member account before Security Hub or Config Conformance Packs can evaluate anything.
- A compliance framework (CIS, PCI-DSS, SOC 2, FedRAMP) mandates continuous configuration history and you must prove the recorder is on in every region.
- You want to record global resource types in a single home region and resource-scoped types everywhere else, without copy-pasting recorder blocks per account.
- You need Config snapshots delivered to a central, encrypted S3 bucket for cross-account aggregation, and want the delivery frequency and KMS key managed as code.
- Skip it if you only need a one-off recorder in a sandbox — but the moment a second account appears, the module pays for itself.
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 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/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_Hour…TwentyFour_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
- Record global resources in exactly one region. Setting
record_global_resource_types = trueeverywhere duplicates IAM/Route 53 configuration items and inflates Config’s per-item cost; pin it to a single home region and leave it false elsewhere. - Always encrypt the delivery bucket with SSE-KMS and pass
s3_kms_key_arnso the module grants the recorderkms:GenerateDataKey; the bucket policy must still allowconfig.amazonaws.comtos3:PutObjectands3:GetBucketAcl. - Use
DAILYrecording mode or a longer snapshot frequency in low-change accounts. Sandboxes and dormant accounts rarely needCONTINUOUSrecording —recording_frequency = "DAILY"and a 24-hour snapshot cadence cut cost without losing audit coverage. - Bound the IAM role with a permissions boundary via
permissions_boundary_arnin regulated accounts, and scope the delivery policy to the exact bucket ARN rather than*(the module already does the latter). - Treat the recorder status as the source of truth. A recorder that exists but is not started records nothing — assert
recorder_enableddownstream before any Conformance Pack or Security Hub standard depends on Config data. - Keep naming deterministic. Drive names off
name_prefix(region- and environment-qualified) so recorders are unambiguous across a multi-account aggregator instead of every account showing a genericdefaultrecorder.