Terraform Import: Import Existing Infrastructure

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
kreuzwerker/docker 3.9.0
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform and Docker — Terraform lab environment on Ubuntu
Privilege Normal user (Docker socket access required)
Scope Bring existing infrastructure under Terraform management — the terraform import CLI, declarative import blocks, generated configuration with -generate-config-out, for_each imports, module addresses, resource IDs and identity, plan verification, and common import failures. Does not cover a full state mv tutorial, moved block depth, or cloud-provider import catalogs.
Related guides terraform state commands
Terraform state explained
Refactor with moved and removed blocks
Terraform drift detection
Terraform Associate certification course

Someone created a volume, a database, or a network by hand months ago. It works, nobody wants to recreate it, and Terraform has no idea it exists. Import is how you close that gap: you tell Terraform which real object belongs at which resource address, and Terraform writes that binding into state.

text
Existing resource
      │ import
Terraform state
Terraform configuration

One expectation to set before you start: import does not hand you a finished, reusable Terraform configuration. It records reality in state. Making configuration match that reality is still your job, and the plan you run afterwards is what tells you how close you got. This lesson uses Docker volumes and networks created outside Terraform so every object is real and disposable, under ~/terraform-labs/terraform-import/.


How Terraform import works

Every import connects three separate things. Keeping them straight explains almost every import error you will hit later.

text
Real object        the thing that already exists in the provider (a Docker volume)
Resource address   where it will live in your configuration (docker_volume.data)
Terraform state    the record binding that address to that object

Import creates the state binding between the resource address and the existing remote object. It does not create the remote object, and it does not write configuration files for you unless you explicitly ask for generated config.

Start by creating something outside Terraform. This volume is made with the Docker CLI, so Terraform has no record of it anywhere:

bash
docker volume create tf-import-lab-data

Sample output:

output
tf-import-lab-data

Confirm the object exists on its own terms before Terraform is involved. Note the creation time and mount point — you will compare them after the import:

bash
docker volume inspect tf-import-lab-data --format 'Name={{.Name}} Created={{.CreatedAt}} Mountpoint={{.Mountpoint}}'

Sample output:

output
Name=tf-import-lab-data Created=2026-08-12T10:50:49+05:30 Mountpoint=/var/lib/docker/volumes/tf-import-lab-data/_data

That is the object Terraform must adopt without recreating it.


Import a resource with terraform import

The CLI command is the original import workflow and still the fastest one for a single resource. It needs two arguments: a resource address that already exists in configuration, and a provider-specific ID.

Create the working directory for this first exercise:

bash
mkdir -p ~/terraform-labs/terraform-import/cli

Move into it, because everything in this section runs from that directory:

bash
cd ~/terraform-labs/terraform-import/cli

Declare the provider in versions.tf so Terraform knows which plugin owns docker_volume:

hcl
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

Now write the destination resource block in main.tf. This step is mandatory — terraform import refuses to run against an address that does not exist in configuration:

hcl
resource "docker_volume" "data" {
  name = "tf-import-lab-data"
}

Install the provider plugin before any state operation:

bash
terraform init

Ask Terraform what it currently tracks. On a fresh directory the answer is nothing at all:

bash
terraform state list

Sample output:

output
No state file was found!

State management commands require a state file. Run this command
in a directory where Terraform has been run or use the -state flag
to point the command to a specific state location.

That empty state is the whole problem. Because Terraform has no record of the volume, a plan proposes to create one:

bash
terraform plan

Sample output:

output
# docker_volume.data will be created
  + resource "docker_volume" "data" {
      + driver     = (known after apply)
      + id         = (known after apply)
      + mountpoint = (known after apply)
      + name       = "tf-import-lab-data"
    }

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

Applying that plan would fail or, on other providers, create a duplicate object with a slightly different name. Import instead. The address comes first, the provider ID second:

bash
terraform import docker_volume.data tf-import-lab-data

