Terraform Lesson 36 of 57

Importing Existing Infrastructure into Terraform, In Depth: import Blocks, Config Generation & Brownfield Adoption

Almost no one starts with Terraform. They start with a console, a free afternoon, and a deadline — and three years later there is a production estate of VPCs, databases, IAM roles, DNS records and load balancers that nobody can reproduce, nobody dares touch, and nobody has in code. This is brownfield infrastructure: real, running, business-critical, and entirely unmanaged. The day you decide to bring it under Terraform is the day you meet import — the mechanism that takes a resource that already exists in the cloud and writes it into Terraform’s state so that, from then on, Terraform manages it instead of fighting it.

Import is deceptively dangerous precisely because it feels harmless. It does not create anything; it does not (by itself) destroy anything. But get it slightly wrong — import an object against the wrong address, leave the configuration out of sync with reality, forget that a secret never came across — and the next terraform apply will happily “fix” the drift by rewriting or replacing a resource that was perfectly fine. The whole skill of brownfield adoption is making terraform plan show zero changes after the import, because a no-op plan is the only proof that your code now faithfully describes what is actually running.

This lesson is the complete tour of getting existing infrastructure into Terraform. It covers the original imperative terraform import CLI and every flag it takes; the modern declarative import {} block introduced in Terraform 1.5 — with its to/id arguments, its plan-time semantics, and the -generate-config-out flag that writes first-draft HCL for you (and the caveats HashiCorp attaches to that draft); the disciplined brownfield workflow of discover → write/generate config → import → plan-until-no-op → refactor; importing at scale with for_each import blocks and the bulk reverse-engineering tools (Terraformer, aztfexport, former2); the per-provider resource ID formats you will need; and the pitfalls — computed defaults, secrets that do not import, and dependency ordering — that turn a clean import into a destructive plan. OpenTofu behaves identically here (substitute tofu for terraform throughout); where there is any nuance I call it out.

This lesson is the import and adoption specialist. Its sibling — Refactoring Terraform Safely with moved, import, and removed Blocks — owns the refactoring side: moved for renames and module re-homing, removed for decommissioning, and the CI delete-gate. I will reference those where they interlock but will not re-derive them.

Learning objectives

After working through this lesson you will be able to:

Prerequisites & where this fits

You should be comfortable with the Terraform workflow (init / plan / apply), with resource addresses (aws_s3_bucket.logs, module.net.aws_subnet.private["a"]), and with how state binds a config address to a real cloud object — see Terraform State, In Depth for the state-file anatomy and the terraform state command surface that import writes into. This is an Advanced, State-track lesson in the Terraform Zero-to-Hero course; it follows the workspaces deep-dive and precedes the Terragrunt Stacks lesson. It maps to the HashiCorp Certified: Terraform Associate objectives on the import workflow and state management. Requires Terraform 1.5+ for import {} blocks and 1.5+ for -generate-config-out; OpenTofu 1.6+ supports both identically.

Core concepts: what import actually is

Terraform’s model has three layers: your configuration (the resource blocks describing desired state), the state file (Terraform’s record of which real objects it manages and what their last-known attributes were), and the real infrastructure (the actual objects in the cloud). A normal apply keeps all three in sync by creating real objects to match config and recording them in state.

Import bridges the gap when a real object already exists but state does not know about it. It does exactly one thing: it reads the live object via the provider’s API and writes a corresponding entry into state, bound to a resource address you choose. That is the whole operation. Critically:

The mental model to hold onto: after a correct import, terraform plan shows no changes. If the plan wants to change something, your configuration does not yet match what is really deployed, and you must edit the config until the diff disappears. If the plan wants to destroy and recreate (a -/+ “replacement”), you imported correctly but a force-new attribute in your config disagrees with reality — usually a missing name, a wrong availability_zone, or an attribute that cannot be changed in place. Either way, the plan is the oracle.

There are two ways to perform an import, and knowing when to reach for each is half the battle.

terraform import CLI (imperative) import {} block (declarative)
Introduced Terraform 0.7 (legacy) Terraform 1.5 (2023)
Form A command you run A block committed to config
Writes config? No — state only No by itself, but supports -generate-config-out
Plannable? No — mutates state immediately Yes — appears in terraform plan, applied by apply
Reviewable in a PR? No (it is a side-effecting command) Yes — it is code
Repeatable across workspaces? No (run per workspace by hand) Yes — same block, applied in each
Supports for_each/count? One resource per invocation Yes (but disables config generation)
Undo before commit Mutated state already; revert via state rm Just delete the block — nothing happened until apply
Status today Still supported; fine for one-offs/scripts Preferred for adoption you want reviewed

The modern default is the import {} block, because it makes import a plannable, reviewable, replayable change instead of a side effect typed at a terminal. The CLI command remains entirely valid and is often the quickest tool for a single ad-hoc import or inside a throwaway script. Both ultimately do the same thing to state.

The imperative terraform import CLI, every flag

The original command takes a resource address and a resource ID and writes the binding into state:

terraform import [options] ADDRESS ID
# Bind the existing EC2 instance i-0abc... to the address aws_instance.web
terraform import aws_instance.web i-0abcd1234ef567890

You must already have resource "aws_instance" "web" {} in your configuration for this to succeed (unless you pass -allow-missing-config, below). The command runs the provider’s import logic, reads the live attributes, and stores them in state under that address. It then prints a short summary and, importantly, does not print a plan — you run terraform plan next, yourself, to see whether config matches.

Every option terraform import accepts:

Flag What it does
-config=PATH Directory of Terraform config to load (defaults to the working directory). Use when the config lives elsewhere.
-input=true|false Whether to prompt for input (e.g. missing variables/provider config). Set -input=false in automation so it fails loudly instead of hanging.
-var 'name=value' Set an input variable — needed when your provider configuration depends on a variable (region, credentials) that has no default. Repeatable.
-var-file=FILE Load variables from a .tfvars/.tfvars.json file. terraform.tfvars and *.auto.tfvars are auto-loaded; others need this flag.
-provider=PROVIDER Deprecated. Override the provider configuration for the imported resource. Modern guidance: add a provider argument to the resource block instead (for aliased providers).
-state=PATH / -state-out=PATH Legacy local-state paths to read from / write to. Ignored when a remote backend is configured; do not use with remote backends.
-lock=true|false Whether to hold the state lock during the operation (default true). Leave on — import writes state and must not race a concurrent run.
-lock-timeout=DURATION How long to wait for the lock, e.g. -lock-timeout=60s, before giving up.
-no-color Disable colourised output (useful in CI logs).
-ignore-remote-version (HCP Terraform / cloud backend) Skip the check that your local Terraform version matches the remote workspace’s. Use with care.
-allow-missing-config Import even when no matching resource block exists in config. The object lands in state with no configuration — your very next plan will propose to destroy it (no config = should not exist). Almost always a mistake; it exists for edge tooling.

Two flags deserve emphasis. -var / -var-file matter because the provider must authenticate to read the object. If your provider "aws" block reads region = var.region with no default, a bare terraform import fails with a provider-configuration error until you supply the variable. -allow-missing-config is a trap for beginners: it lets the import succeed without a resource block, but a state entry with no config is an orphan, and the next plan deletes it. The correct order is always write the resource block first, then import.

The imperative command’s real limitations are inherent, not bugs:

These limitations are exactly what the import {} block was created to fix.

The declarative import {} block

Since Terraform 1.5 you can express an import as a block in configuration rather than a command. It has two required arguments and is processed during plan:

import {
  to = aws_instance.web
  id = "i-0abcd1234ef567890"
}

resource "aws_instance" "web" {
  ami           = "ami-0abc123"
  instance_type = "t3.micro"
  # ... attributes that match the live instance ...
}
Argument Required Meaning
to Yes The resource address the object should be bound to. May include an instance key for for_each/count resources, e.g. aws_instance.web["api"].
id Yes The import ID of the existing object, in the provider’s expected format (see the ID table later). Can be any expression that resolves to a string, including a reference to a local or var.
for_each No A map or set to import a fleet in one block (see the scale section). When present, to/id reference each.key/each.value.
provider No Override the provider configuration (e.g. an aliased provider = aws.eu) for this import, matching the provider on the target resource.

How the block behaves — the part that makes it superior:

The ideal plan after writing both the import block and a matching resource block reads:

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

If you instead see 1 to import, 0 to add, 3 to change, the import is correct but three attributes in your config disagree with the live object — edit the config until those changes vanish. If you see 1 to add and 1 to import for what should be the same resource, your to key does not line up with what you think; fix it before applying.

A subtle but important rule: the import block must reference a resource that exists in configuration (the to address must correspond to a real resource block — unless you are generating config in the same run, below). You cannot import into thin air with the declarative block the way -allow-missing-config allows with the CLI.

Generating configuration with -generate-config-out

Hand-writing the resource block for a complex imported object — every argument, every nested block, matching the live state exactly so the plan is a no-op — is tedious and error-prone. Terraform 1.5 added automatic config generation: pair an import block with the -generate-config-out flag on plan, and Terraform reads the live object and writes first-draft HCL for you.

Write only the import block (no resource block yet):

import {
  to = aws_iam_role.ci_deployer
  id = "ci-deployer"
}

Then generate config to a file that does not yet exist — Terraform refuses to overwrite an existing file, to protect you from clobbering hand-written code:

terraform plan -generate-config-out=generated.tf

Terraform reads the live ci-deployer role, writes a matching resource "aws_iam_role" "ci_deployer" { ... } into generated.tf, and shows a plan that imports rather than creates:

  # aws_iam_role.ci_deployer will be imported
  resource "aws_iam_role" "ci_deployer" {
      name = "ci-deployer"
      ...
  }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

This is enormously useful, but the generated HCL is a starting point, not a finished artefact — HashiCorp documents this explicitly and the format can change between minor versions. Treat it as a first draft and clean it up:

Generated-config limitation What you must do
Conflicting arguments can be emitted The generator may write mutually exclusive attributes (the canonical AWS example is ipv6_address_count and ipv6_addresses on aws_instance). The plan errors until you delete one.
Literals instead of references Every value is inlined. Replace hard-coded IDs/ARNs/CIDRs with var.*, local.*, module.* and data.* references so the config is DRY and portable.
Sensitive values are not populated Secrets and other sensitive attributes are omitted (or left blank). You must fill them in by hand — Terraform will not read a password out of the cloud for you.
No modules / no for_each Generation produces flat top-level resources. It does not place resources into modules, and it does not work with for_each/count (see below). Refactor into modules afterwards.
Provider-default noise Some attributes are emitted at their provider default even though you would normally omit them; trim for readability once the plan is a no-op.
null / unsupported attributes Occasionally an attribute is emitted that the provider rejects on plan; remove or correct it.

The hard constraint to memorise for exams and for real work: -generate-config-out does not work with for_each or count — neither on the import block nor on the target resource. Attempting it fails with “the given import block is not compatible with config generation.” The practical pattern is therefore: generate config for one representative resource without for_each, then refactor that HCL into a for_each resource and switch to a for_each import block (which you write by hand).

OpenTofu parity: OpenTofu supports import {} blocks and -generate-config-out from 1.6 with the same semantics. The one OpenTofu-specific caveat: if you use OpenTofu’s state/plan encryption, config generation interacts with the encrypted plan the same way any plan output does — generation itself is unaffected, but treat the generated file (which may inline non-sensitive but identifying values) with the same review discipline.

After generation: move the cleaned-up resource out of generated.tf into its proper file, replace literals with references, fill in any sensitive values, run terraform plan again and drive it to zero changes, then terraform apply to perform the import. Finally, delete the import block once applied everywhere.

The brownfield adoption workflow, step by step

Adopting an estate is a repeatable loop. Run it per logical group of resources (one service, one module’s worth) rather than trying to swallow the whole account at once.

1. Discover. Inventory what exists and capture the import ID of each object. Use the cloud’s own tools — aws resourcegroupstaggingapi get-resources, az resource list, gcloud asset search-all-resources — or read IDs straight from the console. For anything non-trivial, lean on a bulk tool (next section) to enumerate and even pre-generate.

2. Scaffold the configuration. Create a fresh working directory with terraform { required_providers { ... } } and the provider block(s) configured with the right region/account/credentials. Run terraform init. Get authentication working before you try to import — import reads the live object through the provider, so the provider must be able to talk to the cloud.

3. Write or generate config. Either hand-write the resource block(s), or write import blocks and run terraform plan -generate-config-out=generated.tf to draft them. For fleets, generate one and refactor to for_each.

4. Import. Add the import { to id } block(s) (or, for one-offs, run terraform import ADDR ID). Run terraform plan and confirm the summary reads N to import, 0 to add, … — the 0 to add is your proof that you matched existing objects. Then terraform apply.

5. Plan until no-op. Run terraform plan again. It must report No changes. Your infrastructure matches the configuration. This is the entire objective. If it shows changes, edit the config to match reality and re-plan. If it shows a replacement (-/+), a force-new attribute disagrees — fix it before any apply, because applying would destroy the live resource.

6. Refactor into shape. Now that the resource is faithfully captured and the plan is clean, restructure: pull resources into modules, parameterise with variables, add for_each, replace literals with references. This is refactoring, and you do it with moved blocks so renames and module re-homing never trigger destroy/create — see the refactoring lesson for that side of the work. Each refactor step should still plan to zero changes.

7. Garbage-collect. Once applied in every workspace, delete the now-satisfied import blocks in a follow-up commit. Confirm that commit also plans to zero changes.

The cardinal rule throughout: never apply an import workflow whose plan you have not driven to no-op (or to a deliberate, reviewed change). A surprise change or replace in an import plan is the failure mode that takes down production.

Importing at scale: for_each blocks and bulk tools

One resource at a time is fine for a handful of objects. A real brownfield estate has hundreds. There are two complementary approaches: native for_each import blocks for known, structured fleets, and bulk reverse-engineering tools for whole-estate discovery.

Native scale: for_each import blocks

An import block accepts for_each, so a map of “key → import ID” imports an entire fleet in a single apply, bound to a single for_each resource:

locals {
  legacy_log_groups = {
    api       = "/acme/api"
    worker    = "/acme/worker"
    scheduler = "/acme/scheduler"
  }
}

resource "aws_cloudwatch_log_group" "this" {
  for_each          = local.legacy_log_groups
  name              = each.value
  retention_in_days = 30
}

import {
  for_each = local.legacy_log_groups
  to       = aws_cloudwatch_log_group.this[each.key]
  id       = each.value
}

The to uses the instance key so each map entry maps to exactly one resource instance, and id is that object’s import identifier. As covered above, config generation is unavailable with for_each — write the resource HCL by hand (or generate one representative resource, then refactor). Review the plan line by line: it must read 3 to import, 0 to add, 0 to destroy. A typo in an id produces a loud import failure (safe); a typo in a to key can leave a real object unmanaged while a new one is planned (quiet and dangerous). The deeper mechanics of for_each import blocks — and the interplay with moved/removed — are also discussed in the refactoring lesson; here the point is that this is the native path for fleets you already understand and want to express cleanly.

Bulk reverse-engineering tools

For a whole account you do not yet understand, third-party tools enumerate live resources and emit Terraform config and state (or import blocks) automatically. They are the fastest way to bootstrap, but their output is machine-generated and needs the same clean-up discipline as -generate-config-out.

Tool Scope Output Notes
Terraformer (GoogleCloudPlatform/terraformer) Multi-cloud (AWS, GCP, Azure, and many more providers) Generates both .tf config and terraform.tfstate (it imports as it goes) The broadest tool. terraformer import aws --resources=vpc,subnet --regions=eu-west-1. Pin provider versions; output uses older HCL idioms you will modernise.
aztfexport (Azure/aztfexport, formerly aztfy) Azure only (Microsoft-maintained) Generates config and imports into state; can target a resource, a resource group, or an ARG (Azure Resource Graph) query The supported Azure path. Modes: single resource, resource-group, and query. Renamed from aztfy to aztfexport; older docs may say aztfy.
former2 (community, by Ian Mckay) AWS only Generates Terraform and CloudFormation/CDK/etc.; scans via the browser using your credentials Runs as a web app (or CLI) that reads your AWS account and emits IaC. Generates config; you wire up state via the produced import statements.

How to use them sanely:

The decision rule: use for_each import blocks when you know the fleet and want clean, reviewed code; use Terraformer / aztfexport / former2 when you face a large, unfamiliar estate and need a starting inventory fast — then converge both onto a hand-refined, no-op configuration.

Resource ID formats: what to put in id

The single most common import failure is an ID in the wrong format. The id is not always the obvious name — it is whatever the provider’s import logic expects, which varies per resource and is documented under the “Import” section at the bottom of each resource’s provider-registry page. Always check that page. Common patterns:

Resource Import ID format Example
aws_instance Instance ID i-0abcd1234ef567890
aws_s3_bucket Bucket name acme-prod-logs
aws_iam_role Role name ci-deployer
aws_iam_role_policy role_name:policy_name (composite) ci-deployer:s3-access
aws_route53_record zoneID_name_type[_set-id] (underscore-joined) Z123_www.example.com_A
aws_security_group_rule sgID_type_protocol_from_to_source sg-0abc_ingress_tcp_443_443_0.0.0.0/0
aws_subnet / aws_vpc Resource ID subnet-0abc… / vpc-0abc…
aws_db_instance DB identifier acme-prod-db
azurerm_* (most) Full resource ID path /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Network/virtualNetworks/<name>
google_compute_instance project/zone/name (or full self-link) my-proj/us-central1-a/web-1
google_storage_bucket Bucket name (or project/name) acme-prod-assets
kubernetes_* namespace/name (namespaced) or name (cluster-scoped) default/my-config
local_file The file path ./content.txt

Three rules that save hours:

  1. Composite IDs use a provider-specific separator (often :, /, or _). Route 53 records and security-group rules are notorious; copy the exact format from the docs.
  2. Azure IDs are the full ARM path, not the short name — aztfexport exists partly because hand-assembling these is painful.
  3. GCP IDs frequently need the project and location prefix; the bare name often is not enough.

When in doubt, the provider docs’ Import block is authoritative — it even gives a copy-paste import {} snippet for newer providers.

Architecture at a glance

The brownfield import workflow: existing cloud objects are discovered and their IDs captured; an import block (to + id) plus a written or -generate-config-out-generated resource block are added to configuration; terraform plan shows N-to-import with zero-to-add; apply writes the objects into state under a lock; a second plan must reach no-op before the configuration is refactored into modules with moved blocks, after which the satisfied import blocks are deleted

The diagram traces a single object from “exists in the cloud, unknown to Terraform” through discovery, the import {} + resource pairing (hand-written or generated), the import plan, apply writing it into state, the all-important no-op confirmation plan, and finally refactoring and block clean-up — the loop you repeat for every resource in the estate.

Hands-on lab

This lab teaches the entire import workflow with zero cloud spend and no account by importing a real on-disk file via the local provider’s local_file resource. The file exists outside Terraform first (created with a normal shell command), exactly modelling a brownfield object Terraform does not yet know about. (If you prefer a cloud-shaped target, the same steps work with a local Docker container via the kreuzwerker/docker provider — see the variant at the end.)

1. Set up a fresh working directory and create the “brownfield” object.

mkdir tf-import-lab && cd tf-import-lab
printf 'hello from a file Terraform did not create\n' > content.txt

2. Scaffold the provider and init. Create providers.tf:

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

provider "local" {}
terraform init

Expected: Terraform has been successfully initialized!

3. Write only an import block and generate config. Create import.tf:

import {
  to = local_file.content
  id = "content.txt"
}
terraform plan -generate-config-out=generated.tf

Expected: Terraform writes generated.tf containing a resource "local_file" "content" and prints a plan ending with Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. Open generated.tf — it will contain the file’s content and filename. (Note: local_file stores content; for a real cloud resource, sensitive values would be omitted here.)

4. Tidy the generated config and apply the import. The generator may emit attributes you would normally omit (e.g. permission attributes at their default). Leave them for now to keep the plan a no-op. Then:

terraform apply

Type yes. Expected: Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

5. Validation — prove the no-op and inspect state.

terraform plan
terraform state list
terraform state show local_file.content

Expected: terraform plan reports No changes. Your infrastructure matches the configuration. — the win condition. state list shows local_file.content, and state show displays the imported attributes including the file path. The object Terraform never created is now under Terraform management.

6. (Optional) Refactor and garbage-collect. Move the resource from generated.tf into a sensibly named file, then delete the import block in import.tf (its job is done). Re-run terraform plan — it must still report No changes, proving the import block was safely removable.

Cleanup.

terraform destroy   # removes the now-managed file; type yes
cd .. && rm -rf tf-import-lab

destroy will delete content.txt because Terraform now manages it — which is the point: it is a fully managed resource. (If you wanted to keep the file but stop managing it, you would use a removed { lifecycle { destroy = false } } block — see the refactoring lesson.)

Cost note. Zero. The local provider creates nothing in any cloud — no account, no spend, nothing to leak. This is the safest possible way to practise the full import → generate → plan-to-no-op → refactor loop before you ever point it at production.

Docker variant (still free, more cloud-like). Add the kreuzwerker/docker provider, docker run -d --name nginx-legacy nginx to create a container outside Terraform, then import { to = docker_container.web; id = "<container-id>" } and follow the identical steps. It exercises a stateful, API-backed resource with computed attributes — a closer rehearsal for real cloud imports — at no cost if Docker is already installed.

Common mistakes & troubleshooting

Symptom Cause Fix
Error: resource address ... does not exist in the configuration (CLI import) No matching resource block before running terraform import Write the resource block first, then import. Do not reach for -allow-missing-config.
Plan after import shows 1 to add for the same resource The to address/key does not match what you intended — Terraform sees a new resource beside the imported one Fix the to address (especially for_each keys) so plan shows 0 to add.
Plan after import shows a -/+ replacement A force-new attribute in config disagrees with reality (wrong name, availability_zone, immutable field) Edit config to match the live value; never apply a replacement on an imported production resource.
Error: ... import is not compatible with config generation Used -generate-config-out together with for_each/count Generate config for one non-for_each resource, then refactor to for_each and write the import block by hand.
Error: ... file already exists from -generate-config-out Target file already present (Terraform never overwrites) Point at a new filename, or delete/rename the existing file first.
Import fails: Cannot import non-existent remote object / ... not found Wrong ID format for that resource (composite separators, missing project/zone, ARM path) Copy the exact format from the resource’s provider-docs Import section.
Provider auth error during import (no valid credential, region unset) The provider cannot reach the cloud to read the object Configure credentials/region; pass -var/-var-file if provider config depends on variables.
After import, a password/secret attribute keeps showing a diff Secrets are not imported; config has a placeholder or empty value Set the sensitive attribute explicitly (from a vault/variable), or mark it ignore_changes if the provider cannot read it back.
Bulk-tool output won’t plan to no-op Deprecated arguments / provider-version drift / inlined literals init against your pinned versions, fix deprecated args, replace literals with references, re-plan.

Best practices

Security notes

