Terraform import existing resources into modules safely

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

24 October 2026 · 11 min read · Cloudline Consulting

Three engineers reviewing a laptop and a handwritten checklist beside an office whiteboard.
Photo: jurvetson (BY 2.0)

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.

In this article
  1. Choose the import workflow without confusing module support
  2. Establish ownership and a recovery route before importing
  3. Model the existing AWS queue in a reusable child module
  4. Make CI reject replacements before approving the saved plan
  5. Use data sources and moved blocks where import is wrong
  6. Hand over production with a phased effort and cost worksheet

Choose the import workflow without confusing module support

An engineer working on a laptop beside an open notebook.
Choose where the import belongs before touching state. Photo: tlaukkanen (BY 2.0)

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.

WorkflowModule supportState and review
CLI import into a module addressSupported independently of declarative import blocksWrites state immediately; no import plan to approve
Root-level declarative import targeting a child resourceAvailable from Terraform 1.5Plan and review before applying
Import block packaged inside a child moduleSupported by Terraform 1.16Plan 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.

Establish ownership and a recovery route before importing

Two engineers reviewing a diagram and notes on a whiteboard.
Agree who owns the object before adding another controller. Photo: juhansonin (BY 2.0)
PhaseAWS account and regionObjectCurrent controllerTarget workspace and addressProvider version and import identifierDependency ownersEvidenceEngineer hoursReviewerRecurring cost deltaAcceptance criterion
Own — illustrative123456789012 / eu-west-2Standard SQS queue: orders-prodManual console changes; platform teamprod / module.orders.aws_sqs_queue.thishashicorp/aws 6.0.0; https://sqs.eu-west-2.amazonaws.com/123456789012/orders-prodOrders producers; fulfilment consumers; IAM and KMS ownersQueue attributes; ownership inventory; owner sign-off; protected state snapshot8 totalPlatform lead£0 expected for import alone; backend costs separateSingle 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

Model the existing AWS queue in a reusable child module

An engineer comparing laptop output with handwritten notes.
The first module should describe production as it is. Photo: rickmassey1 (BY 2.0)

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.

Capture the queue before modelling it

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.

Keep configuration explicit and providers in the root

.
├── 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.

Make CI reject replacements before approving the saved plan

An engineer seated at a desk with two monitors.
Reject dangerous actions mechanically, then review the exact plan. Photo: DVIDSHUB (BY 2.0)

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.

Gate the saved plan

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.

Approve the artifact, not just the job

  • Review adopt.txt for the expected queue identity, account, region and settings. Reconcile its queue URL and ARN with the approved inventory.
  • Bind approval to the commit and plan artifact digest. Restrict apply credentials to the protected job.
  • Serialise workspace operations across planning, approval and apply. Re-plan and reapprove after intervening changes.
  • Keep plan binaries, JSON, text and state confidential: sensitive values can appear in plaintext.

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

Use data sources and moved blocks where import is wrong

Three colleagues discussing notes around a meeting table.
Referencing infrastructure and owning it are different decisions. Photo: Office Now (BY 2.0)
SituationRight mechanism
Unmanaged object whose lifecycle this team will ownImport into the intended resource address.
Existing dependency whose lifecycle stays elsewhereUse a data source or an agreed input/output contract.
Object already tracked at another address in this same stateUse a moved block, not import.

Read dependencies without adopting them

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.

Move addresses, not ownership between states

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.

Hand over production with a phased effort and cost worksheet

Two engineers reviewing a laptop together at an office desk.
Adoption is complete when the next change has an owner. Photo: University of Salford (BY 2.0)

Return to the worksheet: Terraform import without recreation is complete only when production evidence closes every row.

PhaseAcceptance evidence
OwnController agreement and tested state-recovery evidence.
MatchObserved queue settings represented in HCL.
GateExpected import; zero managed creates, updates or deletes.
Hand overPost-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.

Separate adoption effort from running costs

Start with eight engineer-hours. These additional planning allowances are not a fixed-price commitment:

Labour itemHours
Base adoption8
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.

BaselineProposed changeMonthly usage assumptionRegion / currencyRecurring deltaApproval owner
Existing queue and tooling billState adoption onlyUnchanged queue traffic; daily drift CIeu-west-2 / £No capacity increase; meter API, storage and CI overheadPlatform 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.

Final thoughts

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.

Frequently asked questions

Can Terraform import existing resources directly into a module?
Yes. Define the destination resource inside the child module and use its complete module-prefixed address for a CLI import or a root-level import block. You do not need to import into the root first. Terraform 1.16 additionally allows the import block itself to live inside the child module.
Which Terraform version supports import blocks inside child modules?
Terraform 1.16 supports declaring import blocks inside child modules, including evaluation within module instances. Earlier declarative imports could already target resources inside modules, but the import block belonged in the root configuration. Set an explicit Terraform version requirement and run a consistent selected version locally and in CI.
Does Terraform import recreate existing AWS resources?
Import binds an existing object to a Terraform resource address; it does not require recreating that object. However, the surrounding plan can also propose updates or replacement when configuration differs. Review a saved plan and reject managed mutations during adoption instead of treating a successful import as proof of safety.
How do I detect Terraform replacements in CI?
Generate JSON from the saved plan with terraform show -json and inspect resource_changes. Reject any managed resource whose change.actions contains delete, which catches deletion and either replacement order. For adoption, also reject creates and updates, and assert the expected import address and identifier before approving that same saved plan.
Should I use an import block, a data source or a moved block?
Use import when adopting an unmanaged object's lifecycle. Use a data source when you need to read infrastructure that another team or system continues to own. Use a moved block when an object already in the same Terraform state needs a different address, such as moving into a child module.

Sources and further reading

Found this useful? Share it or cite it: