Setting up AWS Config with Terraform
Learn how to enable AWS Config with Terraform, including the configuration recorder, delivery channel, managed rules, and multi-account aggregation
AWS Config continuously records the configuration of your resources and evaluates them against rules, giving you both a change history and a compliance dashboard. It’s a three-piece setup - a recorder, a delivery channel, and a status resource to actually turn recording on - that’s easy to get partially wrong, so this guide builds it piece by piece.
Prerequisites
- AWS CLI configured with appropriate permissions
- Terraform installed (version 1.0.0 or later)
Project Structure
aws-config-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
Enabling the Recorder
Three resources are required together: the recorder defines what gets recorded, the delivery channel defines where the history goes, and the status resource is what actually starts it - creating the recorder alone leaves it in a stopped state.
# main.tf
provider "aws" {
region = var.aws_region
}
resource "aws_iam_role" "config" {
name = "${var.project_name}-config-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "config.amazonaws.com"
}
}
]
})
}
# AWS_ConfigRole grants read access across supported resource types plus
# permission to deliver to the S3 bucket/SNS topic below. Confirm this
# managed policy name against current IAM docs before relying on it.
resource "aws_iam_role_policy_attachment" "config" {
role = aws_iam_role.config.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWS_ConfigRole"
}
resource "aws_s3_bucket" "config" {
bucket = "${var.project_name}-config-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_public_access_block" "config" {
bucket = aws_s3_bucket.config.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Config needs to write to this bucket and check its ACL - the managed
# role above covers the write, but same-account bucket policies are
# still commonly added explicitly for clarity and to survive policy drift
resource "aws_s3_bucket_policy" "config" {
bucket = aws_s3_bucket.config.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AWSConfigBucketPermissionsCheck"
Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:GetBucketAcl"
Resource = aws_s3_bucket.config.arn
Condition = {
StringEquals = { "AWS:SourceAccount" = data.aws_caller_identity.current.account_id }
}
},
{
Sid = "AWSConfigBucketDelivery"
Effect = "Allow"
Principal = { Service = "config.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.config.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/Config/*"
Condition = {
StringEquals = {
"AWS:SourceAccount" = data.aws_caller_identity.current.account_id
"s3:x-amz-acl" = "bucket-owner-full-control"
}
}
}
]
})
}
data "aws_caller_identity" "current" {}
resource "aws_config_configuration_recorder" "main" {
name = "${var.project_name}-recorder"
role_arn = aws_iam_role.config.arn
recording_group {
all_supported = true
include_global_resource_types = true
}
}
resource "aws_config_delivery_channel" "main" {
name = "${var.project_name}-delivery-channel"
s3_bucket_name = aws_s3_bucket.config.id
snapshot_delivery_properties {
delivery_frequency = "TwentyFour_Hours"
}
# aws_config_configuration_recorder_status references this by name, and
# the recorder can't be enabled before a delivery channel exists
depends_on = [aws_config_configuration_recorder.main]
}
# This is the resource that actually starts recording - without it, the
# recorder above exists but sits stopped
resource "aws_config_configuration_recorder_status" "main" {
name = aws_config_configuration_recorder.main.name
is_enabled = true
depends_on = [aws_config_delivery_channel.main]
}
Managed Rules
AWS Config ships dozens of managed rules identified by a source_identifier string - no Lambda function to write, just point at the identifier.
resource "aws_config_config_rule" "s3_public_read_prohibited" {
name = "${var.project_name}-s3-public-read-prohibited"
source {
owner = "AWS"
source_identifier = "S3_BUCKET_PUBLIC_READ_PROHIBITED"
}
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_config_rule" "encrypted_volumes" {
name = "${var.project_name}-encrypted-volumes"
source {
owner = "AWS"
source_identifier = "ENCRYPTED_VOLUMES"
}
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_config_rule" "root_mfa" {
name = "${var.project_name}-root-account-mfa-enabled"
source {
owner = "AWS"
source_identifier = "ROOT_ACCOUNT_MFA_ENABLED"
}
depends_on = [aws_config_configuration_recorder.main]
}
resource "aws_config_config_rule" "restricted_ssh" {
name = "${var.project_name}-restricted-ssh"
source {
owner = "AWS"
source_identifier = "INCOMING_SSH_DISABLED"
}
scope {
compliance_resource_types = ["AWS::EC2::SecurityGroup"]
}
depends_on = [aws_config_configuration_recorder.main]
}
Every aws_config_config_rule needs depends_on the recorder, since a rule can’t evaluate anything until recording has actually started.
Automatic Remediation
Some rules can trigger an SSM Automation document to fix the finding without a human in the loop - useful for high-confidence, low-risk fixes like re-blocking public S3 access.
resource "aws_config_remediation_configuration" "s3_public_read" {
config_rule_name = aws_config_config_rule.s3_public_read_prohibited.name
resource_type = "AWS::S3::Bucket"
target_type = "SSM_DOCUMENT"
target_id = "AWS-DisableS3BucketPublicReadWrite"
target_version = "1"
parameter {
name = "AutomationAssumeRole"
static_value = aws_iam_role.remediation.arn
}
parameter {
name = "S3BucketName"
resource_value = "RESOURCE_ID"
}
automatic = true
maximum_automatic_attempts = 3
retry_attempt_seconds = 60
}
resource "aws_iam_role" "remediation" {
name = "${var.project_name}-config-remediation-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ssm.amazonaws.com"
}
}
]
})
}
Multi-Account Aggregation
If you’re running Config in every account of an AWS Organization, an aggregator gives you one place to see compliance across all of them without cross-account console switching.
resource "aws_config_configuration_aggregator" "organization" {
name = "${var.project_name}-org-aggregator"
organization_aggregation_source {
all_regions = true
role_arn = aws_iam_role.aggregator.arn
}
}
resource "aws_iam_role" "aggregator" {
name = "${var.project_name}-config-aggregator-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "config.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "aggregator" {
role = aws_iam_role.aggregator.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSConfigRoleForOrganizations"
}
This requires AWS Config to be enabled as a trusted service in AWS Organizations first (aws_organizations_organization.aws_service_access_principals should include config.amazonaws.com), and typically a delegated administrator account set up via aws_config_organization_managed_rule/aws_config_organization_custom_rule if you want to push rules to every member account centrally rather than defining them per-account as above.
Variables Configuration
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "project_name" {
description = "Project name"
type = string
}
Best Practices
-
Recorder Scope
- Start with
all_supported = trueand narrow later with explicitresource_typesif the volume of recorded items becomes a cost concern - Include global resource types (IAM) in exactly one region per account - recording them in every region just duplicates the same IAM history
- Start with
-
Rules
- Prefer managed rules over custom Lambda-backed rules unless you have a genuinely org-specific check - they need no code to maintain
- Use conformance packs to deploy a curated set of rules (e.g. a CIS benchmark subset) as one unit instead of one
aws_config_config_ruleat a time
-
Remediation
- Start new remediations with
automatic = falseand watch them fire manually before flipping to automatic - an auto-remediation that mis-triggers on a resource type it wasn’t tested against is its own incident
- Start new remediations with
Conclusion
AWS Config’s value is almost entirely in the recorder actually being on - a recorder resource that exists but was never enabled via the status resource is a surprisingly common (and silent) misconfiguration. Verify recording is active after every apply with aws configservice describe-configuration-recorder-status.
Remember to:
- Confirm
is_enabled = truetook effect, not just that the resources applied cleanly - Review new AWS-managed rules periodically - AWS adds new ones for newly launched services
- Keep the delivery bucket’s lifecycle policy in mind; configuration history accumulates indefinitely by default