Groups existing AWS resources for console visibility, tagging governance, and automation targeting — a classic tag/CloudFormation query group or an AppRegistry/service-configuration group, plus optional explicit resource attachments. Built for the AWS provider v6.x.
- 🗂️ Two ways to define a group, never both. A classic query-based group (
resource_query— tag filters or a CloudFormation stack) or a service-configuration group (configuration— AppRegistry applications, generic groupings, Capacity Reservation pools, Network Firewall rule groups). The module enforces exactly one at plan time. - 📎 Optional explicit membership.
resource_arnsattaches specific resources to the group directly (aws_resourcegroups_resource) alongside — or instead of relying entirely on — a dynamic tag/CloudFormation query. - 🪶 Organizational, not data-plane. No encryption, no public-access surface, no network exposure — this module manages metadata about resources, never the resources themselves.
- 🗺️ Map-driven
configuration. Configuration items aremap(object(...))keyed by a stable caller string, each item's parameters collapsed tomap(list(string))— no redundantnamefields. - 🏷️ Tags on the group only.
var.tagsflows to the group and merges with providerdefault_tags;aws_resourcegroups_resourcemembership records are not taggable (the AWS API has no tags for them). - 🌍 Regional service. No
regionvariable, no us-east-1 constraint — group membership never crosses Regions.
💡 Why it matters: in a large, multi-account FI estate, "which resources back this application" is a governance question as much as a technical one — a clean group boundary is what lets Backup selections, Network Firewall rule groups, and SSM automations target the right blast radius without hand-maintained ARN lists drifting out of date.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
tf-mod-aws-resource-groups is a near-foundation, cross-cutting module — it takes ARNs from virtually any other module in the library and feeds its own arn into automation-targeting modules downstream.
flowchart LR
EC2["tf-mod-aws-ec2-instance"]
RDS["tf-mod-aws-rds"]
S3["tf-mod-aws-s3-bucket"]
LAMBDA["tf-mod-aws-lambda (Phase 7)"]
CFN["tf-mod-aws-cloudformation (Phase 4)"]
RG["tf-mod-aws-resource-groups"]
NFW["tf-mod-aws-network-firewall (Phase 6)"]
BACKUP["tf-mod-aws-backup"]
SSM["tf-mod-aws-ssm (Phase 4)"]
EC2 -- "resource_arns" --> RG
RDS -- "resource_arns" --> RG
S3 -- "resource_arns" --> RG
LAMBDA -- "resource_arns" --> RG
CFN -- "StackIdentifier (query)" --> RG
RG -- "arn" --> NFW
RG -- "arn" --> BACKUP
RG -- "arn" --> SSM
style RG fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
flowchart TB
subgraph Module["tf-mod-aws-resource-groups"]
GRP["aws_resourcegroups_group.this<br/>(keystone)"]
RES["aws_resourcegroups_resource.this<br/>for_each var.resource_arns"]
end
RQ["var.resource_query<br/>(tag / CloudFormation query)"]
CFG["var.configuration<br/>(AppRegistry / service config)"]
ARN["var.resource_arns<br/>(explicit ARNs)"]
RQ -. "XOR".-> GRP
CFG -. "XOR".-> GRP
ARN --> RES
GRP -- "group_arn" --> RES
style GRP fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
style RES stroke-dasharray: 5 5
| Resource | Count | Created when |
|---|---|---|
aws_resourcegroups_group.this |
1 | always (keystone) |
aws_resourcegroups_resource.this |
0..N | one per resource_arns entry |
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 |
The module declares only a required_providers block (providers.tf) and inherits the configured provider. There is no provider {} block and no credential variable — credentials resolve through the standard AWS chain at the root/pipeline level (env vars → SSO/shared credentials → assume_role → instance profile / IRSA → OIDC web identity).
Least-privilege actions the Terraform execution identity needs to manage this module.
| Action | Required for | Notes |
|---|---|---|
resource-groups:CreateGroup, resource-groups:GetGroup, resource-groups:GetGroupQuery |
Group lifecycle / read-back | — |
resource-groups:UpdateGroup, resource-groups:UpdateGroupQuery |
In-place update of description / resource_query |
— |
resource-groups:PutGroupConfiguration, resource-groups:GetGroupConfiguration |
Service-configuration (configuration-based) groups |
Only when configuration is set |
resource-groups:DeleteGroup |
Destroy | — |
resource-groups:Tag, resource-groups:Untag, resource-groups:GetTags |
Tag management | — |
resource-groups:GroupResources, resource-groups:UngroupResources, resource-groups:ListGroupResources |
aws_resourcegroups_resource membership |
One per resource_arns entry |
cloudformation:DescribeStacks |
Read-back when using CLOUDFORMATION_STACK_1_0 queries |
Only when resource_query.type = "CLOUDFORMATION_STACK_1_0" |
No iam:PassRole is required — Resource Groups is a control-plane construct with no service role of its own, and there is no service-linked role to create.
🔒 Scope
resource-groups:*actions to the group ARN pattern (arn:aws:resource-groups:<region>:<account>:group/casey-*) — a loosely-scoped grant lets a principal redefine which resources an automation (Backup selection, Network Firewall rule group, SSM target) applies to.
- No service-linked role is required for AWS Resource Groups.
- Tag-based groups depend on an upstream tagging strategy. A
TAG_FILTERS_1_0query only surfaces resources that are actually tagged consistently — wire the same tag keys/values this module's query targets into thetagsvariable of the resources you expect to match. configuration-based groups are a distinct path fromresource_query. AppRegistry-style groups (AWS::AppRegistry::Application) are typically created for you when an AppRegistry application is registered; hand-authoring one here is mainly forAWS::ResourceGroups::Generic,AWS::EC2::CapacityReservationPool, orAWS::NetworkFirewall::RuleGroupconfiguration types.- Region: Resource Groups is regional — a group only contains resources in its own Region. No
regionvariable; the module inherits the provider's Region. - Quotas: default 200 groups per account per Region (soft, raisable via Service Quotas).
tf-mod-aws-resource-groups/
├── providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block
├── variables.tf # name → description → resource_query / configuration → resource_arns → tags → timeouts
├── main.tf # aws_resourcegroups_group.this + aws_resourcegroups_resource.this
├── outputs.tf # id + arn + name + resource_ids/resource_types maps + tags_all
├── README.md # this file
└── SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas
Smallest production-shaped call — a tag-based group with an explicit attachment:
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-prod-app"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [
{ Key = "Environment", Values = ["prod"] },
{ Key = "Application", Values = ["core-banking"] }
]
})
}
resource_arns = [
module.rds.arn, # from tf-mod-aws-rds
module.ec2_instance.arn, # from tf-mod-aws-ec2-instance
]
tags = {
Environment = "prod"
DataClass = "confidential"
}
}| Input | Type | Source module |
|---|---|---|
resource_arns |
list(string) (any ARN-addressable resource) |
Any module — tf-mod-aws-ec2-instance, tf-mod-aws-rds, tf-mod-aws-s3-bucket, tf-mod-aws-lambda, etc. |
resource_query.query (embedded StackIdentifier) |
string (CloudFormation stack ARN) |
tf-mod-aws-cloudformation (Phase 4) |
| Output | Description | Consumed by |
|---|---|---|
id |
Group id (the group name) | aws_resourcegroups_resource.group_arn (accepts name or ARN), CLI/console references |
arn |
Group ARN arn:aws:resource-groups:<region>:<account>:group/<name> — the cross-resource reference type |
tf-mod-aws-network-firewall stateful rule groups, tf-mod-aws-backup selections targeting a group, IAM policies scoping access to this group |
name |
Group name | Console/CLI, tagging/reporting dashboards |
resource_ids |
Map of membership record ids (group_arn,resource_arn) keyed by resource_arn |
Import/diagnostics |
resource_types |
Map of AWS resource type strings (e.g. AWS::EC2::Instance) keyed by resource_arn |
Inventory/reporting tooling |
tags_all |
All tags incl. provider default_tags (resource tags win) |
Governance/audit |
1 · Minimal tag-based group
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-landing"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "Project", Values = ["landing-zone"] }]
})
}
}2 · CloudFormation stack-based group
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-stack-group"
resource_query = {
type = "CLOUDFORMATION_STACK_1_0"
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
StackIdentifier = "arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/abc123"
})
}
}3 · Tags (merge with provider default_tags)
# Caller's provider block owns default_tags; the module never sets it.
provider "aws" {
default_tags { tags = { Owner = "platform", ManagedBy = "terraform" } }
}
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-tagged"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "Environment", Values = ["prod"] }]
})
}
tags = {
Environment = "prod" # resource tag — wins over default_tags on key conflict
DataClass = "confidential"
}
}
# module.rg.tags_all == { Owner, ManagedBy, Environment, DataClass }4 · Secure-by-default opt-out — n/a (no data-plane defaults to loosen)
# Resource Groups has no encryption / public-access / logging posture to opt out
# of — it is a control-plane construct. The one non-negotiable the module
# enforces is the resource_query / configuration XOR, which mirrors the AWS
# API's own constraint rather than an secure-default choice, so there is
# no opt-out variable for it. This example instead shows the module's only
# real "loosening" lever: explicit resource_arns membership bypasses the
# tag/CloudFormation query discovery mechanism for a specific resource.
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-explicit-only"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "Owner", Values = ["never-matches"] }]
})
}
resource_arns = [
module.s3_bucket.arn, # attached explicitly regardless of tag drift
]
}5 · Generic service-configuration group
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-generic-config"
configuration = {
generic = {
type = "AWS::ResourceGroups::Generic"
parameters = {
AllowedResourceTypes = ["AWS::EC2::Instance", "AWS::RDS::DBInstance"]
}
}
}
}6 · AppRegistry application group
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-appregistry"
configuration = {
application = {
type = "AWS::AppRegistry::Application"
}
}
}7 · Network Firewall rule-group configuration
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-nfw-rulegroup"
configuration = {
nfw = {
type = "AWS::NetworkFirewall::RuleGroup"
}
}
resource_arns = [
module.security_group.arn, # from tf-mod-aws-security-group, kept in sync with the rule group
]
}8 · Explicit resource membership only (no query, config-driven)
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-explicit-inventory"
configuration = {
generic = { type = "AWS::ResourceGroups::Generic" }
}
resource_arns = [
module.ec2_instance_a.arn,
module.ec2_instance_b.arn,
module.rds.arn,
]
}9 · Custom timeouts
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-slow-account"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "Environment", Values = ["prod"] }]
})
}
timeouts = {
create = "30m"
update = "30m"
delete = "10m"
}
}10 · for_each pattern — one group per business unit
locals {
business_units = {
retail = "Retail"
ag_credit = "AgCredit"
wholesale = "Wholesale"
}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
for_each = local.business_units
name = "casey-${each.key}"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "BusinessUnit", Values = [each.value] }]
})
}
tags = { BusinessUnit = each.value }
}11 · import block — adopt an existing group
import {
to = module.rg.aws_resourcegroups_group.this
id = "casey-existing-group"
}
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-existing-group"
resource_query = {
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
TagFilters = [{ Key = "Environment", Values = ["prod"] }]
})
}
}12 · Multiple configuration items on one group
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-multi-config"
configuration = {
generic = {
type = "AWS::ResourceGroups::Generic"
parameters = { AllowedResourceTypes = ["AWS::AllSupported"] }
}
application = {
type = "AWS::AppRegistry::ApplicationResourceGroup"
}
}
}13 · End-to-end composition — CloudFormation stack group feeding Backup + Network Firewall
# The application's CloudFormation stack (managed elsewhere / imported)
data "aws_cloudformation_stack" "app" {
name = "core-banking-app"
}
# Resource group scoped to that stack's resources
module "rg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-resource-groups?ref=v1.0.0"
name = "casey-core-banking"
resource_query = {
type = "CLOUDFORMATION_STACK_1_0"
query = jsonencode({
ResourceTypeFilters = ["AWS::AllSupported"]
StackIdentifier = data.aws_cloudformation_stack.app.id
})
}
tags = { Environment = "prod", Application = "core-banking" }
}
# Backup selection targeting the group's resources
module "backup" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-backup?ref=v1.0.0"
name = "casey-core-banking-backup"
iam_role_arn = module.backup_role.arn
plans = { daily = { rules = [{ rule_name = "daily", schedule = "cron(0 5 ? * * *)" }] } }
selections = {
core_banking = {
plan_key = "daily"
resources = ["*"]
conditions = {
string_equals = [{ key = "aws:ResourceTag/Application", value = "core-banking" }]
}
}
}
}
# Network Firewall stateful rule group kept in sync with the same group
module "network_firewall" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-network-firewall?ref=v1.0.0"
#... references module.rg.arn in a stateful rule group referencing the group
}- Core:
name(required, FORCE-NEW, keystone),description(optional). - Group definition (mutually exclusive):
resource_query(object({ query, type }), tag/CloudFormation queries),configuration(map(object({ type, parameters })), AppRegistry/service-configuration items). - Membership:
resource_arns(list(string), explicitaws_resourcegroups_resourceattachments). - Tagging:
tags(map(string), default{}). - Timeouts:
timeouts(object({ create, update, delete }), default{}— only the fields each resource supports are applied).
See variables.tf for full heredoc schemas and validation rules.
id— the resource group id (the group name).arn— the resource group ARN (cross-resource reference type).name— the resource group name.resource_ids— map ofaws_resourcegroups_resourcemembership record ids keyed byresource_arn.resource_types— map of AWS resource type strings keyed byresource_arn.tags_all— all tags including those inherited from providerdefault_tags.
No output is sensitive = true — this module carries no secret-bearing attributes.
- ARN format:
arn:aws:resource-groups:<region>:<account-id>:group/<name>. ID format: the groupidis the group name. Membership recordidis a comma-delimited composite:group_arn,resource_arn— this is the formatterraform importexpects. - Force-new fields:
nameon the group forces replacement — renaming destroys and recreates the group (tag-based membership is re-evaluated against the new group; explicitaws_resourcegroups_resourceattachments are recreated against it). tags↔tags_all↔default_tags:var.tagsis applied to the group only.tags_allis the provider-computed merge of resource tags over providerdefault_tags, with resource tags winning on key conflict.default_tagsis the caller's provider-block concern — never set inside this module.aws_resourcegroups_resourcemembership records are not taggable.- Eventual consistency: a
TAG_FILTERS_1_0group's membership is evaluated live by the Resource Groups service at read time — newly tagged resources can take a short time to appear inListGroupResources/console views; this is not a Terraform-managed state and causes no plan drift. - Destroy ordering: Terraform's dependency graph removes
aws_resourcegroups_resourcerecords (which referencegroup_arn) before the group itself — no manualdepends_onis needed. resource_queryvsconfigurationis enforced at plan time, not just documented:var.configuration's validation block asserts(var.resource_query != null) != (length(var.configuration) > 0), mirroring the AWS API's own mutually-exclusive constraint. The provider's markdown docs page marksresource_queryas "(Required)" — this is stale relative to the live v6.54.0 schema (terraform providers schema -jsonshows it as an optionallistblock,max_items = 1), which is what actually permitsconfiguration-only groups.- us-east-1 globals: N/A. AWS Resource Groups is a regional service — there is no us-east-1 constraint and no
regionvariable.
Secure-by-default posture and every opt-out, explicitly:
| Posture | Default | Opt-out |
|---|---|---|
| Group definition | exactly one of resource_query / configuration enforced at plan time |
n/a — non-negotiable (matches the AWS API constraint, not a module-specific tightening) |
| Explicit membership | opt-in via resource_arns (empty list = query-driven membership only) |
supply resource_arns to add explicit attachments |
Resource Groups has no encryption-at-rest, public-access, or network-exposure surface — the real "secure by default" concern is least-privilege IAM on who can redefine group membership, since a loosely-scoped
resource-groups:*grant is a control-plane privilege-escalation vector for any downstream automation (Backup, Network Firewall, SSM) that targets resources by group. Scope the actions in 🔑 Required IAM Permissions to the group ARN pattern in your account.
Other principles:
- One composite, one keystone. The group owns only what is meaningless without it (explicit membership records). The actual protected/organized resources are referenced by ARN, never created here.
for_each, nevercount, for the membership child collection — keyed on the ARN itself (toset(var.resource_arns)), since each membership record carries no metadata beyondgroup_arn+resource_arn.configurationasmap(object({...}))keyed by a caller-supplied stable string, withparametersflattened tomap(list(string))— avoids a redundantnamefield per our block-mirroring-without-needless-repetition principle.- Primary outputs
id+arn, plusname, the membership maps, andtags_all.
# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check
plan/applyrequire valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain and a configured Region. There is no destructive state here beyond the group and its membership records — no VPC/ENI/NAT-style destroy ordering to worry about.
⚠️ Always pin the module source with?ref=v1.0.0— never a branch — so a caller's plan doesn't silently pick up unreviewed changes.
terraform init -backend=false && terraform validate— schema + reference integrity, including theresource_query/configurationXOR validation.terraform fmt -check— canonical formatting.terraform planagainst a sandbox account to confirm the group and anyresource_arnsmemberships materialize as expected.- Assert
module.<name>.arn,resource_ids,resource_types, andtags_allin your root-module test harness. - Test both branches of the XOR validation deliberately (a
resource_query-only call and aconfiguration-only call) to confirm neither regresses to accepting both or neither.
module.rg.aws_resourcegroups_group.this: Creation complete after 2s [id=casey-prod-app]
module.rg.aws_resourcegroups_resource.this["arn:aws:rds:us-east-1:123456789012:db:core-db"]: Creation complete after 1s
Outputs:
arn = "arn:aws:resource-groups:us-east-1:123456789012:group/casey-prod-app"
id = "casey-prod-app"
resource_ids = { "arn:aws:rds:us-east-1:123456789012:db:core-db" = "arn:aws:resource-groups:us-east-1:123456789012:group/casey-prod-app,arn:aws:rds:us-east-1:123456789012:db:core-db" }
resource_types = { "arn:aws:rds:us-east-1:123456789012:db:core-db" = "AWS::RDS::DBInstance" }
tags_all = { "DataClass" = "confidential", "Environment" = "prod" }
| Symptom | Likely cause | Fix |
|---|---|---|
Exactly one of resource_query or configuration must be supplied (validation) |
Both or neither were set | Set exactly one — resource_query for tag/CloudFormation groups, configuration for AppRegistry/service-configuration groups |
| Group shows zero matching resources | Tag keys/values in resource_query don't match what's actually applied upstream |
Confirm the tagged resources' tags variable matches the query's TagFilters exactly (case-sensitive) |
resource_query.query must be a valid JSON-encoded string (validation) |
Hand-built JSON string with a syntax error | Build the query with jsonencode rather than a raw string literal |
InvalidParameterException on CLOUDFORMATION_STACK_1_0 query |
StackIdentifier is not a full stack ARN, or the stack doesn't exist in this Region |
Use the stack's ARN (not just its name); confirm the stack is in the same Region as the group |
AccessDeniedException on group create/update |
Terraform identity missing a resource-groups:* action |
Add the specific action from 🔑 Required IAM Permissions, least-privilege scoped to the group ARN pattern |
PutGroupConfiguration fails for an AppRegistry configuration type |
The referenced AppRegistry application doesn't exist yet, or the type string is misspelled | Create/verify the AppRegistry application first; AWS may add new configuration types over time — this field is intentionally not a closed enum |
| Tag drift on every plan | A tag also set by provider default_tags with a different value |
Let resource tags win, or remove the overlap from default_tags |
terraform import fails on aws_resourcegroups_resource |
Wrong id format | Use the comma-delimited group_arn,resource_arn format, not a single ARN |
Credential-chain failure (NoCredentialProviders / ExpiredToken) |
No valid AWS credentials resolved by the provider chain | Confirm AWS_PROFILE / SSO session / OIDC role is active; this module has no credential variables of its own |
- AWS Resource Groups User Guide
- Resource Groups tag-based and CloudFormation-based queries
- AWS Service Catalog AppRegistry
- Terraform:
aws_resourcegroups_group·aws_resourcegroups_resource - Sibling modules:
tf-mod-aws-backup,tf-mod-aws-network-firewall,tf-mod-aws-ssm,tf-mod-aws-cloudformation - Module internals:
SCOPE.md
🧡 "Infrastructure as Code should be standardized, consistent, and secure."