IaC AWS

Terraform Module: AWS SNS Topic — encrypted, policy-driven pub/sub fan-out

Quick take — A reusable Terraform module for AWS SNS topics: KMS encryption, FIFO support, delivery retry policies, dead-letter redrive, and least-privilege access policies wired up for hashicorp/aws ~> 5.0. 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 "sns" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-sns?ref=v1.0.0"

  name = "..."  # Base topic name (no `.fifo` suffix); letters, digits, h…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon SNS (Simple Notification Service) is AWS’s managed pub/sub messaging service. A single SNS topic acts as a logical access point: publishers push a message once, and SNS fans it out to every subscribed endpoint — SQS queues, Lambda functions, HTTP/S endpoints, email, SMS, or even other AWS accounts. It is the connective tissue behind event-driven architectures, S3 event notifications, CloudWatch alarms, and cross-service decoupling.

Creating a topic with aws_sns_topic is a one-liner, but a production topic is never one resource. You almost always need server-side encryption with a customer-managed KMS key, a resource policy that restricts who may publish and subscribe, sensible HTTP delivery retry behaviour, and — for FIFO topics — content-based deduplication. Hand-rolling these across dozens of topics leads to drift: one topic gets KMS, the next ships plaintext; one has a locked-down policy, another is wide open. This module wraps aws_sns_topic (plus its policy and an optional CloudWatch logging role) into a single, var-driven, opinionated unit so every topic in your estate is encrypted, governed, and named consistently by default.

When to use it

Reach for a raw aws_sns_topic resource only for a throwaway topic in a sandbox. For anything that carries real events, the module pays for itself the first time an auditor asks “is this encrypted and who can publish to it?”.

Module structure

terraform-module-aws-sns/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # aws_sns_topic + topic policy + logging role
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # topic ARN/name/id + key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # FIFO topics must end in ".fifo"; normalise the name so callers
  # don't have to remember the suffix.
  is_fifo    = var.fifo_topic
  topic_name = local.is_fifo ? "${var.name}.fifo" : var.name

  # Only build the feedback role when at least one delivery-status
  # feedback channel is requested.
  enable_feedback_role = (
    var.http_success_feedback_sample_rate != null ||
    var.lambda_success_feedback_sample_rate != null ||
    var.sqs_success_feedback_sample_rate != null
  )
}

resource "aws_sns_topic" "this" {
  name         = local.is_fifo ? null : local.topic_name
  name_prefix  = null
  display_name = var.display_name

  # Encryption at rest. Pass a KMS key id/alias/ARN; defaults to the
  # AWS-managed alias/aws/sns when null is supplied.
  kms_master_key_id = var.kms_master_key_id

  # FIFO ordering + dedup.
  fifo_topic                  = local.is_fifo
  content_based_deduplication = local.is_fifo ? var.content_based_deduplication : null

  # HTTP/S delivery retry policy (JSON). Controls SNS retry backoff
  # to HTTP subscribers; ignored for SQS/Lambda subscriptions.
  delivery_policy = var.delivery_policy

  # Optional delivery-status logging to CloudWatch Logs.
  http_success_feedback_role_arn      = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
  http_success_feedback_sample_rate   = var.http_success_feedback_sample_rate
  http_failure_feedback_role_arn      = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
  lambda_success_feedback_role_arn    = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
  lambda_success_feedback_sample_rate = var.lambda_success_feedback_sample_rate
  lambda_failure_feedback_role_arn    = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
  sqs_success_feedback_role_arn       = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
  sqs_success_feedback_sample_rate    = var.sqs_success_feedback_sample_rate
  sqs_failure_feedback_role_arn       = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null

  tags = merge(
    var.tags,
    {
      Name = local.topic_name
    },
  )
}

# Resource policy: who may publish/subscribe. Pass your own JSON via
# var.policy, or let the module render a least-privilege default that
# allows the listed account ARNs to publish and the local account to
# manage the topic.
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}

