Skip to content

Repository files navigation

Terraform AWS Config Recorder

Introduction

AWS Control Tower establishes an AWS Config baseline in enrolled accounts (including creating/enabling a configuration recorder), but it does not provide a central way to consistently configure that recorder across accounts.

The practical impact is that accounts can drift back to a “record everything, continuously” posture (or simply remain there), which can create unexpectedly high AWS Config costs at scale—especially in large organizations with many accounts and regions.

Intent

This module provides a repeatable way to enforce a desired AWS Config recorder configuration across member accounts enrolled in Control Tower, from the organization management account, without replacing the Control Tower baseline.

It helps you:

  • Standardize recorder settings across accounts
  • Reduce cost exposure by switching to daily recording, excluding resource types, or applying overrides
  • Continuously remediate drift (on a schedule) rather than relying on one-time manual changes

Deployment model

Deploy this module in the AWS Organizations management account (the account that owns the organization).

The Lambda runs there so it can call organizations:ListAccounts and resolve member accounts by name. For each configured entry, the function assumes the Control Tower execution role in the target account. That role is named AWSControlTowerExecution in every enrolled account.

Cross-account access is constrained in two ways:

  1. Lambda execution role (main.tf): sts:AssumeRole is allowed only for arn:aws:iam::*:role/AWSControlTowerExecution.
  2. AssumeRole session policy (assets/functions/client.py): each sts:AssumeRole call attaches an inline session policy that can only reduce privileges. It limits the session to config:DescribeConfigurationRecorders, config:DescribeConfigurationRecorderStatus, and config:PutConfigurationRecorder.

How it works

  • The desired configuration is stored as JSON in AWS Secrets Manager (secret_manager_name).
  • A Lambda reads the secret, lists accounts in Organizations, and for each matching member account and region updates the existing baseline recorder (recorder_name) via the Config API using credentials from the assumed AWSControlTowerExecution role.
  • Amazon EventBridge invokes the Lambda on schedule_expression.

Account filters and regions

The config input is a map. Each value describes how to tune the recorder for one Organizations member account:

  • filter.names (required): list of account names as returned by Organizations (ListAccounts), not account IDs. The Lambda applies this map entry to every member account whose name appears in the list (exact string match). Use a single-element list for one account, or several names when the same recorder settings should apply to multiple accounts from one block. An empty list matches no accounts.
  • filter.regions (optional): when set to a non-empty list, the Lambda applies that block only in those regions and does not use var.regions for that entry. When omitted, null, or [], that entry uses the module default regions below.

The top-level keys in config (for example devops, workloads) are arbitrary labels for Terraform and become keys in the JSON secret; they are not sent to AWS.

var.regions vs filter.regions

Input Role
var.regions Default region list for every config entry that does not set filter.regions. Passed to the Lambda as RECORDER_REGIONS (comma-separated). If you leave it as the default [], the function uses the Lambda runtime region (AWS_REGION) for those entries—typically the region where you deploy the stack.
filter.regions Per-entry override: replaces var.regions for that map entry only (for all accounts listed in that entry’s filter.names), so you can target one region for one group of accounts and a broader list for another.

Usage examples

Example 1: Daily recording and exclusions (shared default regions)

module "config_recorder" {
  source = "appvia/config-recorder/aws"

  name                = "aws-config-recorder"
  recorder_name       = "aws-controltower-BaselineConfigRecorder"
  schedule_expression = "cron(0 2 * * ? *)"
  secret_manager_name = "/org/aws-config/recorder/config"

  regions = ["eu-west-2", "us-east-1"]

  config = {
    workloads = {
      filter = {
        names = ["Workloads"] # Must match Organizations account name(s)
      }
      mode = "DAILY"
      exclude_resources = [
        "AWS::CloudTrail::Trail",
        "AWS::Config::ResourceCompliance",
      ]
    }
  }
}

Example 2: Continuous recording for specific resource types — filter.regions overrides var.regions

For this entry only, the recorder is updated in eu-west-2 even if var.regions lists additional regions.

module "config_recorder" {
  source = "appvia/config-recorder/aws"

  name                = "aws-config-recorder"
  recorder_name       = "aws-controltower-BaselineConfigRecorder"
  schedule_expression = "rate(6 hours)"
  secret_manager_name = "/org/aws-config/recorder/config"

  regions = ["eu-west-2", "us-east-1"]