Sample output:

output
docker_volume.data: Importing from ID "tf-import-lab-data"...
docker_volume.data: Import prepared!
  Prepared docker_volume for import
docker_volume.data: Refreshing state... [id=tf-import-lab-data]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

Terraform read the object through the provider and wrote it into state. Confirm the address now exists:

bash
terraform state list

Sample output:

output
docker_volume.data

Read the recorded attributes to see what Terraform actually captured, not what you wrote in main.tf:

bash
terraform state show docker_volume.data

Sample output:

output
# docker_volume.data:
resource "docker_volume" "data" {
    driver      = "local"
    driver_opts = {}
    id          = "tf-import-lab-data"
    mountpoint  = "/var/lib/docker/volumes/tf-import-lab-data/_data"
    name        = "tf-import-lab-data"
}

The mount point matches the docker volume inspect output from before the import, which proves Terraform adopted the same object rather than creating a new one.

The next command matters more than the import itself. A successful import says state was written; it says nothing about whether your configuration describes that object correctly:

bash
terraform plan

Sample output:

output
docker_volume.data: Refreshing state... [id=tf-import-lab-data]

No changes. Your infrastructure matches the configuration.

An empty plan is the goal. It means the resource block and the imported object agree, so the next real apply will not surprise anyone.


Import existing resources with an import block

The CLI command writes state the moment you press Enter, which makes it awkward to review. An import block moves the same operation into configuration: it shows up in the plan, goes through code review like any other change, and applies with terraform apply.

Create a second object outside Terraform — this time a network, which uses a different kind of ID:

bash
docker network create tf-import-lab-net

Sample output:

output
f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb

Docker printed the network ID rather than the name. Capture it into a shell variable so you do not have to retype the hash:

bash
NET_ID=$(docker network inspect tf-import-lab-net --format '{{.Id}}')

Set up a separate working directory for the declarative workflow:

bash
mkdir -p ~/terraform-labs/terraform-import/block

Switch into it before writing configuration:

bash
cd ~/terraform-labs/terraform-import/block

Use the same versions.tf provider requirements as the previous directory, then declare the destination resource in main.tf:

hcl
resource "docker_network" "lab" {
  name = "tf-import-lab-net"
}

Add the import instruction in import.tf. The to argument is the resource address; id is the provider ID you captured above:

hcl
import {
  to = docker_network.lab
  id = "f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb"
}

Initialize this directory so the Docker provider is available here too:

bash
terraform init

Plan first. Unlike the CLI command, nothing has been written yet — this is a preview you can paste into a review:

bash
terraform plan

Sample output:

output
docker_network.lab: Preparing import... [id=f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb]
docker_network.lab: Refreshing state... [id=f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb]

Terraform will perform the following actions:

  # docker_network.lab will be imported
    resource "docker_network" "lab" {
        driver       = "bridge"
        id           = "f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb"
        ipam_driver  = "default"
        name         = "tf-import-lab-net"
        scope        = "local"
    }

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

1 to import with no adds, changes, or destroys is exactly what you want to see. Apply it to write state:

bash
terraform apply -auto-approve

Sample output:

output
docker_network.lab: Importing... [id=f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb]
docker_network.lab: Import complete [id=f180c7b461a30f8ea22718515422e4baeaad438c65f310db28033961d3115acb]

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

An import block is idempotent. Once the resource is imported and stays in state, later plans do not propose the import again, so leaving the block in place costs you nothing. You can remove it afterwards, but HashiCorp recommends keeping it when you want a version-controlled record of how the resource first came under Terraform management.

Rename the block away to prove that ongoing management does not depend on it:

bash
mv import.tf import.tf.done

Terraform tracks the network through state rather than through the block, so the plan stays empty:

bash
terraform plan

Sample output:

output
No changes. Your infrastructure matches the configuration.

Neither workflow is obsolete. Use the table below to pick between them:

Workflow Behaviour Best for
terraform import ADDRESS ID Imperative CLI state operation, applied immediately One-off adoption, quick lab fixes, scripted repairs
import block Declarative, planned and applied like any change Reviewable pull requests, batches, generated configuration

Both end with the same state entry. The difference is who gets to see the operation before it happens. An import block also accepts the provider meta-argument, which matters when the object lives behind an aliased provider configuration such as provider = aws.east.


Generate Terraform configuration during import

Writing the destination resource block by hand is tedious for a resource with many attributes. When you point an import block at an address that has no resource block, Terraform can write a first draft for you.

Create the target object with a custom subnet and a label, so the generated file has something interesting in it:

bash
docker network create --subnet 172.31.7.0/24 --label owner=platform-team tf-import-lab-gen

Sample output:

output
68ac9ae30875494ff0693550d3e9a8bc05837adf9a4eec64897b21c89a735d7b

Make a directory for the generation exercise:

bash
mkdir -p ~/terraform-labs/terraform-import/generate

Work from inside it:

bash
cd ~/terraform-labs/terraform-import/generate

Copy the same versions.tf provider block here. Terraform needs a provider requirement even though no resource block exists yet — without one it cannot tell which plugin should describe the object. Then write import.tf with the import block alone:

hcl
import {
  to = docker_network.generated
  id = "68ac9ae30875494ff0693550d3e9a8bc05837adf9a4eec64897b21c89a735d7b"
}

Initialize before planning, as usual:

bash
terraform init

Now plan with -generate-config-out and a file path that does not exist yet:

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

Sample output:

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

Warning: Config generation is experimental

Generating configuration during import is currently experimental, and the
generated configuration format may change in future versions.

Terraform has generated configuration and written it to generated.tf. Please
review the configuration and edit it as necessary before adding it to version
control.

Terraform is explicit that this is experimental and that you are expected to edit the result. Read the file it wrote:

bash
cat generated.tf

Sample output:

output
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "68ac9ae30875494ff0693550d3e9a8bc05837adf9a4eec64897b21c89a735d7b"
resource "docker_network" "generated" {
  attachable   = false
  driver       = "bridge"
  ingress      = false
  internal     = false
  ipam_driver  = "default"
  ipam_options = {}
  ipv6         = false
  name         = "tf-import-lab-gen"
  options      = {}
  ipam_config {
    aux_address = {}
    gateway     = "172.31.7.1"
    ip_range    = null
    subnet      = "172.31.7.0/24"
  }
  labels {
    label = "owner"
    value = "platform-team"
  }
}

This is a starting point, not a finished module. Terraform asked the provider for a value for every writable attribute, so the file carries defaults (attachable = false), empty placeholders (ipam_options = {}, aux_address = {}), and explicit nulls you would never type yourself. Before this goes anywhere near version control, review it for:

  • Default and empty attributes that add noise without changing behaviour
  • Provider-computed values that belong in the provider, not in your resource block
  • Names and labels that should come from variables or locals instead of hardcoded strings
  • lifecycle rules the original object relied on, which generation cannot infer
  • Secrets or credentials that must not be committed in plain text
  • Whether one flat resource block is the right home, or the resource belongs in a module

Run the formatter as the first cleanup step so indentation matches the rest of your code:

bash
terraform fmt generated.tf

The command prints nothing when the file is already canonical, which was the case here. Now edit it down to the attributes you actually want to manage — for this network that is the name, driver, subnet, and label:

hcl
resource "docker_network" "generated" {
  name   = "tf-import-lab-gen"
  driver = "bridge"

  ipam_config {
    subnet  = "172.31.7.0/24"
    gateway = "172.31.7.1"
  }

  labels {
    label = "owner"
    value = "platform-team"
  }
}

Prove the trimmed version still describes the same object by applying the import:

bash
terraform apply -auto-approve

Sample output:

output
docker_network.generated: Importing... [id=68ac9ae30875494ff0693550d3e9a8bc05837adf9a4eec64897b21c89a735d7b]
docker_network.generated: Import complete [id=68ac9ae30875494ff0693550d3e9a8bc05837adf9a4eec64897b21c89a735d7b]

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

Then run one more plan, because a trimmed configuration is only correct if Terraform agrees:

bash
terraform plan

Sample output:

output
No changes. Your infrastructure matches the configuration.

Dropping half the generated attributes cost nothing here because those values were provider defaults. On a larger resource some removals do change the plan, which is precisely why the review step exists.

IMPORTANT
Do not treat generation as a one-command adoption pipeline. Read the file, remove what you do not intend to manage, and re-plan before you commit. Terraform's own output calls the format experimental and subject to change.

Import multiple resources with for_each

Import blocks accept for_each, so one block can adopt a whole set of objects that share a resource definition. Create two more volumes outside Terraform to import together:

bash
docker volume create tf-import-lab-web

Sample output:

output
tf-import-lab-web

Create the second one the same way:

bash
docker volume create tf-import-lab-api

Sample output:

output
tf-import-lab-api

Make a directory for the batch import:

bash
mkdir -p ~/terraform-labs/terraform-import/foreach

Change into it before writing the configuration:

bash
cd ~/terraform-labs/terraform-import/foreach

Define the same map twice — once to build the resource instances, once to drive the import. Put both the locals block and the resource in main.tf:

hcl
locals {
  app_volumes = {
    web = "tf-import-lab-web"
    api = "tf-import-lab-api"
  }
}

resource "docker_volume" "app" {
  for_each = local.app_volumes

  name = each.value
}

The import block iterates the same map. each.key selects the instance address and each.value supplies the provider ID:

hcl
import {
  for_each = local.app_volumes

  to = docker_volume.app[each.key]
  id = each.value
}

Initialize the directory before planning:

bash
terraform init

Plan to see both instances lined up for import in one operation:

bash
terraform plan

Sample output:

output
# docker_volume.app["api"] will be imported
    resource "docker_volume" "app" {
        id   = "tf-import-lab-api"
        name = "tf-import-lab-api"
    }

  # docker_volume.app["web"] will be imported
    resource "docker_volume" "app" {
        id   = "tf-import-lab-web"
        name = "tf-import-lab-web"
    }

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

Apply to write both state entries:

bash
terraform apply -auto-approve

Sample output:

output
docker_volume.app["web"]: Importing... [id=tf-import-lab-web]
docker_volume.app["web"]: Import complete [id=tf-import-lab-web]
docker_volume.app["api"]: Importing... [id=tf-import-lab-api]
docker_volume.app["api"]: Import complete [id=tf-import-lab-api]

Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.

Check the resulting addresses — each map key became an instance key:

bash
terraform state list

Sample output:

output
docker_volume.app["api"]
docker_volume.app["web"]

Quote those addresses in the shell, because unquoted brackets are glob characters:

bash
terraform state show 'docker_volume.app["web"]'

Sample output:

output
# docker_volume.app["web"]:
resource "docker_volume" "app" {
    driver      = "local"
    id          = "tf-import-lab-web"
    mountpoint  = "/var/lib/docker/volumes/tf-import-lab-web/_data"
    name        = "tf-import-lab-web"
}

Keep batches small and readable. A map of two to twenty objects is comfortable; hundreds of hand-written IDs usually means you want a discovery workflow instead.

Bulk search and import in newer Terraform

Recent Terraform releases add a query workflow that discovers unmanaged objects instead of making you list their IDs. You write list blocks in .tfquery.hcl files and run terraform query, which can emit ready-made import and resource blocks:

bash
terraform query -help

Sample output:

output
Terraform will search for .tfquery.hcl files within the current configuration.
  Terraform will then use the configured providers to query the remote
  infrastructure for resources that match the defined list blocks.

  -generate-config-out=path  Instructs Terraform to generate import and resource
                             blocks for any found results.
NOTE
Search and import is beyond Terraform 1.12 and outside the Terraform Associate (003/004) objectives. It also depends on providers implementing list resources. Know that it exists; study the import block and terraform import for the exam.

Import resources into modules

Resources inside a module have longer addresses, and the module path is part of the address you import to. Create a directory for the module exercise:

bash
mkdir -p ~/terraform-labs/terraform-import/module/modules/application

Move to the root of that configuration:

bash
cd ~/terraform-labs/terraform-import/module

Create one more object outside Terraform for the module to adopt:

bash
docker volume create tf-import-lab-module

Sample output:

output
tf-import-lab-module

The child module needs its own provider requirement. A child inherits the provider configuration from the root, but it does not inherit the source address or the version constraint, and kreuzwerker/docker is a community provider rather than a HashiCorp one. Without this file Terraform assumes the docker_ prefix means hashicorp/docker and init fails on a registry lookup. Save it as modules/application/terraform.tf:

hcl
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 3.0"
    }
  }
}

Keep the variable and the resource in modules/application/main.tf:

hcl
variable "volume_name" {
  type = string
}

resource "docker_volume" "data" {
  name = var.volume_name
}

Call the module from the root main.tf:

hcl
module "application" {
  source = "./modules/application"

  volume_name = "tf-import-lab-module"
}

Run init so Terraform loads the local module and resolves both provider requirements:

bash
terraform init

Sample output:

output
Initializing modules...
- application in modules/application
- Finding kreuzwerker/docker versions matching ">= 3.0.0, ~> 3.0"...
- Installing kreuzwerker/docker v3.9.0...

Terraform merged the root constraint and the child constraint into one requirement and installed a single plugin that satisfies both. The address to import to is the module path plus the resource address inside it. Quote it to keep the shell out of the way:

bash
terraform import 'module.application.docker_volume.data' tf-import-lab-module

Sample output:

output
module.application.docker_volume.data: Importing from ID "tf-import-lab-module"...
module.application.docker_volume.data: Import prepared!
  Prepared docker_volume for import
module.application.docker_volume.data: Refreshing state... [id=tf-import-lab-module]

Import successful!

State now carries the fully qualified address:

bash
terraform state list

Sample output:

output
module.application.docker_volume.data

Verify the same way you would at the root — the plan must be clean:

bash
terraform plan

Sample output:

output
No changes. Your infrastructure matches the configuration.

An import block works here too: set to = module.application.docker_volume.data. Nested modules and indexed instances stack the same way, as in module.application.module.storage.docker_volume.data[0]. Get the address wrong and Terraform tells you the configuration does not exist — it will not guess which module you meant. Module design itself is covered in the Terraform modules lesson.


Resource IDs and identity

The import ID is defined by the provider, not by Terraform. There is no universal rule that says "use the name" or "use the ARN", which is why every import starts with the provider's documentation for that specific resource type.

The two objects in this lab already disagree. A Docker volume uses its name as its ID, so tf-import-lab-data imports cleanly. A Docker network has a long hexadecimal ID that is nothing like its name, and that is the value the provider documents. Cloud resources are often stranger still: many require a composite string built from a parent ID and a child name.

Resource Documented import ID Example
docker_volume Volume ID, which Docker sets to the volume name tf-import-lab-data
docker_network Long network ID, not the network name f180c7b461a3…
Typical cloud child resource Parent and child joined by a separator my-instance/my-disk

Providers can also accept more than the documented form. Importing the lab network by name instead of its ID succeeded on my host, because the Docker API resolves either one. State then recorded id = "tf-import-lab-net" instead of the canonical hash, which works right up until something compares that value against the real network ID. Use the documented form.

Terraform 1.12 added provider-defined resource identity, which lets an import block use an identity argument with a structured set of attributes instead of a single id string:

