In the Django Commons organization we use OpenTofu to manage our Infrastructure as Code (IaC).
While we have done this since the very beginning (though we initially started with Terraform instead of OpenTofu), I was always a bit unsure as to what is actually happening. I decided the best way to understand it was to just dive in, head first, and use it for some of my own personal infrastructure.
OpenTofu is an open source forked alternative to Terraform version 1.5.6. Why an open source alternative? HashiCorp changed the license from Mozilla Public License (MPL) 2.0 to the Business Source License (BSL) on August 10, 2023. For more, read their Manifesto.
Start with what you want to manage¶
The easiest way to understand OpenTofu is to start with the thing you want to manage and work backwards. At Django Commons that's things like the members and teams of our GitHub organization. For me, it was the servers, firewalls and DNS records I have on Hetzner (and, eventually, a handful of other services).
Once you know what you want to manage, you can work out what OpenTofu needs to manage it:
- A provider to talk to the service
- The resources you want it to manage
- Somewhere to keep state, the record of what it manages
- Variables (and maybe outputs) for the values that change or are secret
Modules come last, once there's enough that you want to break it into smaller pieces.
Provider¶
Providers are connectors. Each one is a plugin that lets OpenTofu create, read, update, and delete resources on a given platform (AWS, GitHub, Hetzner, etc). You write your configuration in a consistent way, and the provider takes care of talking to that platform's API for you. There are a LOT of providers.
For Django Commons managing GitHub members, that's the github provider. For my Hetzner servers, it's the hcloud provider.
Resource¶
Resources are the actual things you're managing ... users, servers, DNS records, firewalls, etc. Per the docs:
In summary, applying an OpenTofu configuration will:
- Create resources that exist in the configuration but are not associated with a real infrastructure object in the state.
- Destroy resources that exist in the state but no longer exist in the configuration.
- Forget resources that exist in the state but no longer in the configuration and are referenced in a
removedblock within the configuration.- Update in-place resources whose arguments have changed.
- Destroy and re-create resources whose arguments have changed but which cannot be updated in-place due to remote API limitations.
Each resource type is implemented by a provider. You can also change how OpenTofu treats a resource with a lifecycle block. Some of the arguments it takes:
create_before_destroy: does what it says, but there are some limitations depending on the API you're interacting withprevent_destroy: stops OpenTofu from destroying the resource. You'll see me use this below when I set up Hetznerignore_changes: useful for things that might change outside of OpenTofu. The example in the docs is tags that get added to a resource at the sourceenabled: when set tofalse, OpenTofu acts as if the resource isn't in the configuration at all. If it's already in state, that means it gets destroyed. It's tempting to compare this tomanaged = Falsein Django, but that's nearly the opposite: Django leaves an unmanaged table alone, while OpenTofu deletes a disabled resourcedestroy: when set tofalse, removing the resource from the configuration makes OpenTofu forget it (drop it from state) instead of destroying the real thing. It works like aremovedblockpreconditionandpostcondition: these "can help capture assumptions, helping future maintainers understand the configuration design and intent" per the docs
A few other things that stood out:
- You can set operation timeouts
- You might get deprecation warnings
terraform_datais confusing. I asked Claude to explain it, and what made it click was this:replace_triggered_byonly works with resources, so you can't point it at a plain variable or local.terraform_datawraps a plain value in a resource, so changing that value can trigger a replacement. It's also handy as a do-nothing container for provisioners when there's no other resource that makes sense to hang them on.
One last thing on resources. Provisioners run a script or copy files onto a machine after OpenTofu creates it. They should be used as a last resort, and the docs suggest using cloud-init instead.
State and backend¶
State is how OpenTofu remembers what it manages. Its primary purpose is to store the bindings between objects in a remote system and the resources declared in your configuration. When you run tofu plan, OpenTofu compares three things: your configuration, the state, and what actually exists.
The backend is where that state lives. If you don't configure one, it's a terraform.tfstate file in the directory you run tofu from. That's fine for trying things out, but it means the state only exists on your laptop.
Where to keep state was one of the bigger decisions at Django Commons. We started with the state file stored on GitHub, and then Daniel and Tim set up an AWS backend so that everyone on the team works from one shared state file.
For my own infrastructure I started with local state, and then moved it to an S3 backend. Every directory (root module) gets its own state file in the same bucket:
terraform {
backend "s3" {
bucket = "ryancheley-tofu-state"
key = "hetzner/terraform.tfstate"
region = "us-west-2"
use_lockfile = true
}
}
The bucket is versioned, so a bad state write can be undone by restoring an older version, and use_lockfile = true stops two runs from writing state at the same time. Changing the backend means running tofu init -migrate-state, which copies the existing state to the new location.1
A couple of other things on state:
importis how you bring in existing resources. This is what I ended up using for my own infrastructure- Workspaces let one configuration have more than one state. Despite what a video I watched implied, they aren't meant for separate environments (separate directories like
envs/prodandenvs/stagingare). Neither Django Commons nor I use them
The terraform_remote_state docs look scary at first. I initially read them as a lot of "You can do this, but you shouldn't." The concern is that terraform_remote_state exposes the entire state file, which can include secrets that another team maybe shouldn't have access to. This is one of those questions of: will my current setup ALWAYS be just me and NEVER change? If the answer is yes, then this is a perfectly acceptable way to proceed ... but remember, ALWAYS and NEVER are very long times.
Variable¶
Input variables are like function arguments. Some things I learned about them:
- You can add a description which acts like a doc string for the variable
- Setting
sensitive = truewill keep the value out of theplanandapplyoutput - Be careful though ... errors can leak these sensitive values
- Ephemeral variables are available by setting
ephemeral = true
I wasn't sure when I'd use an ephemeral variable instead of a sensitive one, so I asked Claude. Ephemeral variables are for secrets that OpenTofu needs while it runs but should never write down. Their values aren't stored in state at all, and only the variable names are saved in the plan. A sensitive value, on the other hand, is still stored in state and in saved plan files. Only the display is masked. I had assumed they did the same thing!
You can set variables individually on the command line, or put them in a *.tfvars file to read from. This feels a lot like docker with flags vs. docker compose with a docker-compose.yml file. OpenTofu will also automatically load variable definition files if they're present:
- Files named exactly
terraform.tfvarsorterraform.tfvars.json - Any files with names ending in
.auto.tfvarsor.auto.tfvars.json
If the same variable is assigned more than once, OpenTofu uses the last value it finds. See the docs for the full precedence order.
Output¶
Per the docs:
Output values make information about your infrastructure available on the command line, and can expose information for other OpenTofu configurations to use. Output values are similar to return values in programming languages.
You can use precondition and postcondition here too, and there's a depends_on key that works a lot like it does in docker compose.
Module¶
Modules are how you group resources together, and they're what you reach for once a configuration gets big enough that you want to break it into smaller pieces. Every OpenTofu configuration has at least one module, known as its root module. This is just the resources defined in the .tf or .tofu files in the directory you're running tofu from. For my own setup, each provider gets its own directory, so each one is its own root module with its own state.
Here's what grouping looks like. A web_server module could bundle a server with the firewall it always needs:
# modules/web_server/main.tf
variable "name" {
type = string
}
resource "hcloud_firewall" "this" {
name = "${var.name}-fw"
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
resource "hcloud_server" "this" {
name = var.name
server_type = "cx22"
image = "ubuntu-24.04"
firewall_ids = [hcloud_firewall.this.id]
}
Then each new server is a few lines in the root module:
module "blog" {
source = "./modules/web_server"
name = "blog"
}
module "status_page" {
source = "./modules/web_server"
name = "status-page"
}
A couple of things I noticed:
- Only provider configurations are inherited by child modules, not provider source or version requirements. Each module has to declare its own provider requirements. This is especially important for non-HashiCorp providers.
- When you've outgrown your initial configuration, check out Refactoring in the docs
Using OpenTofu to manage my own infrastructure¶
To help solidify what I'd learned, I decided to use OpenTofu to manage my own infrastructure. For that I used the following providers:
- AWS
- Coolify
- DigitalOcean
- GitHub
- Hetzner
- Tailscale
In my case I'm importing what I already have, not adding new things (yet!).
Here's a quick walkthrough of setting up Hetzner with OpenTofu:
- Created an
infradirectory, and then ahetznerdirectory inside of it - Created
main.tfand added the Hetzner provider - Ran
tofu init - Created
imports.tfto catalog all of the infrastructure I wanted to import - Ran
tofu plan -generate-config-out=generated.tf, which createdgenerated.tf. I did have to set theHCLOUD_TOKENenvironment variable first withset -x HCLOUD_TOKEN (op read "op://token-location")2 -
Updated each server with a
lifecycleblock to prevent any accidental destruction:lifecycle { ignore_changes = [image, user_data, ssh_keys] prevent_destroy = true } -
Ran
tofu applyto bring everything into state - Later, moved the state from a local file to the S3 backend described above
Some caveats:
- For Hetzner, make sure every server has
prevent_destroy = true(see step 6 above). You do NOT want a typo to delete a server - For Tailscale, import your ACL policy and use
tailscale_devicedata sources for your machines (all ten in my case). Be careful though ... a bad policy can lock you out of your machines3 - Don't let Coolify create Hetzner servers, as they will collide with the
hcloudprovider
Encrypting my state¶
This part is specific to my setup. Django Commons doesn't encrypt its state (we use 1Password there to manage access to AWS).
State holds everything OpenTofu knows about your resources, and depending on the provider that can include secrets, so I wanted mine encrypted. OpenTofu has native state encryption built in, which is one of the things it has that Terraform doesn't. I added an encryption block to the terraform block:
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase # at least 16 characters
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
enforced = true
}
plan {
method = method.aes_gcm.main
enforced = true
}
}
}
Along with a state_passphrase variable (marked sensitive). The passphrase lives in 1Password, and I pass it in as TF_VAR_state_passphrase with op run. enforced = true means OpenTofu will refuse to write state or plans unencrypted.
This was a pita because I had already imported everything, so my state file was sitting there unencrypted, and OpenTofu won't read it once you've told it everything should be encrypted. To migrate it I had to:
- Back up
terraform.tfstate(just in case!) - Remove
enforced = truefor now. While it's set you get "Unable to use unencrypted method since the enforced flag is set." - Add a temporary
method "unencrypted" "migrate" {}and afallback { method = method.unencrypted.migrate }inside bothstateandplan. This lets OpenTofu read the plain state one last time - Run
tofu apply -refresh-only, then check withgrep -c encrypted_data terraform.tfstate. If it prints 1, the state is encrypted - Remove the
unencryptedmethod and bothfallbackblocks, putenforced = trueback, and make suretofu planreports "No changes"
A few gotchas:
- Plain
tofu initstops working. The encryption block needs the passphrase, so eveninithas to run throughop run - Don't lose the passphrase! Without it, the state is unreadable. Same goes for removing the
encryptionblock later ... treat it as permanent - Keep the passphrase in its own 1Password item. If you add it as an extra field on, say, the Hetzner API token's item, then replacing that item when you rotate the token takes your state key with it
If I were doing this again (which I did for every other provider after Hetzner), I'd turn encryption on before the first import and skip the whole migration dance.
Does the order of the files matter?¶
As things evolved, I refactored from having just main.tf and generated.tf to having the following *.tf files:
dns.tffirewalls.tfprimary_ips.tfproviders.tfrdns.tfservers.tfssh_keys.tfvariables.tfversions.tfzones.tf
Each one is named for the resources it's managing. This led me to wonder:
When I run
tofu planortofu apply, what order do the*.tffiles get processed in? Does it matter what I name them?
I did some research, and also asked Claude. Names don't affect the order. OpenTofu loads every .tf file in the directory, merges them into one configuration, and then works out the order from the references between blocks.
A little more detail on how that works:
- When you run
tofu planortofu apply, it reads every.tfand.tf.jsonfile in the current directory. It does NOT read subdirectories - It reads the files alphabetically, but that has no effect on the result. All of the blocks are merged as if they were in one file
- It then builds a dependency graph from the references between blocks. If
hcloud_server.webp1useshcloud_firewall.firewall.id, the firewall is handled first, no matter which file each one is defined in. Resources that don't depend on each other run in parallel, 10 at a time by default - If the graph misses a dependency, you add
depends_on. My Tailscale config does this so the ACL is applied before the auth key. Renaming or reordering files will never fix ordering
Here's the overall flow of a tofu plan or tofu apply:
flowchart LR
load["1 · Load and merge<br/>every .tf file"] --> readstate["2 · Read state<br/>from the backend"]
readstate --> deps["3 · Build the<br/>dependency graph"]
deps --> plan["4 · Plan: compare<br/>config, state, reality"]
plan --> apply["5 · Apply changes,<br/>write state back"]
This is also why, when I moved my state bucket's configuration from main.tf into s3.tf as part of my refactor, the plan showed no changes. A resource's address (aws_s3_bucket.resource_name) is the same no matter which file it's in.
That said, there are a few cases where file names DO matter:
- Override files: files named
override.tfor ending in_override.tfare loaded last. Their blocks are merged into blocks with the same name instead of being treated as duplicates. Avoid these unless you really mean to use them .tofufiles: OpenTofu also reads.tofuand.tofu.jsonfiles. Iffoo.tofuandfoo.tfboth exist, it usesfoo.tofuand ignoresfoo.tf- Test files:
.tftest.hcland.tofutest.hclfiles are only used bytofu test - Ignored files: editor temp files (names ending in
~or wrapped in#...#) are skipped, as is any other extension. My Tailscalepolicy.hujsononly gets read because I load it withfile() - Duplicates: a block defined twice is an error, no matter which files the two copies are in
A checklist for adding a new provider¶
After doing this for six providers, I wrote up a checklist so the next one doesn't involve rediscovering the same gotchas. It assumes you're adopting infrastructure that already exists, and that each provider gets its own root module with its own state. Each section starts with its Bail out criteria, so you can stop before doing work you'll throw away.
tofu commands run through op run --env-file=.env --, which is 1Password's runner; use your own secrets manager's equivalent.
1. Discover what exists¶
Bail out if the account is empty and you don't want a placeholder module, or if the account isn't yours. Infrastructure that belongs to an employer or an organization you volunteer for goes in that organization's repo.
Take inventory¶
- Check that your local credentials still work. A CLI that returns 401 may have a stale token, so get a fresh one before concluding anything.
-
List every resource type with read-only calls. Use the service's CLI or API, or the provider's own list data sources in a temporary
discover.tfwith anoutput. For Hetzner:data "hcloud_servers" "all" {} output "servers" { value = [for s in data.hcloud_servers.all.servers : { id = s.id, name = s.name }] }Run
op run --env-file=.env -- tofu apply(it only adds the output), thentofu output -json servers. Keep the output to IDs and names like this, and mark itsensitive = trueif you need fields that hold secrets. Deletediscover.tfafterwards; its outputs stay in state until the next apply removes them. - Note IDs, names, and how each resource relates to others (which project, server or app it belongs to). Note which resources are defaults the service creates for you.
Decide what to manage¶
- Skip defaults the service won't let you delete (default VPCs, default projects), empty accounts and leftover test resources. Records the service manages itself, like DNS
SOAandNSrecords, stay out too. - Decide which tool owns each resource. Two providers managing the same thing fight each other. For example, if one provider creates your servers, a deployment platform's provider should only register them. Resources another system creates become
datasources. - Note anything the provider can't manage, such as OAuth clients, consent screens or key material. Those stay manual, or you reference them as
datasources. Bare-metal hardware and what runs inside machines belong to a configuration tool like Ansible.
2. Vet the provider¶
Bail out if the important resources can't be imported, Read only checks that the resource exists, or the provider is unmaintained. Check these first:
- Find it on search.opentofu.org. If it isn't there, check the Terraform registry or search GitHub for
terraform-provider-<name>. A service with no public API can't have a provider. - Check maintenance: last release, last commit, open issues, and whether the repo is archived.
- Check import support in the provider source with
grep -rln ImportState --include='*.go'. The docs often leave it out. - For the resources that matter most, read
ImportStateandRead:- Does
ImportStateset every attribute thatReadneeds, such as the name? - Does
Readrefresh attributes, or only check that the resource exists? If it only checks existence, OpenTofu can't detect drift. - Does
Readreturn parent IDs (project, server)? If not, imports generate them asnulland you fill them in by hand.
- Does
Then note these, since they shape how you write the config:
- A
replacedirective ingo.modmeans the provider builds against someone's fork. Pin the version of anything pre-1.0. - Check which credentials the provider reads. Environment variables or Tofu variables let you inject them from a secrets manager instead of writing them to disk.
- Check whether the provider has several resource types for the same thing (for example, one per app source). The type has to match how the resource was created, or the plan forces a replacement.
- Check whether the provider offers several ways to express one relationship (for example, attaching a firewall from the server, from the firewall, or with a separate attachment resource). Pick one; two describing the same link cause a permanent diff.
3. Set up credentials¶
Create the token¶
- Store the credentials in a secrets manager. Use a dedicated token with the minimum permissions the provider needs. A CLI login token (like
gh auth token) has the access of your whole login and gets replaced when you log in again, which leaves the stored copy stale. - Give the token read access to sensitive fields if the provider compares them. With redacted reads, every plan shows phantom diffs. Avoid root or admin scopes the provider doesn't need.
- If the provider warns about token permissions, work out what the token can actually read. A token that can read config files can read the secrets in them, and imported values land in state. That's a reason to encrypt state.
- Keep the state passphrase in its own secrets-manager item. If it's an extra field on a provider's token item, replacing that item when you rotate the token takes the state key with it.
Use it safely¶
- Test the credentials once, interactively or with a single API call, before using them from OpenTofu. Don't retry failed logins blindly. Some services, Synology DSM among them, block an IP after repeated failures.
-
Never print secret values. To debug, print only their shape:
op read "op://Private/Example OpenTofu/token" | wc -c # length (+1 for op's trailing newline) op read "op://Private/Example OpenTofu/token" | sed 's/[^[:space:]]/*/g' | cat -et # stray spaces show before the $ -
Don't export credentials in your shell, and never in persistent shell variables (fish universal variables are stored in plain text). Inject them per command with your secrets manager's runner. With 1Password,
op run --env-file=.envreadsop://references from a.envfile (section 4) and resolves them for that one command only. - Remove anything that overrides the environment: a
profile = ...in the provider block, entries in~/.aws/credentials, exported shell variables. Providers prefer those over the injected values. Run the CLI the same way when you need it:op run --env-file=.env -- aws sts get-caller-identity.
4. Scaffold the module¶
Files¶
- Create a branch for the work.
- Name the directory after the provider, in lowercase (
hetzner/,digitalocean/). -
Split the config across
versions.tf,providers.tfandvariables.tf. Use the same names in every module, and skipmain.tf, so you always know where to look.versions.tfholds the OpenTofu and provider versions, the backend, and the state encryption. Turn encryption on now, before the first import; migrating existing unencrypted state is the dance from Encrypting my state.terraform { required_version = ">= 1.10" required_providers { example = { source = "example-org/example" version = "~> 1.4" } } backend "s3" { bucket = "my-tofu-state" key = "example/terraform.tfstate" region = "us-west-2" use_lockfile = true } encryption { key_provider "pbkdf2" "main" { passphrase = var.state_passphrase } method "aes_gcm" "main" { keys = key_provider.pbkdf2.main } state { method = method.aes_gcm.main enforced = true } plan { method = method.aes_gcm.main enforced = true } } }providers.tfholds the provider blocks. Leave them empty and let credentials come from the environment:provider "example" {}variables.tfholds every variable, starting with the passphrase:variable "state_passphrase" { type = string sensitive = true }Each module uses the same bucket with its own
key. Pin the provider with~>to the major and minor version you install, so a new release can't change behaviour without you noticing. -
Add one aliased
providerblock per extra region (for AWS, a secondprovider "aws"withalias = "use1"andregion = "us-east-1"). A resource in another region needsprovider = aws.use1on both its import block and its resource. - Keep
required_providersand each defaultproviderblock in exactly one file. A second copy is a "Duplicate provider configuration" error.
Secrets¶
-
Create a gitignored
.envthat holdsop://references, not the secrets themselves.op runresolves them at run time (see section 3):EXAMPLE_TOKEN=op://Private/Example OpenTofu/token TF_VAR_state_passphrase=op://Private/OpenTofu State/passphraseIf a
.envalready exists, add to it instead of overwriting it. Tools like direnv may also load a file named.envand set the literalop://strings as values. - Make sure.gitignorecovers.env,.terraform/,*.tfstate,*.tfstate.*and*.tfplan.
First run¶
op run --env-file=.env -- tofu init, thentofu fmt -check && tofu validate.initneedsop runtoo: the encryption block reads the passphrase variable, and without it init fails with "depends on var.state_passphrase which is not available". Re-runinitwheneverrequired_providers, the backend or encryption changes;init -upgradeto move to a newer provider version.op run --env-file=.env -- tofu planshould report "No changes". An empty plan doesn't log in, so the first import is the real auth test.- Prove the plan uses only the secrets manager: unset exported variables (
env -u VAR), hide CLI config files (AWS_SHARED_CREDENTIALS_FILE=/dev/null), and block CLI fallbacks (GH_CONFIG_DIR=$(mktemp -d)stops the GitHub provider using yourghlogin). - Commit
.terraform.lock.hclso every machine installs the same provider version.
5. Import resources¶
Bail out if the plan wants to replace or modify resources you can't safely ignore, or if the provider errors on import. Delete imports.tf and check with tofu state list that nothing was written to state.
Write the import blocks¶
-
Write
imports.tfwith oneimport {}block per resource:import { to = example_ssh_key.laptop id = "12345678" }Check the import ID format in the provider's
ImportState, because it may be a numeric ID, a UUID, a name or a composite (such aszone/name/typefor DNS record sets). - Pick resource names (the label after the type) by one rule and stick to it: -thisfor singletons the account has only one of (an ACL, DNS preferences). - Otherwise, the snake_case form of the real-world name, soexample_bucket.my_backupsfor a bucket calledmy-backups. Avoid nicknames and generic labels likeserverorfirewall; they stop making sense once there's a second one. - DNS records:<zone>_<record>_<type>, such asexample_www_cname.Names are hard to change later: renaming one in state takes a
moved {}block. - For many resources, generate the import blocks with a loop over the CLI's list output. Resource names can't contain dots,@or*, or start with a digit, so sanitize them (a prefix liker_, punctuation to_). - Start with low-risk resources and do one resource type at a time. Leave production resources (servers, VMs) until the low-risk imports come back clean.
Generate the config¶
- Run
op run --env-file=.env -- tofu plan -generate-config-out=generated.tf, but don't apply yet. - The output file must not already exist, and OpenTofu writes it even when the plan fails. If the plan errors, edit
generated.tfto fix the errors and run a plaintofu plan, rather than deleting it and generating it again. - If an import fails with "Cannot import non-existent remote object", the provider is looking in the wrong place: check the resource's region, the provider alias, and which account the credentials belong to (
aws sts get-caller-identityfor AWS). If those are right, plan again; it can be transient. - Commit the generated config before editing it with a script, so the edit is easy to review and undo.
Clean up the generated config¶
- Remove empty strings (
"") that fail validation. - Remove computed or read-only attributes.
- Remove one of each pair of mutually exclusive attributes the generator writes together (AWS S3 buckets get both
bucketandbucket_prefix). - Look for stale values, such as an old download URL or version, that would change the real resource if applied.
- Fill in parent IDs that came back
nullby looking them up in the service's API, and add them toignore_changes. Some force replacement if they differ. - Remove sensitive fields (webhook secrets, passwords) and add them to
ignore_changes. A generatednullwould wipe the real value on apply. - Leave config that contains literal secrets (like a compose file with a password in it) in the source system: use a placeholder value and
ignore_changes. - Set JSON-string attributes to the exact string the API returns.
jsonencode()sorts keys, which shows up as a diff when the provider compares raw text. - Give read-only resources (archived GitHub repos, for example)
lifecycle { ignore_changes = all }. The API rejects updates to them anyway. - If an attribute the provider fills with a default would cause a diff, put it in
lifecycle { ignore_changes = [...] }with a comment explaining why. For each ignored field, the service's UI is now the source of truth. - Split the config into one file per resource type, named in plural snake_case (
ssh_keys.tf,projects.tf, …). Delete the# __generated__header comments, runtofu fmt(generated config isn't always formatted), and deletegenerated.tf.
Plan and apply¶
- Plan again. With many resources, group the remaining diffs by attribute to see which fixes clear the most, and check whether the drifting resources share something, such as all being archived.
- Apply only when the plan shows N to import, 0 to add, 0 to change, 0 to destroy, with no "must be replaced" lines.
- If the plan shows resources "to add" that you know exist, OpenTofu isn't reading your state (wrong workspace, missing or unreadable state file). Don't apply; it would create duplicates.
op run --env-file=.env -- tofu apply- Delete
imports.tf. After the apply, the resources are in state and the import blocks do nothing. tofu planshould report "No changes".
6. Change and clean up (optional)¶
Before you start¶
- Run
tofu planand deal with any pending changes first so they don't mix with yours. - Protect resources you can't lose with
lifecycle { prevent_destroy = true }, plus the service's own protection where it exists (such as Hetzner'sdelete_protection). Turn off settings that delete resources as a side effect, such as auto-delete on IP addresses. Removing a resource block from config destroys the resource.
Making changes¶
- Swap hardcoded IDs and IPs for references to other resources. The plan should still show no changes.
- To rename a resource that's already in state, add a
moved {}block instead of a new import. - Some settings aren't standalone objects (reverse DNS is often a setting on an IP), so there's nothing to import; you create the resource and it overwrites the current value.
- Don't change the same object twice in one apply. Some APIs run one action per object at a time and reject the second with a 409 conflict. A failed apply may still have made the change, so check the service, then import the resource instead of creating it again.
- If an apply hangs, press Ctrl-C once so OpenTofu releases the state lock. Pressing it twice can leave the lock behind.
- After changing access control (ACLs, tags, firewall rules), test the systems that depend on it, not just the plan. In Tailscale, for example, tagging a device removes it from
autogroup:member, so rules that allowed members stop applying to it.
Deleting things¶
- To delete a resource that's now managed, remove it from config and run plan. Read the destroy plan before applying, because deletes can't be undone. Check what's inside first; a bucket may hold another project's state.
- Delete config files that are now empty.
7. Document and ship¶
- Add the module to your README: what it manages and which environment variables its
.envneeds. - Write down the provider quirks you hit (ignored fields, defaults you left unmanaged, import gotchas) so the next import doesn't rediscover them.
tofu fmt -checkin the module.- Commit, open a pull request, and merge once checks pass.
If you'd rather see it as a picture, here's the whole process as a flowchart.
Conclusion¶
This was one of those processes where I ended up going really deep down a rabbit hole or two. BUT, I did learn a lot and now feel more comfortable with the OpenTofu process within Django Commons, which was my goal at the start.
Thanks¶
A big thank you to Daniel Moran and Tim Schilling for reviewing an earlier draft of this post. Their feedback was invaluable. Any mistakes that remain are my own.
- There's a fun chicken-and-egg problem here: the bucket itself is managed by my
awsmodule, which also keeps its state in that bucket. If the bucket ever disappears, theawsmodule has to fall back to local state long enough to recreate it. ↩︎ - I did get a scary warning: "Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now." It seems to be directed more at teams, where something may change between the
planand theapply. Since it's just me, there's little risk here. ↩︎ - I actually saw this manifest on my Coolify server when it couldn't reach any of the servers via Tailscale any longer. It was a quick fix, but there was a bit of "Oh SHIT!" when Coolify stopped being able to interact with my hetzner servers! ↩︎
This post is part 3 of the "learnings-on-leave" series: