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 : 0

This means:

enable_dlq = true → DLQ gets created
enable_dlq = false → DLQ resources do not exist

Example 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 : null

What 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.fifo

Without 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
}) : null

Terraform 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 = 5

Behavior:

  1. Consumer receives message
  2. Processing fails
  3. Message becomes visible again
  4. SQS retries delivery
  5. After 5 failed attempts
  6. 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-prod

And FIFO queues automatically become:

import-dev.fifo
import-stage.fifo
import-prod.fifo
Conclusion

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.