Quick take — A reusable hashicorp/aws ~> 5.0 module for aws_resourcegroups_group: build tag-query and CloudFormation-stack Resource Groups, control insights via group configuration, and emit stable ARNs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "resource_groups" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-resource-groups?ref=v1.0.0"
name = "..." # Group name, unique per account/region. 1-128 chars `[a-…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Resource Groups let you collect and operate on a logical set of resources — every EC2 instance, RDS database, Lambda function, and S3 bucket that shares a common tag or belongs to the same CloudFormation stack — as a single addressable unit. A group is defined by a resource query: either a TAG_FILTERS_1_0 query (match resources by tag key/value) or a CLOUDFORMATION_STACK_1_0 query (membership follows a stack). Once a group exists, services like Systems Manager, CloudWatch, Resource Explorer, and the AWS Console “Resource Groups & Tag Editor” view treat it as a first-class target for automation, dashboards, and patch baselines.
The raw aws_resourcegroups_group resource is workable but awkward: the resource_query.query field is a JSON string, not HCL, so every team hand-rolls jsonencode blobs and copies the same tag-filter scaffolding. This module wraps that in a clean, var-driven interface — you pass a map of tag filters or a stack ARN, pick the group type, and optionally attach a configuration block (used for AWS::EC2::HostManagement and similar group-level settings). It validates the query type, prevents the “tags and stack at the same time” mistake, and returns the group’s ARN and name so downstream Systems Manager, CloudWatch, or IAM resources can reference it without copy-paste.
When to use it
- Standardised operational grouping — you want every team to expose
environment = prodorapp = checkoutresources as a Resource Group with identical naming and tag conventions, fleet-wide. - Systems Manager automation targets — you drive Patch Manager, State Manager, or Run Command against a Resource Group instead of brittle instance-ID lists, so the fleet self-updates as tagged instances come and go.
- CloudWatch & cost views — you back a CloudWatch automatic dashboard, Application Insights, or a cost allocation lens with a stable group definition kept in version control.
- CloudFormation-stack grouping — you run a mixed IaC estate and want a group whose membership tracks an existing CloudFormation stack exactly, without re-declaring its tags.
- Skip it for one-off, throwaway exploration — the console’s ad-hoc Resource Groups are fine there. Reach for the module when the grouping is part of your operational contract and must be reproducible.
Module structure
terraform-module-aws-resource-groups/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Build the resource_query JSON from typed inputs. Resource Groups expects a
# JSON-encoded string, so we assemble the right shape per query type and
# jsonencode() it once here rather than asking callers to hand-write JSON.
# TAG_FILTERS_1_0 expects: { ResourceTypeFilters: [...], TagFilters: [{Key, Values}] }
tag_query = {
ResourceTypeFilters = var.resource_type_filters
TagFilters = [
for key, values in var.tag_filters : {
Key = key
Values = values
}
]
}
# CLOUDFORMATION_STACK_1_0 expects: { ResourceTypeFilters: [...], StackIdentifier: "<arn>" }
stack_query = {
ResourceTypeFilters = var.resource_type_filters
StackIdentifier = var.cloudformation_stack_arn
}
query_payload = var.query_type == "TAG_FILTERS_1_0" ? local.tag_query : local.stack_query
}
resource "aws_resourcegroups_group" "this" {
name = var.name
description = var.description
resource_query {
type = var.query_type
query = jsonencode(local.query_payload)
}
# Optional group-level configuration (e.g. AWS::EC2::HostManagement for
# license-included / auto-allocate host settings). Omitted entirely when
# no configuration items are supplied.
dynamic "configuration" {
for_each = var.configuration
content {
type = configuration.value.type
dynamic "parameters" {
for_each = configuration.value.parameters
content {
name = parameters.value.name
values = parameters.value.values
}
}
}
}
tags = var.tags
}
# variables.tf
variable "name" {
description = "Name of the Resource Group. Must be unique within the account and region."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._-]{1,128}$", var.name))
error_message = "name must be 1-128 chars: letters, numbers, '.', '_' or '-' (no spaces, and it cannot start with 'AWS' or 'aws')."
}
validation {
condition = !can(regex("^(?i)aws", var.name))
error_message = "name must not start with 'AWS' or 'aws' (reserved prefix)."
}
}
variable "description" {
description = "Human-readable description of the group's purpose."
type = string
default = null
}
variable "query_type" {
description = "Resource query type: TAG_FILTERS_1_0 (tag-based membership) or CLOUDFORMATION_STACK_1_0 (membership tracks a CloudFormation stack)."
type = string
default = "TAG_FILTERS_1_0"
validation {
condition = contains(["TAG_FILTERS_1_0", "CLOUDFORMATION_STACK_1_0"], var.query_type)
error_message = "query_type must be either TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0."
}
}
variable "tag_filters" {
description = "Map of tag key => list of allowed values for TAG_FILTERS_1_0 queries. Example: { Environment = [\"prod\"], App = [\"checkout\"] }. A resource matches when it satisfies every tag filter."
type = map(list(string))
default = {}
}
variable "cloudformation_stack_arn" {
description = "ARN of the CloudFormation stack to group, used only when query_type is CLOUDFORMATION_STACK_1_0."
type = string
default = null
validation {
condition = var.cloudformation_stack_arn == null || can(regex("^arn:aws[a-zA-Z-]*:cloudformation:", var.cloudformation_stack_arn))
error_message = "cloudformation_stack_arn must be a valid CloudFormation stack ARN (arn:aws:cloudformation:...)."
}
}
variable "resource_type_filters" {
description = "List of resource type filters to scope membership (e.g. [\"AWS::EC2::Instance\", \"AWS::RDS::DBInstance\"]). Use [\"AWS::AllSupported\"] to include every supported type."
type = list(string)
default = ["AWS::AllSupported"]
validation {
condition = length(var.resource_type_filters) > 0
error_message = "resource_type_filters must contain at least one entry (use [\"AWS::AllSupported\"] for everything)."
}
}
variable "configuration" {
description = "Optional list of service-specific group configuration items (e.g. AWS::EC2::HostManagement). Leave empty for standard tag/stack groups."
type = list(object({
type = string
parameters = optional(list(object({
name = string
values = list(string)
})), [])
}))
default = []
}
variable "tags" {
description = "Tags applied to the Resource Group itself (not the same as the membership query)."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The name of the Resource Group (its unique ID within the region)."
value = aws_resourcegroups_group.this.id
}
output "name" {
description = "The name of the Resource Group."
value = aws_resourcegroups_group.this.name
}
output "arn" {
description = "The ARN of the Resource Group, e.g. for IAM policies or Systems Manager targets."
value = aws_resourcegroups_group.this.arn
}
output "query_type" {
description = "The resource query type in effect (TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0)."
value = var.query_type
}
How to use it
# A tag-based group: every prod resource for the "checkout" app, scoped to
# compute and data layers, used as a Systems Manager patch target.
module "resource_groups" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-resource-groups?ref=v1.0.0"
name = "prod-checkout-fleet"
description = "Prod compute & data plane for the checkout service"
query_type = "TAG_FILTERS_1_0"
tag_filters = {
Environment = ["prod"]
App = ["checkout"]
}
resource_type_filters = [
"AWS::EC2::Instance",
"AWS::RDS::DBInstance",
]
tags = {
Team = "payments"
ManagedBy = "terraform"
}
}
# Downstream: target the group directly from a Systems Manager State Manager
# association, so patching follows the tagged fleet without an instance list.
resource "aws_ssm_association" "patch_checkout" {
name = "AWS-RunPatchBaseline"
association_name = "patch-${module.resource_groups.name}"
targets {
key = "resource-groups:Name"
values = [module.resource_groups.name]
}
parameters = {
Operation = "Install"
}
}
# And reference the ARN in a scoped IAM policy for an automation role.
data "aws_iam_policy_document" "rg_read" {
statement {
sid = "DescribeCheckoutGroup"
effect = "Allow"
actions = ["resource-groups:GetGroup", "resource-groups:ListGroupResources"]
resources = [module.resource_groups.arn]
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/resource_groups/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-resource-groups?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/resource_groups && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Group name, unique per account/region. 1-128 chars [a-zA-Z0-9._-]; cannot start with AWS/aws. |
description |
string |
null |
No | Human-readable description of the group’s purpose. |
query_type |
string |
"TAG_FILTERS_1_0" |
No | TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0. |
tag_filters |
map(list(string)) |
{} |
No | Tag key => allowed values; used for TAG_FILTERS_1_0. A resource matches when it satisfies every filter. |
cloudformation_stack_arn |
string |
null |
No | Stack ARN to group; used for CLOUDFORMATION_STACK_1_0. |
resource_type_filters |
list(string) |
["AWS::AllSupported"] |
No | Scope membership to types (e.g. AWS::EC2::Instance); must be non-empty. |
configuration |
list(object) |
[] |
No | Service-specific group configuration items (e.g. AWS::EC2::HostManagement) with optional parameters. |
tags |
map(string) |
{} |
No | Tags on the Resource Group resource itself (distinct from the membership query). |
Outputs
| Name | Description |
|---|---|
id |
The name of the Resource Group (its unique ID within the region). |
name |
The name of the Resource Group. |
arn |
The ARN of the Resource Group, for IAM policies or Systems Manager targets. |
query_type |
The resource query type in effect (TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0). |
Enterprise scenario
A retail platform team runs roughly 400 EC2 instances and 30 RDS databases across dev, staging, and prod, all tagged with Environment and App. Rather than maintaining patch and dashboard targets by instance ID, they instantiate this module once per (environment, app) pair in a for_each loop, producing tag-driven Resource Groups whose membership self-updates as the auto scaling groups churn nodes. Systems Manager Patch Manager and CloudWatch automatic dashboards point at the group names emitted by the module, so a newly launched, correctly tagged instance is patched and observed within minutes — no Terraform change required to onboard it.
Best practices
- Pick the right query type, and don’t mix signals. Tag-filter groups are the workhorse for fleets that scale; reserve
CLOUDFORMATION_STACK_1_0for grouping that must mirror an existing stack exactly. This module’squery_typevalidation and separatetag_filters/cloudformation_stack_arninputs keep the two from colliding. - Scope membership with
resource_type_filters. Defaulting toAWS::AllSupportedis convenient but pulls in unrelated resources and slowsListGroupResources. Narrow to the types you actually operate on (e.g. justAWS::EC2::Instancefor a patch target) for faster, cheaper, more predictable groups. - Treat the membership query as your tagging contract. A Resource Group is only as good as the tags it filters on, so enforce those keys with AWS Organizations tag policies or SCPs. Untagged or mistagged resources silently fall out of the group — and therefore out of patching and dashboards.
- Name for operability and uniqueness. Group names are unique per region and surface in SSM, CloudWatch, and the console; a scheme like
<env>-<app>-<purpose>(e.g.prod-checkout-fleet) reads well in automation logs. Remember names cannot start with the reservedAWS/awsprefix — the module rejects it early. - Scope IAM to the group ARN, not
*. Use the module’sarnoutput in policies so automation roles canGetGroup/ListGroupResourceson exactly the groups they own, rather than every group in the account. - Resource Groups are free — the resources inside are not. The group itself carries no charge, so lean on it as a free cost-allocation and inventory lens, but pair it with cost-allocation tags so the same dimensions drive both grouping and your Cost Explorer breakdowns.