Adopt an existing AWS queue with reusable Terraform 1.16 HCL, a reviewed saved plan and CI gates that reject production changes.

Your AWS service works. The risk starts when a reusable Terraform module describes what production should look like rather than what actually exists. An import can then arrive alongside an unwanted configuration change or replacement. Terraform 1.16 lets child modules carry their own import blocks, but it does not remove that risk. Follow an existing SQS queue from ownership checks to a reviewed, import-only saved plan, with CI enforcing the boundary between adopting infrastructure and changing it.
Choose the import workflow without confusing module support
Establish ownership and a recovery route before importing
Model the existing AWS queue in a reusable child module
Make CI reject replacements before approving the saved plan
Use data sources and moved blocks where import is wrong
Hand over production with a phased effort and cost worksheet
To import existing resources into Terraform modules, target the resource’s full module address. You do not need Terraform 1.16 for that. The distinction is where an import block can live, not whether Terraform can import into a module.
| Workflow | Module support | State and review |
|---|---|---|
| CLI import into a module address | Supported independently of declarative import blocks | Writes state immediately; no import plan to approve |
| Root-level declarative import targeting a child resource | Available from Terraform 1.5 | Plan and review before applying |
| Import block packaged inside a child module | Supported by Terraform 1.16 | Plan and review within the module-instance context |
Choose one workflow. These are alternatives, not three operations to run against the same production queue.
For CLI import, configure the destination resource first, then supply the existing SQS queue URL:
terraform import 'module.orders.aws_sqs_queue.this' "$QUEUE_URL"
The CLI import reference documents module addresses and immediate state adoption. Import itself does not recreate the queue; a subsequent apply can still change or replace it.
For a root-level declarative import, declare existing_url in the root module:
import {
to = module.orders.aws_sqs_queue.this
id = var.existing_url
}
For Terraform 1.16 import blocks physically inside the child module, the destination is child-local:
import {
to = aws_sqs_queue.this
id = var.existing_url
}
Here, existing_url must be a child-module input. HashiCorp’s announcement dated 28 September 2026 concerns block placement, not the invention of importing into modules. Module-instance context, nested calls and provider import support still matter.
For brownfield infrastructure, prefer declarative imports when production changes need approval. HashiCorp’s Validated Designs guidance describes the configuration-driven approach, giving Terraform plan CI checks something reviewable.
Our framework is Own, Match, Gate, Hand over. Its rule: ownership adoption and infrastructure improvement are separate changes. Bringing a live queue under management is not permission to “tidy up” its settings.

| Phase | AWS account and region | Object | Current controller | Target workspace and address | Provider version and import identifier | Dependency owners | Evidence | Engineer hours | Reviewer | Recurring cost delta | Acceptance criterion |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Own — illustrative | 123456789012 / eu-west-2 | Standard SQS queue: orders-prod | Manual console changes; platform team | prod / module.orders.aws_sqs_queue.this | hashicorp/aws 6.0.0; https://sqs.eu-west-2.amazonaws.com/123456789012/orders-prod | Orders producers; fulfilment consumers; IAM and KMS owners | Queue attributes; ownership inventory; owner sign-off; protected state snapshot | 8 total | Platform lead | £0 expected for import alone; backend costs separate | Single owner confirmed; recovery route recorded; no replacement or queue changes |
Own means proving who controls the object. Check other Terraform workspaces and repositories, plus CloudFormation stacks and CDK deployments. An empty local state list does not prove brownfield infrastructure is unmanaged.
One remote object must have one authoritative controller and one Terraform state binding. Unresolved ownership is a stop condition, not permission to import into a second state.
aws sts get-caller-identity
terraform workspace show
terraform state list
Confirm the intended account, region, backend and workspace. For an existing state, take a protected snapshot:
umask 077; terraform state pull > "state-before-$(date -u +%Y%m%dT%H%M%SZ).json"
Require a successful pull and a non-empty, valid JSON state file with expected state metadata. Stop on failure; never mask errors with || true. For a genuinely new workspace, record “no prior snapshot”; do not manufacture an empty backup.
State pull retrieves Terraform state, not SQS messages or an application backup. Use an encrypted, access-controlled remote backend with locking and backend version recovery. Briefly freeze configuration writers, including console changes; leave normal producers and consumers running.
The SQS import identifier is the complete queue URL, not its ARN or short name. The CLI documentation’s EC2 example uses an instance ID instead. Identifier formats belong to the particular provider resource and version.
Planning assumption: 2 engineer-hours discovery + 3 modelling + 2 CI and review + 1 handover = 8 engineer-hours. This is not an industry benchmark. Use the worksheet to scope the migration around ownership and risk.
Import the ownership you have agreed, not everything your credentials can see.
Share this quote

Existing application producer → existing standard SQS queue → existing worker. Only the queue enters this Terraform workspace. Application deployment remains with the Orders application team; IAM remains with the Security platform team.
This illustrative baseline has no dead-letter queue, no custom queue policy, SQS-managed encryption and existing tags. It is a small brownfield adoption exercise, not a complete production queue design.
export AWS_REGION=eu-west-2
QUEUE_URL=$(aws sqs get-queue-url --queue-name orders-prod --query QueueUrl --output text)
aws sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names All > queue-attributes.json
aws sqs list-queue-tags --queue-url "$QUEUE_URL" > queue-tags.json
export TF_VAR_existing_url="$QUEUE_URL"
Use the values below only if inventory matches, including the empty tag map. KMS, FIFO, redrive or access-policy configuration needs matching configuration and ownership decisions. Do not paste this baseline over it.
.
├── versions.tf
├── main.tf
├── .terraform.lock.hcl
└── modules/
└── queue/
├── versions.tf
├── main.tf
└── imports.tf
versions.tf:
terraform {
required_version = ">= 1.16.0, < 1.17.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.70.0"
}
}
}
Version 6.70.0 is an explicit example pin from the provider’s October 2026 releases, not a claim this exercise ran against it. Commit the generated .terraform.lock.hcl. Use the same selected Terraform patch locally and in CI; keep provider upgrades separate from adoption.
main.tf: replace the example account ID with your account. Do not introduce provider default_tags during adoption.
provider "aws" {
region = "eu-west-2"
allowed_account_ids = ["123456789012"]
}
variable "existing_url" {
type = string
}
module "orders" {
source = "./modules/queue"
existing_url = var.existing_url
queue = {
name = "orders-prod"
message_retention_seconds = 345600
visibility_timeout_seconds = 30
receive_wait_time_seconds = 0
delay_seconds = 0
max_message_size = 1048576
tags = {}
}
}
modules/queue/versions.tf: the child inherits the root’s AWS provider configuration.
terraform {
required_version = ">= 1.16.0, < 1.17.0"
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
modules/queue/main.tf:
variable "existing_url" {
type = string
}
variable "queue" {
type = object({
name = string
message_retention_seconds = number
visibility_timeout_seconds = number
receive_wait_time_seconds = number
delay_seconds = number
max_message_size = number
tags = map(string)
})
}
resource "aws_sqs_queue" "this" {
name = var.queue.name
fifo_queue = false
message_retention_seconds = var.queue.message_retention_seconds
visibility_timeout_seconds = var.queue.visibility_timeout_seconds
receive_wait_time_seconds = var.queue.receive_wait_time_seconds
delay_seconds = var.queue.delay_seconds
max_message_size = var.queue.max_message_size
sqs_managed_sse_enabled = true
tags = var.queue.tags
lifecycle {
prevent_destroy = true
}
}
modules/queue/imports.tf uses Terraform 1.16 import blocks inside child modules:
import {
to = aws_sqs_queue.this
id = var.existing_url
}
The URL is the AWS provider-specific import ID. This module is intentionally adoption-only: every caller must supply a known existing URL. It is not a create-new module.
prevent_destroy is a secondary guard. It cannot protect the queue after its resource configuration is removed.
Reusable adoption modules give platform teams a consistent boundary for importing existing resources into modules. They fit alongside Terraform and deployment automation, not application or IAM ownership.

For brownfield infrastructure, “no replacement” is only the first gate. An adoption-only change should import the queue without changing its settings. A green plan containing an in-place update is still a production change.
Use Terraform’s plan JSON, not terminal summary text, as the policy interface. Replacements contain delete and create, in either order; there is no replace action. Import metadata is additional to that action array.
This Bash script assumes Terraform, AWS CLI and jq are installed, with the backend and AWS role configured. Set QUEUE_URL to the existing queue’s URL. The fixture tests run before planning and fail the job if policy behaviour changes.
#!/usr/bin/env bash
set -euo pipefail
: "${QUEUE_URL:?Set QUEUE_URL to the existing SQS queue URL}"
no_delete() {
jq -e '[.resource_changes[]? | select(.mode == "managed") | .change.actions[]? | select(. == "delete")] | length == 0' adopt.json
}
adoption_gate() {
jq -e '
(.format_version | test("^1\\.[0-9]+$")) and
(.errored != true) and
(.complete != false) and
((.deferred_changes // []) | length == 0)
' adopt.json &&
no_delete &&
jq -e '[.resource_changes[]? | select(.mode == "managed" and .change.actions != ["no-op"])] | length == 0' adopt.json &&
jq -e --arg address 'module.orders.aws_sqs_queue.this' --arg id "$QUEUE_URL" '[.resource_changes[]? | select(.change.importing != null) | {address: .address, id: .change.importing.id}] == [{address: $address, id: $id}]' adopt.json
}
# Isolate fixtures from the real plan artifacts.
(
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cd "$tmp"
while read -r name actions binding expected_delete expected_adoption; do
id="$QUEUE_URL"
if [[ "$binding" == wrong ]]; then id="${QUEUE_URL}-wrong"; fi
jq -n --argjson actions "$actions" --arg binding "$binding" \
--arg id "$id" '{
format_version: "1.2", errored: false, complete: true,
deferred_changes: [],
resource_changes: [{
address: "module.orders.aws_sqs_queue.this",
mode: "managed",
change: {
actions: $actions,
importing: (if $binding == "none" then null else {id: $id} end)
}
}]
}' > adopt.json
actual_delete=fail
if no_delete >/dev/null; then actual_delete=pass; fi
actual_adoption=fail
if adoption_gate >/dev/null; then actual_adoption=pass; fi
if [[ "$actual_delete" != "$expected_delete" ||
"$actual_adoption" != "$expected_adoption" ]]; then
printf 'Fixture failed: %s\n' "$name" >&2
exit 1
fi
done <<'FIXTURES'
destroy-first ["delete","create"] correct fail fail
create-first ["create","delete"] correct fail fail
delete ["delete"] correct fail fail
create ["create"] correct pass fail
update ["update"] correct pass fail
wrong-id ["no-op"] wrong pass fail
missing-import ["no-op"] none pass fail
valid-import ["no-op"] correct pass pass
FIXTURES
# An empty plan must also fail the exact import assertion.
printf '%s\n' '{"format_version":"1.2","resource_changes":[]}' > adopt.json
if adoption_gate >/dev/null; then
printf 'Empty plan unexpectedly passed\n' >&2
exit 1
fi
)
terraform init -input=false -lockfile=readonly
terraform fmt -check -recursive
terraform validate
terraform plan -input=false -lock-timeout=5m -out=adopt.tfplan
terraform show -no-color adopt.tfplan > adopt.txt
terraform show -json adopt.tfplan > adopt.json
adoption_gate
sha256sum adopt.tfplan > adopt.tfplan.sha256
The update fixture deliberately passes the reusable no-delete check but fails adoption. The exact address-and-ID assertion also rejects missing imports, extra imports and the wrong queue binding. Unsupported JSON major versions, errored plans, explicitly incomplete plans and deferred changes fail closed. Command failures retain their non-zero exits.
adopt.txt for the expected queue identity, account, region and settings. Reconcile its queue URL and ARN with the approved inventory.The protected job verifies the approved digest and applies that exact artifact:
set -euo pipefail
sha256sum -c adopt.tfplan.sha256
terraform apply -input=false adopt.tfplan
Terraform plan CI checks enforce a narrow technical policy. They do not replace accountable reviewers, recovery ownership or production change controls.
Taking ownership and changing production belong in different pull requests.
Share this quote

| Situation | Right mechanism |
|---|---|
| Unmanaged object whose lifecycle this team will own | Import into the intended resource address. |
| Existing dependency whose lifecycle stays elsewhere | Use a data source or an agreed input/output contract. |
| Object already tracked at another address in this same state | Use a moved block, not import. |
data "aws_sqs_queue" "shared" {
name = "shared-events"
}
This reads queue attributes; it does not adopt the queue’s lifecycle. The owning team still controls retention, access policy and deletion. Pass data.aws_sqs_queue.shared.arn to consumers that need its ARN.
If discovery by name is unsuitable, agree an explicit ARN or URL input with the owner. Reading shared brownfield infrastructure is not a reason to import it.
For a root queue already managed in this same state, use:
moved {
from = aws_sqs_queue.orders
to = module.orders.aws_sqs_queue.this
}
This is an alternative to the unmanaged-queue exercise, not an additional import step. Move its resource configuration into the child module too. The block changes the tracked address; it does not transfer ownership across states.
Cross-state handover needs coordinated source relinquishment and destination adoption, backups of both states, and a writer freeze covering both configurations. Do not improvise with a blind terraform state rm or a normal destroy.
A removed block with destroy = false can relinquish management without deleting the queue. Remove or disable retained import logic that could unintentionally re-adopt it.
Failure scenario: a team imports a queue successfully, but its module reduces retention from four days to one. Older queued messages can expire even though the no-delete test is green. Terraform import without recreation is not proof of safety: apply the previous section’s stricter gate to retention changes, not just replacements.

Return to the worksheet: Terraform import without recreation is complete only when production evidence closes every row.
| Phase | Acceptance evidence |
|---|---|
| Own | Controller agreement and tested state-recovery evidence. |
| Match | Observed queue settings represented in HCL. |
| Gate | Expected import; zero managed creates, updates or deletes. |
| Hand over | Post-apply no-change plan and a named operational owner. |
Apply only the approved saved artifact, then inspect the adopted resource and check for differences:
terraform apply approved.tfplan
terraform state show 'module.orders.aws_sqs_queue.this'
terraform plan -detailed-exitcode
Exit code 0 means no differences; 1 means error; 2 means changes requiring investigation. Capture the unchanged queue URL and ARN. Compare application processing, queue age and error signals against the team’s pre-adoption baseline.
Schedule drift checks and document break-glass edits, reconciliation, module upgrades and state-recovery responsibility. Decide deliberately whether to keep completed import blocks: they remain idempotent while their destinations remain in state. Retaining an adoption-only child module also preserves its existing-object requirement for future callers.
Start with eight engineer-hours. These additional planning allowances are not a fixed-price commitment:
| Labour item | Hours |
|---|---|
| Base adoption | 8 |
| Controller conflicts | +2 |
| Policy modelling | +2 |
| Reviewer time | +1 |
Keep labour separate from ongoing AWS spend. Pure state adoption should add no replacement capacity, but discovery API requests, state storage and CI still cost money.
| Baseline | Proposed change | Monthly usage assumption | Region / currency | Recurring delta | Approval owner |
|---|---|---|---|---|---|
| Existing queue and tooling bill | State adoption only | Unchanged queue traffic; daily drift CI | eu-west-2 / £ | No capacity increase; meter API, storage and CI overhead | Platform lead |
For configuration proposals, add rows using measured request volume and payload sizes. AWS meters each 64 KB payload chunk as a request; include extra KMS calls and cross-region transfer charges. Avoid a misleading per-queue price: evaluate cost changes separately from adoption.
If useful, discuss a scoped infrastructure-as-code adoption assessment: one service, its ownership map, an effort estimate and explicit go/no-go criteria.
Make the first adoption deliberately uneventful. Agree one owner, model the existing settings, reject managed mutations and apply only the saved plan your team approved. Then confirm a clean plan and hand over the next change, not just the state file. If ownership, module fit or production controls remain unclear, Cloudline Consulting can help scope an infrastructure-as-code adoption assessment around one AWS service, with an ownership map, effort estimate and clear go/no-go criteria.