Quick take — A reusable hashicorp/aws ~> 5.0 module for aws_shield_protection: subscribe ALBs, CloudFront, Route 53, Global Accelerator, and EIPs to Shield Advanced with protection groups, automatic application-layer response, and SRT proactive engagement. 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 "shield" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-shield?ref=v1.0.0"
protected_resources = {} # Resources to subscribe, keyed by logical name; each has…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Shield Advanced is AWS’s paid, managed DDoS protection service. Every AWS account already gets Shield Standard for free at the network and transport layers, but Shield Advanced adds the things an enterprise actually needs: enhanced L3/L4/L7 attack detection, cost protection (AWS credits back the scaling charges an in-scope attack causes), access to the Shield Response Team (SRT), near-real-time attack diagnostics in CloudWatch, and automatic application-layer mitigation that writes WAF rules for you during an attack. The catch is that the subscription is a flat US$3,000 per month per organization plus data-transfer fees — so you want exactly the right resources protected, and you want that decision in code, reviewed and auditable, not clicked into a console.
The core resource, aws_shield_protection, subscribes a single resource ARN to Shield Advanced — an Application Load Balancer, a CloudFront distribution, a Route 53 hosted zone, a Global Accelerator accelerator, or an Elastic IP. On its own that is one line, but a production posture needs three more pieces that are easy to forget and awkward to wire by hand: an aws_shield_protection_group so Shield treats a fleet of resources as one DDoS detection unit (better baselining, fewer false negatives), an aws_shield_application_layer_automatic_response so an L7 flood against a CloudFront/ALB resource is mitigated by an associated WAF web ACL automatically, and aws_shield_proactive_engagement_enabled so the SRT can call your on-call during an incident. This module turns all of that into typed, validated inputs — pass a map of resources to protect, optionally a protection group and an automatic-response rule, and the module assembles the subscription consistently, with guardrails (you cannot enable automatic response without a WAF ACL, you cannot enable proactive engagement without a contact) that the console will happily let you get wrong.
When to use it
- You run internet-facing, revenue-critical endpoints — a payments ALB, a public CloudFront storefront, a Global Accelerator front door — where a volumetric or L7 DDoS attack is a board-level risk and the US$3,000/month is cheaper than the outage.
- You want cost protection: during an in-scope attack, Shield Advanced credits back the CloudFront/Route 53/ELB/EC2 scaling charges the attack caused, so a 500 Gbps flood does not become a surprise bill.
- You need automatic application-layer mitigation — when a layer-7 flood hits a CloudFront distribution or ALB, Shield writes and applies WAF rules in real time (
aws_shield_application_layer_automatic_response) instead of paging a human to author rate rules at 3 a.m. - You protect a fleet of similar resources (every regional ALB, every accelerator) and want Shield to baseline and detect across the group, not resource-by-resource, via a
aws_shield_protection_group. - You want the Shield Response Team engaged proactively — SRT can contact your incident channel and, with a DRT role, act on your WAF during a large attack — wired consistently rather than configured once and forgotten.
If your workload is internal-only (no public ingress) or you are comfortable with Shield Standard’s automatic L3/L4 mitigation and do not need cost protection or SRT, you do not need this — Shield Advanced is for public, high-value, attack-attractive endpoints.
Module structure
terraform-module-aws-shield/
├── versions.tf # provider + Terraform version pins
├── main.tf # protections, protection group, auto-response, proactive engagement
├── variables.tf # resources map, group config, auto-response, contacts (validated)
└── outputs.tf # protection ids/arns, group arn, automatic-response state
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
tags = merge(
var.tags,
{
ManagedBy = "terraform"
},
)
# Automatic application-layer response is only valid for CloudFront and ALB
# resources, and only when each of those resources has a WAF web ACL bound.
# We compute the set here so the WAF requirement is enforced in one place.
auto_response_keys = toset([
for k, v in var.protected_resources : k
if v.enable_automatic_response
])
}
# ---------------------------------------------------------------------------
# Per-resource Shield Advanced subscription. Each entry protects exactly one
# ARN: an ELB/ALB, a CloudFront distribution, a Route 53 hosted zone, a Global
# Accelerator accelerator, or an Elastic IP allocation. Health-check ARNs
# (Route 53 health checks) tighten Shield's "is this really an attack?" signal.
# ---------------------------------------------------------------------------
resource "aws_shield_protection" "this" {
for_each = var.protected_resources
name = each.value.name
resource_arn = each.value.resource_arn
tags = merge(local.tags, each.value.tags, { Name = each.value.name })
}
# Associate Route 53 health checks with a protection so Shield can correlate
# resource health with traffic anomalies (a key input to attack detection).
resource "aws_shield_protection_health_check_association" "this" {
for_each = {
for pair in flatten([
for k, v in var.protected_resources : [
for hc in v.health_check_arns : {
key = "${k}::${hc}"
protection_key = k
health_check_id = hc
}
]
]) : pair.key => pair
}
shield_protection_id = aws_shield_protection.this[each.value.protection_key].id
health_check_arn = each.value.health_check_id
}
# ---------------------------------------------------------------------------
# Automatic application-layer (L7) response — during a detected L7 attack on a
# CloudFront distribution or ALB, Shield mutates the associated WAF web ACL to
# mitigate. "COUNT" observes only; "BLOCK" actually drops attack traffic.
# Requires a WAF web ACL already associated with the protected resource.
# ---------------------------------------------------------------------------
resource "aws_shield_application_layer_automatic_response" "this" {
for_each = local.auto_response_keys
resource_arn = var.protected_resources[each.value].resource_arn
action = var.protected_resources[each.value].automatic_response_action
depends_on = [aws_shield_protection.this]
}
# ---------------------------------------------------------------------------
# Protection group — let Shield baseline and detect across a SET of protected
# resources instead of one at a time. Patterns: ALL (everything), ARBITRARY
# (an explicit list), or BY_RESOURCE_TYPE (e.g. all APPLICATION_LOAD_BALANCERs).
# Aggregation MEAN/SUM/MAX controls how Shield combines per-resource signals.
# ---------------------------------------------------------------------------
resource "aws_shield_protection_group" "this" {
for_each = var.protection_groups
protection_group_id = each.value.protection_group_id
aggregation = each.value.aggregation
pattern = each.value.pattern
# Only set for pattern = BY_RESOURCE_TYPE.
resource_type = each.value.pattern == "BY_RESOURCE_TYPE" ? each.value.resource_type : null
# Only set for pattern = ARBITRARY. Resolve resource keys to ARNs so callers
# can reference protections by their map key rather than repeating ARNs.
members = each.value.pattern == "ARBITRARY" ? [
for m in each.value.member_keys :
aws_shield_protection.this[m].resource_arn
] : null
tags = local.tags
}
# ---------------------------------------------------------------------------
# Proactive engagement — let the Shield Response Team (SRT) contact your team
# during an attack. Requires at least one emergency contact. The SRT can also
# be granted access to your WAF via a DRT role (managed outside this module).
# ---------------------------------------------------------------------------
resource "aws_shield_proactive_engagement" "this" {
count = var.proactive_engagement_enabled ? 1 : 0
enabled = true
dynamic "emergency_contact" {
for_each = var.emergency_contacts
content {
contact_notes = emergency_contact.value.contact_notes
email_address = emergency_contact.value.email_address
phone_number = emergency_contact.value.phone_number
}
}
}
variables.tf
variable "protected_resources" {
description = <<-EOT
Map of resources to subscribe to Shield Advanced, keyed by a stable logical
name. resource_arn must be an ELB/ALB, CloudFront distribution, Route 53
hosted zone, Global Accelerator accelerator, or Elastic IP. Set
enable_automatic_response = true ONLY for CloudFront/ALB resources that
already have a WAF web ACL associated; automatic_response_action is COUNT
(observe) or BLOCK (mitigate). health_check_arns associates Route 53 health
checks to sharpen attack detection.
Example:
{
payments_alb = {
name = "payments-alb"
resource_arn = aws_lb.payments.arn
}
storefront_cf = {
name = "storefront-cdn"
resource_arn = aws_cloudfront_distribution.store.arn
enable_automatic_response = true
automatic_response_action = "BLOCK"
}
}
EOT
type = map(object({
name = string
resource_arn = string
enable_automatic_response = optional(bool, false)
automatic_response_action = optional(string, "COUNT")
health_check_arns = optional(list(string), [])
tags = optional(map(string), {})
}))
validation {
condition = length(var.protected_resources) > 0
error_message = "Provide at least one resource to protect; an empty subscription wastes the US$3,000/month flat fee."
}
validation {
condition = alltrue([
for k, v in var.protected_resources :
contains(["COUNT", "BLOCK"], v.automatic_response_action)
])
error_message = "automatic_response_action must be COUNT or BLOCK for every protected resource."
}
validation {
# Automatic response only applies to CloudFront and ALB ARNs.
condition = alltrue([
for k, v in var.protected_resources :
!v.enable_automatic_response ||
can(regex("^arn:aws:(cloudfront::|elasticloadbalancing:.*:loadbalancer/app/)", v.resource_arn))
])
error_message = "enable_automatic_response is only valid for CloudFront distributions or Application Load Balancers."
}
}
variable "protection_groups" {
description = <<-EOT
Optional Shield protection groups for cross-resource DDoS detection, keyed
by a logical name. pattern is ALL (every protected resource in the account),
BY_RESOURCE_TYPE (set resource_type), or ARBITRARY (set member_keys, which
reference keys in protected_resources). aggregation MEAN/SUM/MAX controls how
Shield combines per-resource signals into a group volume.
EOT
type = map(object({
protection_group_id = string
aggregation = optional(string, "MAX")
pattern = optional(string, "ALL")
resource_type = optional(string)
member_keys = optional(list(string), [])
}))
default = {}
validation {
condition = alltrue([
for k, v in var.protection_groups :
contains(["SUM", "MEAN", "MAX"], v.aggregation)
])
error_message = "protection_groups aggregation must be SUM, MEAN, or MAX."
}
validation {
condition = alltrue([
for k, v in var.protection_groups :
contains(["ALL", "ARBITRARY", "BY_RESOURCE_TYPE"], v.pattern)
])
error_message = "protection_groups pattern must be ALL, ARBITRARY, or BY_RESOURCE_TYPE."
}
validation {
condition = alltrue([
for k, v in var.protection_groups :
v.pattern != "BY_RESOURCE_TYPE" || contains([
"CLOUDFRONT_DISTRIBUTION", "ROUTE_53_HOSTED_ZONE", "ELASTIC_IP_ALLOCATION",
"CLASSIC_LOAD_BALANCER", "APPLICATION_LOAD_BALANCER", "GLOBAL_ACCELERATOR"
], coalesce(v.resource_type, ""))
])
error_message = "When pattern = BY_RESOURCE_TYPE, resource_type must be a valid Shield resource type (e.g. APPLICATION_LOAD_BALANCER)."
}
validation {
condition = alltrue([
for k, v in var.protection_groups :
v.pattern != "ARBITRARY" || length(v.member_keys) > 0
])
error_message = "When pattern = ARBITRARY, member_keys must list at least one key from protected_resources."
}
}
variable "proactive_engagement_enabled" {
description = "Enable Shield Response Team (SRT) proactive engagement so AWS can contact your team during an attack. Requires emergency_contacts."
type = bool
default = false
}
variable "emergency_contacts" {
description = <<-EOT
Contacts the SRT can reach during an incident. Phone numbers must be in E.164
format (e.g. +14155550100). At least one is required when
proactive_engagement_enabled = true.
EOT
type = list(object({
email_address = string
phone_number = optional(string)
contact_notes = optional(string)
}))
default = []
validation {
condition = alltrue([
for c in var.emergency_contacts :
can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", c.email_address))
])
error_message = "Every emergency contact email_address must be a valid email."
}
validation {
condition = alltrue([
for c in var.emergency_contacts :
c.phone_number == null || can(regex("^\\+[1-9][0-9]{1,14}$", c.phone_number))
])
error_message = "emergency_contacts phone_number must be E.164 (e.g. +14155550100) or omitted."
}
}
variable "tags" {
description = "Tags applied to every protection and protection group."
type = map(string)
default = {}
}
outputs.tf
output "protection_ids" {
description = "Map of logical resource key to Shield protection ID."
value = { for k, p in aws_shield_protection.this : k => p.id }
}
output "protection_arns" {
description = "Map of logical resource key to Shield protection ARN."
value = { for k, p in aws_shield_protection.this : k => p.protection_arn }
}
output "protected_resource_arns" {
description = "Map of logical resource key to the underlying protected resource ARN."
value = { for k, v in var.protected_resources : k => v.resource_arn }
}
output "protection_group_arns" {
description = "Map of protection-group key to its ARN, for cross-stack references."
value = { for k, g in aws_shield_protection_group.this : k => g.protection_group_arn }
}
output "automatic_response_resources" {
description = "List of resource keys with Shield automatic application-layer response enabled."
value = tolist(local.auto_response_keys)
}
output "proactive_engagement_enabled" {
description = "Whether SRT proactive engagement was enabled for the account."
value = var.proactive_engagement_enabled
}
How to use it
This example subscribes a payments ALB and a public storefront CloudFront distribution to Shield Advanced, turns on automatic L7 mitigation (BLOCK) for the CDN, groups every ALB in the account for fleet-wide detection, and wires SRT proactive engagement to the security on-call. The CloudFront resource already has a WAF web ACL associated, which is what automatic response acts on.
module "shield_advanced" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-shield?ref=v1.0.0"
protected_resources = {
payments_alb = {
name = "payments-alb"
resource_arn = aws_lb.payments.arn
health_check_arns = [aws_route53_health_check.payments.arn]
}
storefront_cf = {
name = "storefront-cdn"
resource_arn = aws_cloudfront_distribution.store.arn
enable_automatic_response = true # requires a WAF ACL on the distribution
automatic_response_action = "BLOCK" # COUNT first in non-prod, then BLOCK
}
}
# Detect across the whole ALB fleet, not one load balancer at a time.
protection_groups = {
all_albs = {
protection_group_id = "all-app-load-balancers"
pattern = "BY_RESOURCE_TYPE"
resource_type = "APPLICATION_LOAD_BALANCER"
aggregation = "SUM"
}
}
proactive_engagement_enabled = true
emergency_contacts = [
{
email_address = "secops-oncall@example.com"
phone_number = "+14155550100"
contact_notes = "Primary 24x7 security on-call rotation"
},
]
tags = {
Environment = "prod"
Team = "security"
CostCenter = "ddos-protection"
}
}
# Downstream: alarm on Shield's DDoSDetected metric for the storefront using the
# protection's underlying resource. A non-zero value means an attack is in flight.
resource "aws_cloudwatch_metric_alarm" "storefront_ddos" {
alarm_name = "storefront-ddos-detected"
namespace = "AWS/DDoSProtection"
metric_name = "DDoSDetected"
statistic = "Maximum"
period = 60
evaluation_periods = 1
threshold = 1
comparison_operator = "GreaterThanOrEqualToThreshold"
treat_missing_data = "notBreaching"
dimensions = {
ResourceArn = module.shield_advanced.protected_resource_arns["storefront_cf"]
}
alarm_actions = [aws_sns_topic.security_alerts.arn]
}
output "shield_protection_ids" {
value = module.shield_advanced.protection_ids
}
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/shield/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-shield?ref=v1.0.0"
}
inputs = {
protected_resources = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/shield && 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 |
|---|---|---|---|---|
protected_resources |
map(object) |
— | Yes | Resources to subscribe, keyed by logical name; each has resource_arn, optional automatic-response and Route 53 health-check ARNs. |
protection_groups |
map(object) |
{} |
No | Cross-resource detection groups; pattern is ALL/BY_RESOURCE_TYPE/ARBITRARY, with aggregation SUM/MEAN/MAX. |
proactive_engagement_enabled |
bool |
false |
No | Enable SRT proactive engagement; requires emergency_contacts. |
emergency_contacts |
list(object) |
[] |
No | SRT contacts (email required, E.164 phone_number optional). |
tags |
map(string) |
{} |
No | Tags applied to every protection and protection group. |
Outputs
| Name | Description |
|---|---|
protection_ids |
Map of resource key → Shield protection ID. |
protection_arns |
Map of resource key → Shield protection ARN. |
protected_resource_arns |
Map of resource key → underlying protected resource ARN (use as the ResourceArn CloudWatch dimension). |
protection_group_arns |
Map of protection-group key → its ARN. |
automatic_response_resources |
Resource keys that have automatic L7 response enabled. |
proactive_engagement_enabled |
Whether SRT proactive engagement was enabled. |
Enterprise scenario
A fintech runs its public payments ALB and a global CloudFront storefront in ap-south-1 and us-east-1, and a single missed minute of availability is a regulatory and reputational event. The platform team subscribes both endpoints to Shield Advanced through this module at v1.0.0, enables BLOCK automatic application-layer response on the CloudFront distribution (which already carries a CLOUDFRONT-scope WAF ACL), and creates a BY_RESOURCE_TYPE protection group over every ALB so Shield baselines the whole fleet. Proactive engagement is wired to the 24x7 security on-call with E.164 numbers, and a DDoSDetected CloudWatch alarm pages that rotation the moment Shield flags an attack — giving the business cost protection on attack-driven scaling, automatic L7 mitigation without a human in the loop, and an SRT escalation path, all defined in one reviewed pull request.
Best practices
- Subscribe only high-value, internet-facing resources. Shield Advanced is a flat US$3,000/month per organization regardless of how many resources you protect, but every resource still adds data-transfer cost — protect the ALBs, CloudFront distributions, accelerators, and Route 53 zones that are genuinely attack-attractive, and let internal workloads ride Shield Standard.
- Roll automatic response out as
COUNTfirst, thenBLOCK. Automatic application-layer response mutates your WAF web ACL during an attack; ship it asCOUNTin staging (and on first deploy to prod), confirm in the WAF sampled requests that it would only catch attack traffic, then flip toBLOCK— this module makes that a one-field change per resource. - Always associate a WAF web ACL before enabling automatic response.
aws_shield_application_layer_automatic_responseneeds a WAF ACL on the CloudFront/ALB resource to act on; the module’s validation blocks the combination for non-CloudFront/ALB ARNs, but you must still attach the ACL (pair this with your WAFv2 module) or the apply fails. - Use protection groups for fleet-wide detection. A
BY_RESOURCE_TYPEorARBITRARYgroup lets Shield baseline normal traffic across many resources and detect a distributed, low-and-slow attack that no single resource would trip — far stronger than protecting endpoints in isolation. - Enable proactive engagement and keep contacts current. SRT can only help fast if
emergency_contactsare real, E.164, and monitored 24x7; stale contacts are the difference between a 5-minute and a 50-minute mitigation. Review them as part of your on-call rotation handover. - Alarm on
AWS/DDoSProtectionand tag for cost ownership. WireDDoSDetectedandDDoSAttackBitsPerSecondalarms off each protection’sResourceArn, and tag every protection withEnvironment/Team/CostCenterso finance can attribute the flat fee and security can prove coverage during an audit.