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
- You are standing up event-driven or fan-out workloads where one publisher feeds many consumers (SNS → multiple SQS queues, the classic fan-out pattern).
- You need ordered, deduplicated delivery and want a FIFO topic with content-based deduplication.
- You want every topic encrypted at rest with a CMK and governed by a least-privilege resource policy instead of the permissive AWS default.
- You are wiring CloudWatch alarms, S3 bucket notifications, or EventBridge targets that publish to a topic and want the publish permissions managed as code.
- You operate many topics across teams and need consistent tagging, naming, and DLQ/retry posture enforced by the module rather than by convention.
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 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/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
- Always encrypt with a CMK, not the default key. Pass a customer-managed
kms_master_key_idso you control rotation and key policy; subscribers (and SNS itself) must be grantedkms:Decrypt/kms:GenerateDataKeyon that key or deliveries silently fail. - Scope the resource policy tightly. Prefer
allowed_publisher_arns/allowed_publisher_servicesover a wildcard principal, and setsource_account_idwhenever an AWS service (S3, CloudWatch, EventBridge) publishes to defeat the confused-deputy problem. - Use FIFO only when you need ordering. FIFO topics cost more, are capped at lower throughput, and only fan out to SQS FIFO queues — reach for them for sequencing-sensitive flows, and keep high-volume notifications on standard topics.
- Put a redrive policy on every subscription. SNS itself doesn’t store messages, so attach a dead-letter queue at the subscription level (as in the usage example) to capture deliveries that exhaust their retries.
- Tune
delivery_policyfor HTTP/S subscribers, not SQS/Lambda. Exponential backoff with a bounded retry count protects flaky webhooks; SQS and Lambda deliveries ignore this policy and have their own retry semantics. - Standardise naming and tagging. Drive
name,environment, andteamtags from the module so topics are discoverable, cost-allocatable, and consistent across every account in the organisation.