IaC AWS

Terraform Module: AWS Shield Advanced — managed DDoS protection, resource grouping, and automatic L7 mitigation in one block

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

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 configlive/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 configlive/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

TerraformAWSShield AdvancedModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading