All posts
Infrastructure as Code

What Does a Production Ready SQS Queue Actually Need?

DevLift Engineering6 min read

The minimal Terraform for a queue is genuinely six lines:

resource "aws_sqs_queue" "payments" {
  name = "payments"
}

That works. Messages go in, messages come out, and nothing in the console suggests anything is missing.

Then a consumer throws an exception on one message. Then a deploy takes longer than expected. Then somebody publishes a message that is 300 KB. Each of those is a different failure, none of them is loud, and the six line version handles none of them.

Here is what the rest of the definition is for.

What happens to a message your consumer cannot process?

Without a dead letter queue: it goes back on the queue. Then it is picked up again, fails again, and goes back again. Forever, until the retention period expires and SQS deletes it.

There is no error, no alarm and no record. A poison message quietly consumes consumer capacity for four days and then disappears.

A dead letter queue changes that into something visible:

resource "aws_sqs_queue" "payments_dlq" {
  name                      = "payments-dlq"
  message_retention_seconds = 1209600  # 14 days, the maximum
}

resource "aws_sqs_queue" "payments" {
  name = "payments"

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.payments_dlq.arn
    maxReceiveCount     = 5
  })
}

Three details worth getting right.

maxReceiveCount is attempts, not failures. A message that is received and not deleted counts, including when your consumer crashed before it got to the logic. Five is a reasonable default: high enough to survive a deploy or a brief downstream outage, low enough that a genuinely bad message stops burning capacity quickly.

Give the DLQ the maximum retention. The main queue can keep the default. The DLQ holds things a human needs to look at, and humans are slower than four days. Fourteen is the ceiling and there is no reason to use less.

A FIFO queue needs a FIFO dead letter queue. Same type, same region, same account. Mixing them fails at apply, which is the good outcome, but it is a five minute detour you can skip by knowing.

Why does visibility timeout cause duplicate work?

This is the setting that produces the strangest bug reports, because the symptom is "the same order was processed twice" and nothing in the logs looks wrong.

When a consumer receives a message, SQS hides it for the visibility timeout, default 30 seconds. If the consumer deletes it in that window, done. If it does not, the message becomes visible again and somebody else picks it up. Your first consumer is probably still working on it.

So the rule is: visibility timeout must exceed the longest time your consumer might take, not the average. Averages hide the case that matters.

For a Lambda consumer, AWS's own guidance is to set the queue's visibility timeout to at least six times the function timeout. That sounds excessive until you account for retries and batching inside the invocation.

visibility_timeout_seconds = 180  # consumer p99 is ~25s; leaves room

If your processing time is genuinely unpredictable, the better answer is to extend the timeout while working, using ChangeMessageVisibility in a heartbeat, rather than setting a very long static timeout. A long static timeout means a crashed consumer's message is invisible for that whole period, which turns a fast failure into a slow one.

Should you enable long polling?

Yes, essentially always, and it is one line.

receive_wait_time_seconds = 20  # the maximum

The default of zero is short polling: a receive call samples a subset of servers and frequently returns nothing even when messages exist. Consumers loop, each empty response is a billable request, and delivery is less prompt rather than more.

Long polling waits up to twenty seconds for a message before returning empty. Fewer requests, lower cost, and messages arrive sooner. There is no scenario where zero is what you wanted; it is a default from an older design.

What about encryption?

Turn it on. The question is only which kind.

sqs_managed_sse_enabled = true

SSE-SQS uses AWS managed keys, costs nothing, and requires no key policy work. It is the right default for almost everything.

Reach for SSE-KMS with a customer managed key only when you need to control the key policy, need the key usage audit trail in CloudTrail, or have a compliance requirement naming customer managed keys. It costs money per request and it adds a way to break things: a consumer whose role cannot use the key gets an access denied that reads like a queue permissions problem.

FIFO or standard?

Standard unless you can name the ordering requirement, because FIFO costs you throughput and adds required fields.

Standard is at-least-once delivery with best-effort ordering, effectively unlimited throughput. Your consumer must be idempotent, which it should be regardless, because visibility timeouts and retries mean duplicates happen on any queue type.

FIFO is exactly-once processing within a message group and strict ordering inside that group. The name must end in .fifo. Every message needs a MessageGroupId, and ordering is guaranteed only within a group, which is the part people miss: one group means strict ordering and no parallelism, many groups means parallelism and ordering only per group.

resource "aws_sqs_queue" "orders" {
  name                        = "orders.fifo"
  fifo_queue                  = true
  content_based_deduplication = true
}

The honest test: if you cannot state which two messages must not be reordered and why, you want standard.

What should you alarm on?

One metric matters more than the obvious one.

Age of the oldest message (ApproximateAgeOfOldestMessage) tells you whether anything is being processed. It rises when consumers are down, stuck, or too slow, and it stays flat when they are keeping up regardless of volume. This is the alarm.

Queue depth (ApproximateNumberOfMessagesVisible) tells you volume. A depth of ten thousand is fine if it is draining, and a depth of five is a problem if it has been five for an hour. Depth is a scaling signal, not a health signal.

Anything on the DLQ. Not a threshold, not a rate. A single message arriving in the dead letter queue means something failed five times, and that deserves a human. Alarm on ApproximateNumberOfMessagesVisible >= 1.

The third one is the one teams skip, and it is the reason a DLQ full of failed payments sits undiscovered for a month.

What else bites?

Short list, all cheap to know in advance.

The 256 KB message limit. Larger payloads need the extended client library, which puts the body in S3 and sends a pointer. If your messages are approaching 256 KB, that is usually a design signal rather than a limit to work around.

Queue policy is not IAM policy. The queue's resource policy governs who may send, and IAM governs what your roles may do. Cross-account sending needs both sides, and getting one right while missing the other produces an access denied that looks like the other side's problem.

Retention is a deadline, not storage. Default four days, maximum fourteen. A queue that backs up over a long weekend can silently pass its retention and delete messages, which is exactly when nobody is looking.

What the full definition looks like

resource "aws_sqs_queue" "payments_dlq" {
  name                      = "payments-dlq"
  message_retention_seconds = 1209600
  sqs_managed_sse_enabled   = true
}

resource "aws_sqs_queue" "payments" {
  name                       = "payments"
  visibility_timeout_seconds = 180
  receive_wait_time_seconds  = 20
  message_retention_seconds  = 345600
  sqs_managed_sse_enabled    = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.payments_dlq.arn
    maxReceiveCount     = 5
  })
}

Plus two CloudWatch alarms: age of oldest message on the main queue, and any message at all on the dead letter queue.

Roughly twenty five lines instead of six. Nothing in there is clever, and each line exists because the version without it fails in a way that produces no error message.

The short version

The six line queue works right up to the first failure, and then fails silently. Add a dead letter queue with a real retention, set visibility timeout from your worst case rather than your average, turn on long polling and encryption, and alarm on message age rather than depth.

If you write this more than twice, it belongs in a module. If you write it more than five times by hand, one of them is wrong and you do not know which.


DevLift provisions queues, buckets, databases and caches from a plain-English description, applying this shape by default and opening a pull request rather than applying. Book a walkthrough, or read how to provision from Claude Code.

See what this looks like on your own cloud account

DevLift's agents run continuous cost, drift and compliance detection across AWS, Azure and GCP, and propose fixes as reviewable changes, not dashboards. A walkthrough takes 30 minutes.

Schedule a demo

Keep reading