data "aws_iam_policy_document" "default" {
  count = var.policy == null ? 1 : 0

  statement {
    sid    = "AllowOwnerFullControl"
    effect = "Allow"

    principals {
      type        = "AWS"
      identifiers = ["arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root"]
    }

    actions   = ["SNS:*"]
    resources = [aws_sns_topic.this.arn]
  }

  dynamic "statement" {
    for_each = length(var.allowed_publisher_arns) > 0 ? [1] : []
    content {
      sid    = "AllowScopedPublish"
      effect = "Allow"

      principals {
        type        = "AWS"
        identifiers = var.allowed_publisher_arns
      }

      actions   = ["SNS:Publish"]
      resources = [aws_sns_topic.this.arn]
    }
  }

  # Allow named AWS services (e.g. cloudwatch, s3, events) to publish.
  dynamic "statement" {
    for_each = length(var.allowed_publisher_services) > 0 ? [1] : []
    content {
      sid    = "AllowServicePublish"
      effect = "Allow"

      principals {
        type        = "Service"
        identifiers = [for s in var.allowed_publisher_services : "${s}.amazonaws.com"]
      }

      actions   = ["SNS:Publish"]
      resources = [aws_sns_topic.this.arn]

      dynamic "condition" {
        for_each = var.source_account_id != null ? [1] : []
        content {
          test     = "StringEquals"
          variable = "AWS:SourceAccount"
          values   = [var.source_account_id]
        }
      }
    }
  }
}

resource "aws_sns_topic_policy" "this" {
  arn    = aws_sns_topic.this.arn
  policy = var.policy != null ? var.policy : data.aws_iam_policy_document.default[0].json
}

# IAM role that SNS assumes to write delivery-status logs to CloudWatch.
data "aws_iam_policy_document" "feedback_assume" {
  count = local.enable_feedback_role ? 1 : 0

  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "feedback" {
  count              = local.enable_feedback_role ? 1 : 0
  name               = "${var.name}-sns-feedback"
  assume_role_policy = data.aws_iam_policy_document.feedback_assume[0].json
  tags               = var.tags
}

resource "aws_iam_role_policy" "feedback" {
  count = local.enable_feedback_role ? 1 : 0
  name  = "${var.name}-sns-feedback"
  role  = aws_iam_role.feedback[0].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "logs:PutMetricFilter",
          "logs:PutRetentionPolicy",
        ]
        Resource = "*"
      },
    ]
  })
}

variables.tf

variable "name" {
  description = "Base name of the SNS topic (without the .fifo suffix). Lowercase, alphanumeric, hyphens and underscores."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_-]{1,250}$", var.name))
    error_message = "name must be 1-250 chars: letters, digits, hyphens, underscores only (no .fifo suffix)."
  }
}

variable "display_name" {
  description = "Human-friendly display name. Required for SMS subscriptions; shown as the sender."
  type        = string
  default     = null
}

variable "fifo_topic" {
  description = "Create a FIFO topic for strictly ordered, exactly-once delivery. The .fifo suffix is added automatically."
  type        = bool
  default     = false
}

variable "content_based_deduplication" {
  description = "Enable content-based deduplication (FIFO only). When true, SNS computes the dedup ID from the message body."
  type        = bool
  default     = false
}

variable "kms_master_key_id" {
  description = "KMS key id, alias, or ARN for server-side encryption. Use a CMK ARN in production; null disables SSE (plaintext at rest)."
  type        = string
  default     = "alias/aws/sns"
}

variable "delivery_policy" {
  description = "JSON HTTP/S delivery retry policy (healthyRetryPolicy/throttlePolicy). null uses SNS defaults. Ignored for SQS/Lambda."
  type        = string
  default     = null

  validation {
    condition     = var.delivery_policy == null || can(jsondecode(var.delivery_policy))
    error_message = "delivery_policy must be null or valid JSON."
  }
}

