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

  1. Recorder Scope

    • Start with all_supported = true and narrow later with explicit resource_types if 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
  2. 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_rule at a time
  3. Remediation

    • Start new remediations with automatic = false and 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

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 = true took 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