Provisioners and Importing Existing Infrastructure

Authors

K19G

grok-bot

Updated

October 31, 2026

Provisioners and Importing Existing Infrastructure

Provisioners run a script against a resource after OpenTofu has created it. Import does the opposite kind of work: it tells state about an object that already exists. One of these belongs in a modern module. This chapter is how to tell them apart, and how to bring a legacy object under management without recreating it.

Learning goals

  • Explain why a provisioner breaks the declarative model
  • Name the few cases where local-exec is still honest
  • Import an existing object and generate configuration for it
  • Plan a migration that ends with OpenTofu as the owner

The Problem With Provisioners: Why They Are Discouraged

A resource argument is stored in state and corrected on the next plan. A provisioner runs and is forgotten. The script’s effect is invisible to the next plan unless the script’s failure happened to fail the apply. The object looks clean while the package the script installed has drifted.

Provisioners also put an imperative tool inside a declarative run: SSH keys, WinRM (removed from OpenTofu 1.13’s file and remote-exec), a shell on the machine running tofu. That shell has the pipeline’s credentials. A local-exec that curl-posts to an API is a second provider with none of the provider’s diff.

The replacements are ordinary provider features. Cloud-init and user-data are arguments. Configuration management is a tool that converges on its own. A Kubernetes manifest is a resource of its provider. An API call you need every time is a provider resource, even if you have to write a small one. Reach for a provisioner only after those options are genuinely absent.

When Provisioners Make Sense: file, local-exec, remote-exec, and Beyond

local-exec runs on the machine where tofu runs. The honest uses are narrow: call a control-plane API the provider does not cover yet, during a migration you have dated; or render a one-shot artifact the provider cannot. Even then, a null resource with a local-exec is a resource whose only behaviour is the script, and it will not re-run when the script’s inputs change unless you wire a trigger.

file and remote-exec copy and run on the new machine over SSH. They need a connection block, a reachable address, and a key. They fail when the network path from the pipeline to the instance does not exist, which is the common production case. In 1.13 they do not speak WinRM. If the instance is Windows, use a different channel or, better, an image that does not need a post-create script.

creation-time provisioners run on create. destroy-time provisioners run before destroy. A destroy-time provisioner that must succeed or it blocks destruction will block an incident. Prefer the provider’s own deletion behaviour.

There is no “beyond” that makes provisioners a design. Chef and Puppet provisioner bridges were the old pattern. Leave them in the history of the module you are migrating.

Best Practices If You Must Use Provisioners: Idempotency and Error Handling

If a migration still has one:

  • Make the script idempotent. Running it twice converges. A script that appends a line every time is not finished
  • Fail the script with a non-zero status. A provisioner that swallows errors reports a successful apply
  • Pass data as arguments or environment, not as a string interpolated into a shell command you did not quote
  • Trigger replacement or re-run from a hash of the inputs, or accept that the script runs only on create and document that
  • Keep the script in the repository, not on a laptop path
  • Delete the provisioner in a follow-up commit once the provider has the resource. Put that follow-up on the same change request

on_failure = continue hides the failure. Do not set it to get a green pipeline.

Importing Existing Resources: The import Command and Workflow

Import writes an existing remote object into state under an address. It does not write configuration. After a successful import, a plan with no configuration for that address wants to destroy the object. Write the configuration first, or generate it in the same step, and plan before you apply.

The declarative form, stable since the 1.5 language:

import {
  to = aws_s3_bucket.state
  id = "example-tofu-state-203-0-113"
}

resource "aws_s3_bucket" "state" {
  bucket = "example-tofu-state-203-0-113"
}

tofu plan shows an import. tofu apply records the object in state. The next plan should be empty. If it is not, the configuration does not match the object and apply would change a legacy bucket. Fix the configuration until the plan is empty. That empty plan is the proof you imported rather than adopted-and-mutated.

The one-shot CLI tofu import aws_s3_bucket.state example-tofu-state-203-0-113 does the same state write without an import block. Prefer the block. It is reviewed, it can take for_each, and it documents the migration in git. Remove the import block after every state has applied it, the same way you remove a moved block.

The id string is provider-specific. For many resources it is the cloud id. For others it is a composite (name/namespace). Read the provider’s import document for that resource type. Guessing the id imports the wrong object or imports nothing.

Generating Configuration for Imported Resources: State Inspection Techniques

tofu plan -generate-config-out=generated.tf writes HCL for addresses in import blocks that do not yet have configuration. The file is a starting point. It is not a module.

Generated configuration dumps every attribute the provider returned, including ones you should not set because they are computed, and ones that differ from the provider’s defaults in ways that will churn. Edit it down to the arguments you intend to own. Then plan again.

Inspection tools while you edit:

Command What you learn
tofu state show aws_s3_bucket.state Attributes currently stored
tofu state list Addresses in this state
tofu show -json The same facts for a script
A refresh-only plan Whether the world and the state still agree

Do not generate configuration for an entire account in one pass. Import the object you mean to change, get an empty plan, and then import the next one. A generated file of hundreds of resources is a state you cannot review.

Migration Strategies: Bringing Legacy Infrastructure Under OpenTofu Control

A migration is a sequence of empty plans, not a flag day.

  1. Inventory the objects and decide which ones OpenTofu will own. Some should stay outside: the state bucket itself is often created by hand or by a tiny bootstrap root so the backend exists before init
  2. Write or generate configuration for one group that changes together
  3. Import, plan, and stop when the plan is empty
  4. Apply a no-op so state and configuration are known-good
  5. Only then change an argument in a later commit
  6. Remove the import blocks
  7. Turn on prevent_destroy for objects the migration must not casually delete

Two teams must not manage the same object. If a console workflow still edits it, either stop that workflow or ignore_changes the specific argument and say why. Dual ownership is the drift you will be paged for.

Account-level tools that only scan and suggest imports are fine as a map. They are a poor author of the module boundary. You still decide what the root module is.

When the legacy object cannot be imported because the provider has no import for that type, you are stuck with a one-time recreation or with leaving it unmanaged. Recreate only when you can afford the replacement. Write that down. A provisioner is not the bridge across a missing import.

Summary

  • Provisioners run and forget. Resource arguments are corrected on the next plan
  • OpenTofu 1.13’s file and remote-exec provisioners do not speak WinRM
  • If a script remains, it is idempotent, it fails loudly, and it has a removal date
  • Import with a reviewed import block, then edit configuration until the plan is empty
  • Generated HCL is a draft. Migrate one group at a time, and do not share ownership with the console