  config = {
    security = {
      filter = {
        names   = ["Security"]
        regions = ["eu-west-2"]
      }
      mode      = "CONTINUOUS"
      resources = [
        "AWS::S3::Bucket",
        "AWS::EC2::Instance",
      ]
    }
  }
}

Example 3: Multiple member accounts

module "config_recorder" {
  source = "appvia/config-recorder/aws"

  name                = "aws-config-recorder"
  recorder_name       = "aws-controltower-BaselineConfigRecorder"
  schedule_expression = "rate(1 day)"
  secret_manager_name = "/org/aws-config/recorder/config"

  regions = ["eu-west-2"]

  config = {
    development = {
      filter = { names = ["Development"] }
      mode   = "DAILY"
    }
    production = {
      filter = {
        names   = ["Production"]
        regions = ["eu-west-2", "us-east-1"]
      }
      mode = "DAILY"
    }
  }
}

Example 4: One config block, several account names

Use multiple entries in filter.names when the same mode, resource lists, and overrides should apply to more than one member account (each name must still match Organizations exactly).

  config = {
    shared_daily = {
      filter = {
        names = ["Devops", "PlatformSandbox"]
      }
      mode = "DAILY"
    }
  }

Notes / assumptions

  • Management account: required so Organizations APIs and the intended assume-role pattern align with Control Tower landing zones.
  • Existing recorder: the module does not create the recorder or delivery channel; it updates the existing Control Tower baseline recorder.
  • Pause scheduled runs: set enable = false on the module to skip creating the EventBridge schedule (the Lambda and secret can remain for ad-hoc use).

Providers

Name Version
aws >= 6.0.0

Inputs

Name Description Type Default Required
schedule_expression The EventBridge schedule expression used to invoke the Lambda string n/a yes
config Desired recorder settings per logical block, stored as JSON in Secrets Manager. Each map entry is applied
to the member account whose Organizations account name matches filter.name (see README).
map(object({
# The account filter to filter the accounts to apply the configuration to
filter = object({
# Name of the account
names = list(string)
# If set, only these regions; if null or [], use var.regions (or Lambda region when var.regions is empty)
regions = optional(list(string), [])
})
# Include we set all supported resources in the recorder
enable_all_supported = optional(bool, true)
# Indicates whether global resources should be recorded
enable_global = optional(bool, true)
# The mode to apply to the recorder (i.e. DAILY or CONTINUOUS)
mode = optional(string, "CONTINUOUS")
# The resources to include in the recorder
resources = optional(list(string), [])
# The resources to exclude from the recorder
exclude_resources = optional(list(string), [])
# The overrides to apply to the recorder
overrides = optional(list(object({
# A human-readable description of the override
description = string
# The resource to apply the override to
resources = list(string)
# The type of override to apply (DAILY or CONTINUOUS)
override_type = string
})), [])
}))
{} no
enable Whether to enable the config recorder configuration bool true no
enable_debug Whether to enable debug mode (which will print debug logs to the CloudWatch logs) bool false no
enable_dry_run Whether to enable dry run mode (which will not make any changes to the recorder configuration) bool false no
lambda_memory_size The amount of memory in MB for the Lambda number 256 no
lambda_runtime The runtime for the Lambda string "python3.13" no
lambda_timeout The timeout in seconds for the Lambda number 120 no
name The base name used for resources string "lz-config-recorder" no
recorder_name Name of the existing AWS Config recorder to manage string "aws-controltower-BaselineConfigRecorder" no
regions Default region list for every config entry whose filter.regions is omitted or empty. Passed to the
Lambda as RECORDER_REGIONS. When empty, the function uses the Lambda runtime region (AWS_REGION).
list(string) [] no
secret_manager_name Name of the Secrets Manager secret that stores the recorder configuration JSON string "/lz/aws-config/config" no
sns_topic_arn Optional SNS topic ARN to notify when the Lambda alarm fires string null no
tags A collection of tags to apply to resources map(string) {} no
trigger_on_package_timestamp Whether to trigger the Lambda on the package timestamp bool false no

Outputs

Name Description
eventbridge_rule_name Name of the EventBridge rule invoking the Lambda
lambda_failure_alarm_arn ARN of the CloudWatch alarm which triggers on Lambda errors
lambda_function_arn ARN of the Lambda function
secret_manager_name Name of the Secret Manager secret holding the JSON configuration

About

Used to customize the Control Tower recorder configuration in the absence of a CT configurable

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages