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.
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
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:
- Lambda execution role (
main.tf):sts:AssumeRoleis allowed only forarn:aws:iam::*:role/AWSControlTowerExecution. - AssumeRole session policy (
assets/functions/client.py): eachsts:AssumeRolecall attaches an inline session policy that can only reduce privileges. It limits the session toconfig:DescribeConfigurationRecorders,config:DescribeConfigurationRecorderStatus, andconfig:PutConfigurationRecorder.
- 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 assumedAWSControlTowerExecutionrole. - Amazon EventBridge invokes the Lambda on
schedule_expression.
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 usevar.regionsfor 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.
| 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. |
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",
]
}
}
}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",
]
}
}
}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"
}
}
}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"
}
}- 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 = falseon the module to skip creating the EventBridge schedule (the Lambda and secret can remain for ad-hoc use).
| Name | Version |
|---|---|
| aws | >= 6.0.0 |
| 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({ |
{} |
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 theLambda 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 |
| 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 |