variable "policy" {
  description = "Full SNS topic resource policy as JSON. When set, it overrides the module's generated least-privilege policy."
  type        = string
  default     = null

  validation {
    condition     = var.policy == null || can(jsondecode(var.policy))
    error_message = "policy must be null or valid JSON."
  }
}

variable "allowed_publisher_arns" {
  description = "IAM principal ARNs (roles/users/accounts) granted SNS:Publish in the generated policy. Ignored when var.policy is set."
  type        = list(string)
  default     = []
}

variable "allowed_publisher_services" {
  description = "AWS service short names allowed to publish, e.g. [\"cloudwatch\", \"s3\", \"events\"]. The .amazonaws.com suffix is added."
  type        = list(string)
  default     = []

  validation {
    condition     = alltrue([for s in var.allowed_publisher_services : can(regex("^[a-z0-9.-]+$", s))])
    error_message = "Each service must be a short name like 'cloudwatch' or 's3', without the .amazonaws.com suffix."
  }
}

variable "source_account_id" {
  description = "If set, service publishers are constrained with AWS:SourceAccount to prevent the confused-deputy problem."
  type        = string
  default     = null

  validation {
    condition     = var.source_account_id == null || can(regex("^[0-9]{12}$", var.source_account_id))
    error_message = "source_account_id must be a 12-digit AWS account id."
  }
}

variable "http_success_feedback_sample_rate" {
  description = "Percentage (0-100) of successful HTTP/S deliveries to log to CloudWatch. null disables HTTP delivery logging."
  type        = number
  default     = null

  validation {
    condition     = var.http_success_feedback_sample_rate == null || (var.http_success_feedback_sample_rate >= 0 && var.http_success_feedback_sample_rate <= 100)
    error_message = "http_success_feedback_sample_rate must be between 0 and 100."
  }
}

variable "lambda_success_feedback_sample_rate" {
  description = "Percentage (0-100) of successful Lambda deliveries to log to CloudWatch. null disables Lambda delivery logging."
  type        = number
  default     = null

  validation {
    condition     = var.lambda_success_feedback_sample_rate == null || (var.lambda_success_feedback_sample_rate >= 0 && var.lambda_success_feedback_sample_rate <= 100)
    error_message = "lambda_success_feedback_sample_rate must be between 0 and 100."
  }
}

variable "sqs_success_feedback_sample_rate" {
  description = "Percentage (0-100) of successful SQS deliveries to log to CloudWatch. null disables SQS delivery logging."
  type        = number
  default     = null

  validation {
    condition     = var.sqs_success_feedback_sample_rate == null || (var.sqs_success_feedback_sample_rate >= 0 && var.sqs_success_feedback_sample_rate <= 100)
    error_message = "sqs_success_feedback_sample_rate must be between 0 and 100."
  }
}

variable "tags" {
  description = "Tags applied to the topic and the delivery-feedback IAM role."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "The ARN of the SNS topic (Terraform uses the ARN as the resource id)."
  value       = aws_sns_topic.this.id
}

output "arn" {
  description = "The ARN of the SNS topic. Use this as the subscription/publish target."
  value       = aws_sns_topic.this.arn
}

output "name" {
  description = "The final topic name, including the .fifo suffix when applicable."
  value       = aws_sns_topic.this.name
}

output "display_name" {
  description = "The display name configured on the topic."
  value       = aws_sns_topic.this.display_name
}

output "owner" {
  description = "The AWS account id that owns the topic."
  value       = aws_sns_topic.this.owner
}

output "is_fifo" {
  description = "Whether the created topic is a FIFO topic."
  value       = aws_sns_topic.this.fifo_topic
}

output "feedback_role_arn" {
  description = "ARN of the IAM role SNS uses to write delivery-status logs, or null when delivery logging is disabled."
  value       = local.enable_feedback_role ? aws_iam_role.feedback[0].arn : null
}

How to use it

A standard fan-out topic encrypted with a customer-managed key, allowing CloudWatch and S3 to publish, with HTTP delivery logging at 100%:

module "sns_topic" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-sns?ref=v1.0.0"

  name              = "order-events"
  display_name      = "Order Events"
  kms_master_key_id = aws_kms_key.messaging.arn

  # Let CloudWatch alarms and S3 bucket notifications publish to it,
  # scoped to this account to avoid the confused-deputy problem.
  allowed_publisher_services = ["cloudwatch", "s3"]
  source_account_id          = data.aws_caller_identity.current.account_id

  # Also let a specific cross-account ingestion role publish.
  allowed_publisher_arns = [
    "arn:aws:iam::210987654321:role/order-ingestion",
  ]

  # Custom HTTP retry posture for flaky downstream webhooks.
  delivery_policy = jsonencode({
    healthyRetryPolicy = {
      minDelayTarget     = 5
      maxDelayTarget     = 60
      numRetries         = 8
      numNoDelayRetries  = 0
      numMinDelayRetries = 3
      numMaxDelayRetries = 0
      backoffFunction    = "exponential"
    }
  })

  http_success_feedback_sample_rate = 100

  tags = {
    environment = "prod"
    team        = "commerce"
    managed_by  = "terraform"
  }
}

# Downstream reference: subscribe an SQS queue to the topic using the
# module's ARN output, and grant SNS permission to enqueue.
resource "aws_sns_topic_subscription" "orders_to_queue" {
  topic_arn = module.sns_topic.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.orders.arn

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.orders_dlq.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 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/sns/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-sns?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/sns && 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 Base topic name (no .fifo suffix); letters, digits, hyphens, underscores, max 250 chars.
display_name string null no Human-friendly display name; required for SMS subscriptions.
fifo_topic bool false no Create a FIFO topic; .fifo suffix added automatically.
content_based_deduplication bool false no Enable content-based dedup (FIFO only).
kms_master_key_id string "alias/aws/sns" no KMS key id/alias/ARN for SSE; null stores messages in plaintext.
delivery_policy string null no JSON HTTP/S retry/throttle policy; null uses SNS defaults.
policy string null no Full topic resource policy JSON; overrides the generated policy.
allowed_publisher_arns list(string) [] no IAM principal ARNs granted SNS:Publish in the generated policy.
allowed_publisher_services list(string) [] no AWS service short names allowed to publish (e.g. cloudwatch, s3).
source_account_id string null no 12-digit account id used as AWS:SourceAccount to block confused-deputy.
http_success_feedback_sample_rate number null no 0–100 sample rate for HTTP delivery logging; null disables it.
lambda_success_feedback_sample_rate number null no 0–100 sample rate for Lambda delivery logging; null disables it.
sqs_success_feedback_sample_rate number null no 0–100 sample rate for SQS delivery logging; null disables it.
tags map(string) {} no Tags applied to the topic and feedback IAM role.

Outputs

Name Description
id The ARN of the topic (Terraform resource id).
arn The topic ARN — use as the publish/subscribe target.
name Final topic name, including the .fifo suffix when applicable.
display_name The configured display name.
owner AWS account id that owns the topic.
is_fifo Whether the created topic is FIFO.
feedback_role_arn ARN of the SNS delivery-logging IAM role, or null when logging is off.

Enterprise scenario

A retail platform routes every checkout completion through an order-events topic created by this module. The web tier publishes once; SNS fans the event out to three SQS queues — fulfilment, fraud-scoring, and the data-lake ingestion pipeline — plus a Lambda that updates the loyalty ledger. The topic is encrypted with a CMK the security team rotates annually, its generated policy permits only the checkout service role and the in-account CloudWatch alarms to publish, and source_account_id blocks any cross-account confused-deputy attempt. When a payments partner is onboarded, platform engineers add their ingestion role to allowed_publisher_arns and ship a v1.x tag bump — no console clicks, fully audited in Git.

Best practices

TerraformAWSSNS TopicModuleIaC
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