Almost every infrastructure estate we are called into has resources that predate its Terraform. That is rarely for lack of trying. Adopting what already exists has always been more awkward than writing something new, and the awkwardness had a specific shape in the language itself. Terraform 1.16.0, released on 26 August 2026, changes that shape: import blocks are now supported inside modules.
Where brownfield adoption actually stalls
A team has Terraform for some of its infrastructure. The rest was created by hand during a launch, an incident, or a week when the person who knew the modules was on holiday. Everyone agrees it should be under management. Then somebody prices the work and it goes on the roadmap for next quarter, where it stays.
The blocker is rarely the module code. It is that adoption could not be packaged. Configuration-driven import has been available since Terraform 1.5, and it works well: you declare what exists, you run a plan, you see the truth before you touch anything. But an import block could only be written in the root module.
That restriction is narrower than it sounds. You could always target an address inside a module. What you could not do was write the block there.
# root/main.tf (Terraform 1.15 and earlier)
module "billing_api" {
source = "./modules/service"
name = "billing-api"
project_id = var.project_id
}
# The module knows these three resources belong together.
# The root module has to know it too, in full addresses, by hand.
import {
to = module.billing_api.google_cloud_run_v2_service.this
id = "projects/acme-prod/locations/us-central1/services/billing-api"
}
import {
to = module.billing_api.google_service_account.runtime
id = "projects/acme-prod/serviceAccounts/billing-api@acme-prod.iam.gserviceaccount.com"
}
import {
to = module.billing_api.google_secret_manager_secret.db_url
id = "projects/acme-prod/secrets/billing-api-db-url"
}
Three resources, one service, one environment. Multiply by the number of services and again by the number of environments, and the root module fills with adoption bookkeeping that duplicates knowledge the module already has. Module authors felt this from the other side: a public Terraform module that shipped in-module migration machinery had to revert it, because import blocks could not live in a child module. The upstream request to allow them sat open from July 2023 until it was closed by the 1.16 work in May 2026.

What 1.16 changes
With the block allowed inside a module, adoption becomes an input. The module declares how to adopt the resources it manages, and the caller supplies the IDs of whatever already exists. A greenfield caller passes nothing and gets the same module, creating from scratch.
# modules/service/adopt.tf (Terraform 1.16)
variable "adopt_existing" {
description = "IDs of resources to adopt instead of create. Empty means greenfield."
type = object({
service = optional(string)
service_account = optional(string)
db_url_secret = optional(string)
})
default = {}
}
import {
for_each = var.adopt_existing.service == null ? [] : [var.adopt_existing.service]
to = google_cloud_run_v2_service.this
id = each.value
}
import {
for_each = var.adopt_existing.service_account == null ? [] : [var.adopt_existing.service_account]
to = google_service_account.runtime
id = each.value
}
import {
for_each = var.adopt_existing.db_url_secret == null ? [] : [var.adopt_existing.db_url_secret]
to = google_secret_manager_secret.db_url
id = each.value
}
The addresses in to are now module-local, which is the part that removes the duplication. The caller says what exists and nothing about internal structure.
# root/main.tf (Terraform 1.16)
module "billing_api" {
source = "./modules/service"
name = "billing-api"
project_id = var.project_id
adopt_existing = {
service = "projects/acme-prod/locations/us-central1/services/billing-api"
service_account = "projects/acme-prod/serviceAccounts/billing-api@acme-prod.iam.gserviceaccount.com"
}
}
module "search_api" {
source = "./modules/service"
name = "search-api"
project_id = var.project_id
# Nothing to adopt. Same module, created from scratch.
}
The second block needs nothing extra to stay greenfield. One module serves both populations because the for_each on each import collapses to an empty set when the caller has nothing to hand over, so there is no second module to maintain and no branch in the root configuration.
Better modules do nothing for the resources Terraform has never heard of. In a brownfield estate the bottleneck is always adoption.
Discovery before adoption
You still have to find the IDs and the real attribute values. Terraform generates a starting point for any import block that has no matching resource block yet.
# bash
# The output file must not already exist.
terraform plan -generate-config-out=adopted.tf
# Terraform writes its best guess at every argument, and warns you:
# Warning: Config generation is experimental
#
# Read it, delete the attributes the provider filled in for you,
# then move what is left into the module by hand.
Treat generated HCL the way you would treat decompiler output: useful for finding out what is really there, never committed as written. On resources with complex schemas Terraform can fail outright with a conflicting-arguments error, and even when it succeeds the output carries computed values that have no business sitting in configuration.
The other direction: releasing a resource
Adoption has a mirror image that comes up on the same engagements. Something is in state that should not be managed by this configuration any more, and deleting the block would destroy it. Terraform 1.16 adds destroy = false to resource lifecycle blocks: the resource is removed from state without the real infrastructure being touched.
# A database instance that is moving to a different configuration.
# Terraform forgets it rather than destroying it.
resource "google_sql_database_instance" "legacy" {
# ...
lifecycle {
destroy = false
}
}
This is not a softer prevent_destroy. prevent_destroy rejects the plan and returns an error, which is what you want on a resource that must never go away. destroy = false does the opposite: it lets the run proceed and drops the resource out of management quietly. They solve different problems and confusing them is expensive in both directions.
| Task | Before 1.16 | With 1.16 |
|---|---|---|
| Adopt a resource into a module | Root-level import with a full module address, repeated per module instance | import inside the module, module-local address |
| Ship adoption with a reusable module | Not possible; consumers write the imports | Module variable plus internal import blocks |
| Stop managing a resource without destroying it | removed block, or terraform state rm outside the workflow | lifecycle { destroy = false } on the resource |
| Block a destroy outright | prevent_destroy | prevent_destroy, unchanged |
| Same in OpenTofu | Root module only | Root module only, as of 1.12.6 |
What this looks like on a real estate
We took this on for an early-stage security startup running NodeJS microservices in a monorepo. The engagement was production CI/CD: GitHub Actions building and releasing each service independently, ArgoCD deploying onto a new GKE cluster, and only the services a commit actually touched getting rebuilt. Reworking their existing Terraform on GCP to best practices came with the job.
The module rewrite was the easy half. What made the engagement instructive was that the estate held two populations at once, resources Terraform knew about and resources that existed only in the console, and the second population is what makes people give up. We worked module by module rather than in one pass, ran discovery to get the real attribute values, kept unadopted resources explicitly outside state rather than half inside it, and held one merge rule: the first plan on a reworked module comes back with zero destroys, or it does not merge.
That work predates 1.16, so the adoption logic lived at the root, where the language required it. The rule and the module-at-a-time sequencing are what we would keep. What 1.16 changes is where the logic gets to live, and therefore whether it can be shipped once instead of rewritten per environment.
Honest trade-offs
- Import is not a refactoring record. A Terraform maintainer made this argument against the feature for years, and it is still true:
movedandremovedblocks describe operations on state that stay meaningful later, whileimportalways depends on external state Terraform cannot see. An import block inside a module is a claim about the world at one moment. Delete it once it has served its purpose, or it becomes a comment that lies. - Config generation stays experimental. The warning is in the tool output for a reason. The generated format may change between versions, and it fails on some complex schemas rather than producing something imperfect.
destroy = falseis quiet by design. A resource can leave management with nothing louder than a successful apply. Anything you release this way needs to be picked up somewhere else in the same change, or you have created an orphan that will be rediscovered as a cost line.- OpenTofu has not followed yet. Import blocks in child modules are an open request there, and 1.12.6 still requires them at the root. If your organization is on OpenTofu, this is a real divergence between the two languages rather than a version lag, and it belongs in the decision rather than in a surprise during a migration.
- One upgrade note worth reading before you bump. Terraform 1.16 corrects
bastion_host_keyso that provisioners actually apply it. Any configuration that has been quietly working with the wrong key will start failing. Check your provisioner configurations first.
Smaller things in the same release
Next to the import work sit the machine-readable outputs, which let module documentation and pipeline checks stop depending on regular expressions over text meant for humans.
# bash
# Dependency graphs your docs tool can render directly.
terraform graph -format=mermaid > docs/dependencies.mmd
# Machine-readable state and workspace inspection.
terraform state show -json google_cloud_run_v2_service.this
terraform workspace list -json
And resource action triggers gained failure modes. Previously a failed post-apply action stopped the run; now you can also mark the resource for replacement instead of leaving it half-configured and reading as healthy.
resource "google_compute_instance" "worker" {
# ...
lifecycle {
action_trigger {
events = [after_create]
actions = [action.ansible_playbook.provision]
on_failure = taint # halt (default) | continue | taint
}
}
}
halt stops the apply and reports the error without tainting. continue logs a warning and carries on. taint stops the apply and marks the resource for replacement on the next one, which is the right answer for provisioning steps that leave a machine in an unknown state when they fail halfway.
Where to start
Pick the module that covers the largest number of unmanaged resources, not the messiest one. Add the adoption variable, write the import blocks beside the resources they target, and run a plan. If the plan proposes a single destroy, the module does not yet describe what is actually running, and that is information rather than a failure. Fix the module until the plan is clean, then merge and take the next one. The estate converges one module at a time, and no step in that sequence requires a maintenance window.
Sitting on infrastructure that predates your Terraform? Naviteq’s senior platform team does brownfield IaC adoption for SaaS, FinTech, and Enterprise teams across the US, EU, and Israel. Let’s talk.