hcl
import {
  to = aws_s3_bucket.example

  identity = {
    bucket = "my-existing-bucket"
  }
}

Identity only works where the provider implements it. The Docker provider used here does not, so id remains the only option in this lab. Check the resource page in your provider's documentation before assuming either form.


Verify an imported resource

Verification is where imports are won or lost. Run all three checks in order, every time: state list to confirm the address exists, state show to see what was recorded, and plan to compare that record against your configuration.

The plan is the one that tells you something new, and it has three possible answers:

text
No changes          configuration matches the imported object
Updates proposed    configuration differs from reality — read every line
Replacement         Terraform would destroy and recreate — stop and investigate

The first two are routine. The third deserves real attention, because approving it destroys the object you just went to the trouble of adopting.

Return to the CLI import directory to see a replacement plan on purpose:

bash
cd ~/terraform-labs/terraform-import/cli

Add a label to the resource block that the real volume does not carry. Docker labels are fixed at creation, so this is a change the provider cannot make in place:

hcl
resource "docker_volume" "data" {
  name = "tf-import-lab-data"

  labels {
    label = "owner"
    value = "platform-team"
  }
}

Plan again without applying anything:

bash
terraform plan

Sample output:

output
# docker_volume.data must be replaced
-/+ resource "docker_volume" "data" {
      ~ driver      = "local" -> (known after apply)
      ~ id          = "tf-import-lab-data" -> (known after apply)
      ~ mountpoint  = "/var/lib/docker/volumes/tf-import-lab-data/_data" -> (known after apply)
        name        = "tf-import-lab-data"

      + labels { # forces replacement
          + label = "owner"
          + value = "platform-team"
        }
    }

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

Terraform even names the culprit with # forces replacement next to the offending block. An apply here would delete the volume and its data, then create an empty replacement. The fix is to remove the attribute from configuration. When the label really is required, add it to the real object out of band first, then re-plan so Terraform sees a match.

Restore the resource block to the version that matched reality:

hcl
resource "docker_volume" "data" {
  name = "tf-import-lab-data"
}

Confirm the plan is quiet again before moving on:

bash
terraform plan

Sample output:

output
No changes. Your infrastructure matches the configuration.

Independent verification is worth one extra command. Ask Docker directly and compare with what Terraform recorded:

bash
docker volume inspect tf-import-lab-data --format 'Name={{.Name}} Created={{.CreatedAt}} Mountpoint={{.Mountpoint}}'

Sample output:

output
Name=tf-import-lab-data Created=2026-08-12T10:50:49+05:30 Mountpoint=/var/lib/docker/volumes/tf-import-lab-data/_data

Same creation timestamp, same mount point as before the import. The object was adopted, never replaced. That is the entire point of importing rather than re-creating.


Import versus the state commands

Import is often confused with the state and refactor tools, but each one answers a different question about how an object got into state and how its address changes afterwards.

Operation What it does Use when
terraform import / import block Binds an existing unmanaged object to an address in state The object is real, state has no record of it
terraform state mv Renames an address that is already in state You renamed a block and want to keep the object
moved block Records the address change declaratively in configuration The rename should travel with the code in Git

The distinction is object versus address. Import brings something new under management; the other two rearrange things Terraform already manages. Full walkthroughs live in the terraform state commands lesson and the moved and removed blocks lesson.


Common Terraform import problems

Most import failures come from one of three mistakes: a wrong ID, a missing or misspelled address, or an assumption that import fixes configuration for you. These are the errors you will actually see, reproduced in ~/terraform-labs/terraform-import/errors/.

A mistyped or non-existent ID fails at the provider read, before anything is written to state:

bash
terraform import docker_volume.typo tf-import-lab-dta

Sample output:

output
Error: Cannot import non-existent remote object

While attempting to import an existing object to "docker_volume.typo", the
provider detected that no object exists with the given id. Only
pre-existing objects can be imported; check that the id is correct and that
it is associated with the provider's configured region or endpoint, or use
"terraform apply" to create a new remote object for this resource.

The mention of region and endpoint is a useful hint on cloud providers: the ID may be valid somewhere else. Nothing was changed, so fix the ID and run it again.

Importing to an address that has no resource block fails just as early, and Terraform prints the block you are missing:

bash
terraform import docker_volume.missing tf-import-lab-data

Sample output:

output
Error: resource address "docker_volume.missing" does not exist in the configuration.

Before importing this resource, please create its configuration in the root module. For example:

resource "docker_volume" "missing" {
  # (resource arguments)
}

Import blocks give the same message in plan form, plus the flag that solves it if you wanted generation all along:

bash
terraform plan

Sample output:

output
Error: Configuration for import target does not exist

  on import.tf line 2, in import:
   2:   to = docker_volume.undeclared

The configuration for the given import target docker_volume.undeclared does
not exist. If you wish to automatically generate config for this resource,
use the -generate-config-out option within terraform plan.

Back in the cli/ directory, re-running an import that already succeeded is refused outright, which protects the existing state entry:

bash
terraform import docker_volume.data tf-import-lab-data

Sample output:

output
Error: Resource already managed by Terraform

Terraform is already managing a remote object for docker_volume.data. To
import to this address you must first remove the existing object from the
state.

Terraform is far more relaxed about the opposite mistake. Nothing stops two addresses from importing the same remote object, and the plan reports both as valid:

bash
terraform apply -auto-approve

Sample output:

output
docker_volume.typo: Importing... [id=tf-import-lab-data]
docker_volume.typo: Import complete [id=tf-import-lab-data]
docker_volume.duplicate: Importing... [id=tf-import-lab-data]
docker_volume.duplicate: Import complete [id=tf-import-lab-data]

Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.

Two state entries now point at one volume, and a later destroy would try to delete it twice. Drop the extra binding as soon as you notice it:

bash
terraform state rm docker_volume.duplicate

Sample output:

output
Removed docker_volume.duplicate
Successfully removed 1 resource instance(s).

One more trap catches config generation specifically. A directory that contains only an import block has no required_providers entry, so Terraform guesses the provider namespace from the resource type prefix:

bash
terraform init

Sample output:

output
Could not retrieve the list of available versions for provider
hashicorp/docker: provider registry registry.terraform.io does not have a
provider named registry.terraform.io/hashicorp/docker

Did you intend to use kreuzwerker/docker? If so, you must specify that source
address in each module which requires that provider.

Add the required_providers block and a provider configuration, then re-run init. The remaining problems are quicker to state than to reproduce:

Symptom Likely cause Fix
Cannot import non-existent remote object Wrong ID, wrong form of ID, or wrong region and account Copy the documented ID from the provider's resource import section
does not exist in the configuration Destination resource block missing or misspelled Write the resource block first, or use -generate-config-out
Resource already managed by Terraform Address already imported Run terraform state list; state rm first only if you truly need to re-import
Wrong module or index address Module path or instance key omitted Use the full address and quote brackets: 'module.app.docker_volume.data["web"]'
Init resolves hashicorp/<name> for a community provider Child module has no required_providers of its own Add a terraform block with the correct source inside the module, then re-run init
Target generated file already exists Reused the -generate-config-out path Point at a new file name or move the existing one aside
Generated file will not apply cleanly Conflicting or computed attributes in the draft Trim the block, re-plan, repeat until the diff is empty
Plan proposes updates right after import Configuration does not match the real object Adjust configuration to reality, not the other way round
Plan proposes replacement right after import Configuration differs on a force-new attribute Remove that attribute or change the object out of band first
Provider does not support importing this type Not every resource implements import Check the provider docs; some objects must be recreated
Same object imported twice Two addresses share one ID terraform state rm the duplicate address before applying anything else

Clean up the lab

Everything imported is now managed, which means Terraform will happily delete it. The errors/ directory is the exception: its state points at a volume owned by the cli/ directory, so release that binding rather than destroying it:

bash
cd ~/terraform-labs/terraform-import/errors && terraform state rm docker_volume.typo

Sample output:

output
Removed docker_volume.typo
Successfully removed 1 resource instance(s).

With that duplicate binding gone, destroy the objects each remaining directory genuinely owns:

bash
for d in cli block generate foreach module; do (cd ~/terraform-labs/terraform-import/$d && terraform destroy -auto-approve); done

Sample output:

output
Destroy complete! Resources: 1 destroyed.
Destroy complete! Resources: 1 destroyed.
Destroy complete! Resources: 1 destroyed.
Destroy complete! Resources: 2 destroyed.
Destroy complete! Resources: 1 destroyed.

Six objects across five directories, matching the four volumes and two networks you created by hand. Confirm they are gone rather than trusting the loop:

bash
docker volume ls --filter name=tf-import-lab --format '{{.Name}}'

The command prints nothing when every lab volume has been removed. Check the networks the same way:

bash
docker network ls --filter name=tf-import-lab --format '{{.Name}}'

Empty output on both means Terraform deleted the real objects it adopted. Nothing proves the import worked more clearly than Terraform being able to destroy something it never created.


References


Summary

Terraform import solves one problem: a real object exists, and state has no record of it. You saw both supported ways to fix that. The terraform import CLI command takes an address and a provider ID and writes state immediately, which suits one-off adoptions and quick repairs. An import block puts the same operation in configuration, so it appears in the plan as 1 to import and merges through review like any other change. Neither one is deprecated, and both leave identical state entries behind.

Configuration is still your responsibility. Import never writes .tf files on its own, and terraform plan -generate-config-out only produces a draft — the generated network block in this lab carried defaults, empty maps, and explicit nulls that had no business in version control. Trim it, format it, re-plan, and only then commit. The same discipline applies to batches: for_each on an import block adopts a map of objects in one apply, but each instance still needs a resource definition that matches reality.

The habit worth keeping is the verification sequence. Run terraform state list to confirm the address landed, terraform state show to see what Terraform recorded, and terraform plan to compare that against configuration. An empty plan means you are done. Proposed updates mean your configuration is wrong somewhere. A proposed replacement — like the one the added Docker label triggered here — means an apply would destroy the object you just imported, so investigate before approving anything. When the object is already managed and you only want to change its address, reach for terraform state mv or a moved block instead; import is for adoption, not for renaming.


Frequently Asked Questions

1. What does terraform import do?

It records an existing remote object in Terraform state under a resource address you choose, so future plans compare that object against your configuration instead of proposing to create a new one. It never creates, modifies, or deletes the remote object itself.

2. What is the difference between terraform import and an import block?

The CLI command is an imperative state operation that runs once and writes state immediately. An import block lives in configuration, appears in the plan before anything changes, and applies through the normal plan and apply cycle so reviewers can see the import in a pull request. Both end with the same state entry.

3. Does terraform import generate configuration for me?

The CLI command never writes configuration. An import block combined with terraform plan -generate-config-out writes a starting-point resource block that you must review and trim before applying, because it includes default and provider-computed attributes you would not hand-write.

4. Why does terraform plan still show changes after a successful import?

Import only records reality in state. If your resource block sets attributes the real object does not have, or omits attributes the object does have, the next plan proposes updates or a replacement. Adjust configuration until the plan is empty or the remaining diff is one you intend.

5. Can I import a resource into a module?

Yes. Use the full address including the module path, such as module.application.docker_volume.data, and quote it in the shell when it contains brackets. The module must already declare a matching resource block before the import runs.

6. What import ID should I use?

The ID is provider and resource specific, so check the import section of that resource's provider documentation. Some resources use a name, others use an opaque ID, and many cloud resources need a composite string such as a parent ID and child name joined by a separator.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)