Interview & exam questions

  1. What exactly does terraform import do, and what does it not do? It reads an existing real object and writes a corresponding entry into state, bound to a resource address. It does not create infrastructure, does not (by itself) write configuration, and does not modify the real object. After import you must reconcile config until plan is a no-op.

  2. Imperative terraform import vs the declarative import {} block — give three differences. The block is plannable (appears in plan), reviewable (it is committed code), and replayable across workspaces; it also supports for_each. The CLI mutates state immediately, is per-invocation, and is not reviewable. (Both write state only by default.)

  3. What does -generate-config-out do and what are its limitations? Paired with an import block on terraform plan, it writes first-draft HCL for the imported resource to a new file. Limitations: it can emit conflicting attributes, inlines literals instead of references, omits sensitive values, produces no modules, and does not work with for_each/count. The output is a starting point to clean up, and the format may change between versions.

  4. You add an import block and a matching resource block, and the plan says 1 to import, 1 to add. What is wrong? The to address does not match the object you think — Terraform sees a new resource (the 1 to add) alongside the import. Fix the to address/key so the plan shows 0 to add.

  5. After importing, terraform plan proposes a -/+ replacement of a production database. Do you apply? No. A replacement means a force-new attribute in config disagrees with the live object. Applying would destroy the real database. Edit the config to match reality and re-plan to a no-op first.

  6. Why does importing into a config with no matching resource block (-allow-missing-config) almost always end badly? The object lands in state with no configuration; the next plan reads “managed object not in config” and proposes to destroy it. Always write the resource block first.

  7. How do you import a fleet of fifty similar resources, and what can you not do while doing it? Use a for_each import block driven by a map of key→ID, bound to a for_each resource. You cannot use -generate-config-out with for_each — generate one representative resource, then refactor to for_each and write the import block by hand.

  8. Name the major bulk reverse-engineering tools and their scope. Terraformer (multi-cloud; generates config and state), aztfexport (Azure-only, Microsoft-maintained, formerly aztfy; config + state), and former2 (AWS-only, community; scans the account and emits Terraform/other IaC).

  9. Where do you find the correct id format for a resource, and why does it matter? In the Import section at the bottom of the resource’s provider-registry docs. It matters because formats vary — composite IDs (Route 53 zoneID_name_type, IAM role:policy), full ARM paths for Azure, and project/zone/name for many GCP resources — and a wrong format fails the import.

  10. What is the goal state after an import, and how do you prove it? A no-op plan: terraform plan reports No changes. Your infrastructure matches the configuration. Prove it by running plan (zero changes), terraform state list (new address present), and terraform state show ADDR (live attributes captured).

  11. How does import relate to moved and removed? Import adopts an object into state; moved re-addresses an object already in state (renames/module moves) without destroy/create; removed forgets or destroys a managed object. You import to bring brownfield in, then moved to refactor it, and removed to decommission — see the refactoring lesson for the latter two.

  12. Why prefer import {} blocks for production adoption over running terraform import on a laptop? The block is committed, reviewed in a PR, applied identically in every workspace, and plannable before it runs — eliminating the “ran it in dev, forgot prod” divergence and the un-auditable side effect of an imperative command.

Quick check

  1. Does terraform import create infrastructure?
  2. Which flag generates first-draft HCL during an import, and what is its single biggest restriction?
  3. In an import plan, what does 0 to add prove?
  4. Which Azure-specific bulk tool replaced aztfy?
  5. After a correct brownfield import, what must terraform plan report?

Answers

  1. No. It only writes an entry into state for an object that already exists; it never creates the object.
  2. -generate-config-out=FILE (on terraform plan). Its biggest restriction is that it does not work with for_each/count (also: it inlines literals, omits secrets, can emit conflicting attributes).
  3. That Terraform matched an existing object rather than planning to stand up a new one beside it — i.e. the to address is correct.
  4. aztfexport (Azure/aztfexport), the Microsoft-maintained successor to aztfy.
  5. No changes. Your infrastructure matches the configuration. — a no-op plan is the proof the import and config are faithful.

Exercise

Take a resource you created by hand in any free-tier or local environment (a local_file, a Docker container, an AWS S3 bucket on a sandbox account, or an Azure resource group). Then:

  1. Scaffold a fresh Terraform directory with the right provider, init, and confirm auth.
  2. Write only an import {} block targeting the object, and run terraform plan -generate-config-out=generated.tf. Inspect the generated HCL.
  3. Clean the generated config: remove any conflicting attribute, replace at least one literal with a var.* or local.* reference, and (if relevant) note which sensitive attribute was omitted.
  4. apply the import, then drive terraform plan to No changes.
  5. Refactor the resource’s address using a moved block (rename it to something meaningful) and confirm the plan stays at zero changes.
  6. Delete the satisfied import block and confirm a final no-op plan. Write two sentences on what you would do differently importing this resource at scale (fleet of 50).

Certification mapping

Glossary

Next steps

TerraformImportBrownfieldConfig GenerationOpenTofuState
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments