Building reusable SQS queues with Terraform
When working with asynchronous systems in AWS solutions usually include SQS without thinking too much about long-term architecture and structure. Regardless that SQS is generally easy to deploy and use when you start deploying multiple services and solution gets on its complexity, things quickly become inconsistent:
- Some queues have DLQ configured
- Some queues are FIFO while others are not
- Retention settings differ everywhere
- Queue naming becomes inconsistent across environments
Over time, queue management becomes repetitive and messy so in this post, you’ll see how to build a reusable Terraform module for AWS SQS that supports:
- Standard & FIFO queues
- Optional Dead Letter Queues (DLQ)
- Reusable environment-based deployments
- Consistent queue behavior across services
Queue Architecture
At a high level, this module creates:
- Main queue
- Optional Dead Letter Queue
resource "aws_sqs_queue" "dlq" {
count = var.enable_dlq ? 1 : 0
name = var.fifo_queue ? "${var.name}-dlq.fifo" : "${var.name}-dlq"
fifo_queue = var.fifo_queue
content_based_deduplication = var.fifo_queue ? var.content_based_deduplication : null
message_retention_seconds = var.dlq_message_retention_seconds
tags = var.tags
}
resource "aws_sqs_queue" "main" {
name = var.fifo_queue ? "${var.name}.fifo" : var.name
fifo_queue = var.fifo_queue
content_based_deduplication = var.fifo_queue ? var.content_based_deduplication : null
delay_seconds = var.delay_seconds
max_message_size = var.max_message_size
message_retention_seconds = var.message_retention_seconds
visibility_timeout_seconds = var.visibility_timeout_seconds
redrive_policy = var.enable_dlq ? jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq[0].arn
maxReceiveCount = var.max_receive_count
}) : null
tags = var.tags
}
resource "aws_sqs_queue_redrive_allow_policy" "dlq_allow_policy" {
count = var.enable_dlq ? 1 : 0
queue_url = aws_sqs_queue.dlq[0].id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.main.arn]
})
}
Optional DLQ Creation
The easiest approach is to have DLQ queue support within the module so that with simple count you can configure a DLQ if needed:
count = var.enable_dlq ? 1 : 0This means:
enable_dlq = true → DLQ gets created
enable_dlq = false → DLQ resources do not existExample without DLQ:
module "notification_queue" {
source = "../../modules/sqs"
name = "${var.sqs_notification_queue.name}-${var.env}"
message_retention_seconds = var.sqs_notification_queue.message_retention_seconds
visibility_timeout_seconds = var.sqs_notification_queue.visibility_timeout_seconds
max_receive_count = var.sqs_notification_queue.max_receive_count
enable_dlq = false
tags = local.common_tags
}Example with DLQ enabled:
module "urc_queue" {
source = "../../modules/sqs"
name = "${var.sqs_urc_queue.name}-${var.env}"
message_retention_seconds = var.sqs_urc_queue.message_retention_seconds
visibility_timeout_seconds = var.sqs_urc_queue.visibility_timeout_seconds
max_receive_count = var.sqs_urc_queue.max_receive_count
enable_dlq = true
tags = local.common_tags
}This keeps the module flexible while avoiding duplicated Terraform code.
Instead of forwarding every queue parameter manually inside module calls, another cleaner approach is grouping queue configuration into Terraform objects. This becomes especially useful once infrastructure starts growing and multiple queues share similar configuration patterns.
Example:
variable "sqs_export_queue" {
type = object({
name = string
visibility_timeout_seconds = number
message_retention_seconds = number
dlq_message_retention_seconds = number
max_receive_count = number
})
default = {
name = "export-queue"
visibility_timeout_seconds = 180 # 2 min
message_retention_seconds = 345600 # 4 days
dlq_message_retention_seconds = 1209600 # 14 days
max_receive_count = 3
}
}Another approach would be grouping them up within locals.tf:
locals {
sqs_queues = {
import = {
name = "import-queue"
visibility_timeout_seconds = 960
message_retention_seconds = 604800
dlq_message_retention_seconds = 1209600
max_receive_count = 3
fifo_queue = true
enable_dlq = true
}
notification = {
name = "notification-queue"
visibility_timeout_seconds = 300
message_retention_seconds = 86400
dlq_message_retention_seconds = 1209600
max_receive_count = 5
fifo_queue = false
enable_dlq = false
}
}
}
Supporting FIFO Queues
One clean part of this implementation is support for both Standard and FIFO queues inside the same module.
name = var.fifo_queue ? "${var.name}.fifo" : var.name
fifo_queue = var.fifo_queue
content_based_deduplication = var.fifo_queue ? var.content_based_deduplication : nullWhat happens here:
- Standard queues use regular naming
- FIFO queues automatically append
.fifo - FIFO-specific settings are enabled only when required
Example:
module "import_queue" {
source = "../../modules/sqs"
name = "${var.sqs_import.name}-${var.env}"
message_retention_seconds = var.sqs_import.message_retention_seconds
visibility_timeout_seconds = var.sqs_import.visibility_timeout_seconds
max_receive_count = var.sqs_import.max_receive_count
fifo_queue = true
tags = local.common_tags
}This automatically creates queue names like:
import-dev.fifo
import-stage.fifo
import-prod.fifoWithout requiring additional logic outside the module.
Queue Configuration
The module exposes all important queue settings through variables:
variable "name" {
type = string
}
variable "visibility_timeout_seconds" {
type = number
}
variable "message_retention_seconds" {
type = number
}
variable "max_receive_count" {
type = number
}
variable "delay_seconds" {
type = number
default = 0
}
variable "max_message_size" {
type = number
default = 256000
}
variable "tags" {
type = map(string)
default = {}
}
variable "dlq_message_retention_seconds" {
type = number
default = 1209600 # 14 days
}
variable "enable_dlq" {
type = bool
default = true
}
variable "fifo_queue" {
type = bool
default = false
}
variable "content_based_deduplication" {
type = bool
default = true
}
With the current default values, the module will create a standard SQS queue with DLQ enabled, content-based deduplication enabled for FIFO queues, 14-day DLQ retention and default message size limit unless overridden during module invocation.
This allows different services to customize queue behavior without modifying the module itself.
For example:
- Long-running consumers may require higher visibility timeout
- Some systems may retain messages only a few hours
- Others may keep messages for multiple days
- Some consumers may require delayed delivery
Dead Letter Queue (DLQ) Behavior
When DLQ is enabled, the module automatically configures queue redrive policy:
redrive_policy = var.enable_dlq ? jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq[0].arn
maxReceiveCount = var.max_receive_count
}) : nullTerraform conditionally configures the redrive policy only when DLQ support is enabled, otherwise the value is set to null and no dead-letter queue configuration is applied to the SQS queue. This controls how failed messages move into DLQ.
Example:
maxReceiveCount = 5Behavior:
- Consumer receives message
- Processing fails
- Message becomes visible again
- SQS retries delivery
- After 5 failed attempts
- Message moves to DLQ
Without DLQ support:
- Messages continuously retry
- Queue processing becomes unstable
- Debugging failures becomes difficult
- Consumers may get blocked indefinitely
Redrive Allow Policy
Another important detail is explicitly allowing the main queue to use the DLQ.
resource "aws_sqs_queue_redrive_allow_policy" "dlq_allow_policy" {
count = var.enable_dlq ? 1 : 0
queue_url = aws_sqs_queue.dlq[0].id
redrive_allow_policy = jsonencode({
redrivePermission = "byQueue"
sourceQueueArns = [aws_sqs_queue.main.arn]
})
}This ensures that redrive allow policy explicitly defines which source queues are allowed to use the DLQ. In this case, only the main SQS queue can move failed messages into the dead-letter queue. Which means if you have 5 module calls and each has DLQ then each main SQS will be able to push messages only to it’s DLQ.
Environment-Based Queue Naming
Notice how every queue appends environment suffix:
name = "${var.sqs_import.name}-${var.env}"This avoids:
- Name collisions
- Shared queue usage across environments
- Operational confusion during debugging
Resulting queues become:
import-dev
import-stage
import-prodAnd FIFO queues automatically become:
import-dev.fifo
import-stage.fifo
import-prod.fifoConclusion
SQS itself is relatively simple, but setting up reusable and structured infrastructure code early on makes it much easier to manage and scale once systems and integrations become more complex.
This module supports:
- FIFO queues
- Optional DLQ creation
- Configurable parameters
- Environment isolation
inside a reusable Terraform module makes queue management significantly cleaner as systems grow. The important part is not only provisioning queues, but building infrastructure that behaves consistently across all services and environments.







