Automation

Install OpenTofu on Ubuntu 26.04 / 24.04

Ubuntu’s archive does not carry OpenTofu, so the way to install OpenTofu on Ubuntu is the project’s own APT repository at packages.opentofu.org. That is the method we’d run on any server or workstation. You get a single binary at /usr/bin/tofu that upgrades with the rest of the system, and the repository keeps every release back to the 1.6 alphas in case you need to pin one.

Original content from computingforgeeks.com - post 161444

Below, we add that repository by hand (the official installer script does the same in one command), then run a first project through init, plan, apply and destroy with providers that need no cloud account. After that we turn on OpenTofu’s state encryption for a project that already has a plaintext state, a migration that throws one confusing error and leaves one plaintext file behind. The APT install, first project, encryption migration and tofu test below were run on a fresh Ubuntu 26.04 VM with OpenTofu 1.13.8 in November 2026, and the install was repeated on Ubuntu 24.04.

Prerequisites

OpenTofu is one static binary, so your machine needs very little. It does need outbound HTTPS to more hosts than the package name suggests, because the package, the signing key and every provider come from separate places, and two of them redirect elsewhere. If the box sits behind an egress allowlist, these are the hosts we saw in use:

  • Ubuntu 26.04 LTS or 24.04 LTS on amd64, with a user that has sudo rights
  • About 115 MB free for the package, plus a provider cache in every project
  • curl, gnupg and ca-certificates (the repository step installs them)
  • packages.opentofu.org, which redirects to a CloudFront domain, for the APT repository
  • get.opentofu.org for the signing key and the installer script
  • registry.opentofu.org for provider metadata, plus github.com and release-assets.githubusercontent.com for the provider downloads

The standalone installer also calls api.github.com to find the latest release. Our test VM had 2 vCPUs and 4 GB of RAM. Treat that as a floor for following along rather than a sizing figure, because tofu itself barely registers on a box that size. What grows on a real build server is the .terraform directory in each project, and the two small providers used below already took 36 MB of it. If the server is brand new, work through our post-install checklist for Ubuntu servers first, so updates and SSH are sorted before you add a third-party repository.

Add the OpenTofu APT repository

The repository needs two keyrings and one sources file. Nothing goes into the global /etc/apt/trusted.gpg.d directory that older guides used, including the previous version of this one. A key in /etc/apt/keyrings is trusted only by the sources line that names it in signed-by, so a compromised OpenTofu key cannot vouch for packages from any other repository. On our box apt-cache policy tofu opentofu terraform printed nothing before this step, which confirms Ubuntu ships none of them.

If you followed the previous version of this guide, delete the two keys it placed in trusted.gpg.d, because apt trusts anything there for every repository. The sources file written below has the same name as the old one, so it simply replaces it:

sudo rm -f /etc/apt/trusted.gpg.d/opentofu.gpg /etc/apt/trusted.gpg.d/tofu.gpg

Refresh the package lists and install the tools the key import needs. On our 26.04 image only apt-transport-https was missing, and it is a transitional package these days, so the step mostly confirmed the rest were present:

sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg

An older 24.04 image does more here. Ours installed apt-transport-https and pulled 18 upgrades along with it, and the ca-certificates refresh printed rehash: warning: skipping ca-certificates.crt,it does not contain exactly one certificate or CRL. That warning is harmless, because the same run then reported 17 added, 43 removed; done. and every later HTTPS fetch worked.

Next, create the keyrings directory and fetch both keys. The first is OpenTofu’s own key, and the second belongs to packagecloud, which hosts the repository. We added --yes to the dearmor line, because without it a second run stops with gpg: dearmoring failed: File exists and exit code 2:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://get.opentofu.org/opentofu.gpg | sudo tee /etc/apt/keyrings/opentofu.gpg >/dev/null
curl -fsSL https://packages.opentofu.org/opentofu/tofu/gpgkey | sudo gpg --no-tty --batch --yes --dearmor -o /etc/apt/keyrings/opentofu-repo.gpg >/dev/null
sudo chmod a+r /etc/apt/keyrings/opentofu.gpg /etc/apt/keyrings/opentofu-repo.gpg

Before you trust a key you just piped from the internet, look at its fingerprint. These two commands print each key without importing it into any keyring:

gpg --show-keys --with-fingerprint /etc/apt/keyrings/opentofu.gpg
gpg --show-keys --with-fingerprint /etc/apt/keyrings/opentofu-repo.gpg

You should see the two fingerprints below. Note the one ending in 0C0A F313 E5FD 9F80, because you will meet it again when tofu downloads its first provider:

pub   rsa4096 2023-11-15 [SC]
      E3E6 E43D 84CB 852E ADB0  051D 0C0A F313 E5FD 9F80
uid                      OpenTofu (This key is used to sign opentofu providers) <[email protected]>
sub   rsa4096 2023-11-15 [E]

pub   rsa4096 2023-11-10 [SCEA]
      F4AF 70F6 6EAC 4337 EEEC  C974 07D3 DFCD 4C61 499F
uid                      https://packagecloud.io/opentofu/tofu (https://packagecloud.io/docs#gpg_signing) <[email protected]>
sub   rsa4096 2023-11-10 [SEA]

Now write the sources file and refresh apt. The suite is literally any rather than an Ubuntu codename, so the same line works the day a new LTS ships, with no wait for the vendor to add a suite for the new codename. This is the multi-line echo from the OpenTofu docs, which avoids a heredoc:

echo \
  "deb [signed-by=/etc/apt/keyrings/opentofu.gpg,/etc/apt/keyrings/opentofu-repo.gpg] https://packages.opentofu.org/opentofu/tofu/any/ any main
deb-src [signed-by=/etc/apt/keyrings/opentofu.gpg,/etc/apt/keyrings/opentofu-repo.gpg] https://packages.opentofu.org/opentofu/tofu/any/ any main" | \
  sudo tee /etc/apt/sources.list.d/opentofu.list > /dev/null
sudo chmod a+r /etc/apt/sources.list.d/opentofu.list
sudo apt-get update

Look for the two packages.opentofu.org lines at the end of the update. Everything above them is the usual Ubuntu mirror traffic:

Get:4 https://packages.opentofu.org/opentofu/tofu/any any InRelease [29.2 kB]
Get:6 https://packages.opentofu.org/opentofu/tofu/any any/main amd64 Packages [13.2 kB]
Fetched 42.4 kB in 3s (15.0 kB/s)
Reading package lists...

Both lines with no warning under them mean the keys and the sources file agree, and apt can now see the tofu package. If apt prints a signature warning there instead, it could not verify the repository with the keys named in signed-by. In that case check that both keyring files exist under /etc/apt/keyrings and that the chmod ran, then refresh again before you install anything. Here are both fingerprints and the refresh as they came out of the fresh 26.04 VM we rebuilt for this update:

OpenTofu and packagecloud key fingerprints and apt-get update fetching the OpenTofu repository on Ubuntu 26.04

The packages index was 12.4 kB on our October run. Since then apt-cache madison tofu lists four newer 1.13 builds above 1.13.1 (1.13.2, 1.13.6, 1.13.7 and 1.13.8) plus the 1.12.8, 1.12.9 and 1.12.11 patches. A repository that keeps every old build is what makes the pin-and-downgrade trick near the end of this guide possible.

Install OpenTofu on Ubuntu

Install the package, which is called tofu, not opentofu. It’s a 35.1 MB download that unpacks to 114 MB, almost all of it the one binary, and it pulls in no dependencies:

sudo apt-get install -y tofu

apt resolves the package straight from the any suite. On our box it fetched tofu_1.13.8_amd64.deb in three seconds, and the only new package in the transaction was tofu itself:

sudo apt-get install -y tofu installing OpenTofu 1.13.8 from packages.opentofu.org on Ubuntu 26.04

If apt offers an older build than the newest one on the OpenTofu releases page, your package lists are stale, so run sudo apt-get update again before you go further.

Check the version, the binary’s path and where apt will take upgrades from. The last command matters later too, because its version table is the list you pin from:

tofu version
tofu -v
which tofu
apt-cache policy tofu

You should get the same answers as our test box in the capture below. tofu version reports the release and the platform, and on 1.13.8 the short -v prints the same two lines again, as do -version and --version. That matters for scripts and CI checks that call tofu -v, because earlier 1.13 builds reject it (more on that in the upgrade section). The binary sits in /usr/bin, which matters if you ever mix install methods. In the policy output, Installed and Candidate match, the candidate comes from packages.opentofu.org at priority 500, and every older release is listed underneath it.

tofu version, tofu -v and apt-cache policy tofu showing OpenTofu 1.13.8 installed from the official APT repository on Ubuntu 26.04

For this update we also ran the repository steps and the install on a fresh Ubuntu 24.04.4 VM (kernel 6.8). It pulled the same tofu 1.13.8 package from the same any suite, the same 35.1 MB download unpacking to 114 MB, and tofu version, tofu -v and which tofu matched 26.04 line for line. The project, the encryption migration, the encrypted plan and tofu test were replayed on 24.04 in October with 1.13.1 and behaved identically to 26.04, taking 48 seconds without a failed command, so nothing below needs a separate 24.04 variant.

Tab completion is one more command. It edits only your own shell profile, so run it as your normal user rather than with sudo:

tofu -install-autocomplete

Your ~/.bashrc gets one new line, complete -C /usr/bin/tofu tofu, so completion starts in the next shell you open, where tofu pl plus Tab completes to plan. A second run prints 1 error occurred: * already installed in /home/josphat/.bashrc but still exits 0, which means you can leave it in a provisioning script that runs on every build without breaking it.

Use the installer script, snap or zip instead

We tested all three alternatives on the 26.04 VM, and each one installed a working tofu. The APT repository still wins for most boxes because it upgrades and pins through apt like every other package you run. If the same machine also needs HashiCorp’s own binary, our Terraform and OpenTofu install guide covers both side by side. Otherwise, each alternative below has one case where it is the better pick:

  • Installer script with --install-method deb: the same repository, keys and sources file, set up in one command
  • Snap: desktops that already run snaps, on the stable channel only
  • Standalone zip: a specific version in a path you choose, with no repository added

Of the three, the installer script is the quickest. It is about 50 KB of shell that asks for sudo, so download it and read it before you run it, rather than piping it straight into a shell:

curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method deb
rm -f install-opentofu.sh

In our October run it took 13.2 seconds, installed apt-transport-https, and wrote the same two keyrings and opentofu.list as the manual steps, so the result is identical and the rest of this guide applies unchanged. Our run ended on Checking if OpenTofu is installed correctly... without printing a version, though, so follow it with tofu version yourself.

We’d only use the snap on a desktop. sudo snap install --classic opentofu took 16.6 seconds in our October run, partly because it was the first snap on that VM and snapd refreshed itself first. Check the channels before you trust anything but stable:

snap info opentofu

The stable channel tracks the current release closely, and it carried 1.13.8 with the same 2026-11-04 date as the GitHub release. The candidate and beta channels are another story, because they still point at a February 2024 build. The edge channel holds a beta from October 2025, so stable is the only channel worth installing:

channels:
  latest/stable:    1.13.8       2026-11-04 (286) 27.6MB classic
  latest/candidate: 1.6.2        2024-02-22  (91) 19.0MB classic
  latest/beta:      1.6.2        2024-02-22  (91) 19.0MB classic
  latest/edge:      1.11.0-beta1 2025-10-23 (188) 26.4MB classic

Snap puts its tofu in /snap/bin, which sits after /usr/bin in PATH, so the apt package wins if you have both. Our snap, Flatpak and APT comparison covers the general trade-off. For a tool that has to match across a team, apt and a pinned version are easier to reason about than a channel that refreshes on its own schedule.

The standalone method uses the same installer script with a different flag. It downloads the release zip, checks its SHA256 sum and GPG signature, and unpacks it into /opt/opentofu with a symlink in /usr/local/bin. It needs unzip, which a stock Ubuntu 26.04 cloud image doesn’t have, so the block installs that first and works on its own:

sudo apt-get install -y unzip
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method standalone
rm -f install-opentofu.sh

The installer finished in about 3 seconds in both of our October runs and ended by printing the version, and two details matter if you run it next to the apt package. The files in /opt/opentofu end up owned by the user who ran the script, not root. And because /usr/local/bin comes before /usr/bin in PATH, the standalone copy silently shadows the apt one: which -a tofu listed /usr/local/bin/tofu first on our box, so apt upgrades would have kept landing in a binary nobody runs. Pick one method per machine. To go back to apt alone, we deleted /opt/opentofu and the symlink, then ran hash -r so the shell forgot the old path.

Unzip is missing, please install it to use the standalone installation method

A stock Ubuntu 26.04 cloud image has no unzip. If you run the standalone method without it, the installer stops on that message with exit code 1 before it downloads anything, which is why the block above installs it first. On its own, the fix is:

sudo apt-get install -y unzip

Only the standalone method needs it, because the deb method installs through apt and never unpacks a zip. That is one more reason to stay on the repository unless you have a specific case for the zip.

Write a first OpenTofu project

A first project should prove the whole loop without a cloud account, so this one uses the random and local providers plus the built-in terraform_data resource. It names a few web hosts, generates a database password and writes an inventory file in the INI format Ansible reads. That is enough to watch OpenTofu create, update, replace and destroy things. The project files belong to your user, so create the directory under your home and edit them without sudo, starting with the version constraints:

mkdir -p ~/tofu-demo && cd ~/tofu-demo
vim versions.tf

Each ~> constraint accepts newer minor releases of its provider but never a new major version. Short names like hashicorp/random resolve against registry.opentofu.org, which the lock file records later:

terraform {
  required_version = ">= 1.10.0"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
    local = {
      source  = "hashicorp/local"
      version = "~> 2.9"
    }
  }
}

Variables go in their own file, and web_count gets a validation block. A rule like this rejects a bad value at plan time, before anything is created, and the message you write is exactly what a teammate sees when they pass the wrong number:

vim variables.tf

Add both variables. Leave the defaults as they are, because the later steps override web_count from the command line:

variable "environment" {
  description = "Environment name used in resource names"
  type        = string
  default     = "lab"
}

variable "web_count" {
  description = "Number of web hosts to generate"
  type        = number
  default     = 2

  validation {
    condition     = var.web_count >= 1 && var.web_count <= 5
    error_message = "web_count must be between 1 and 5."
  }
}

Resources go in main.tf. The inventory gives each host an address from 10.0.1.50 upward, and terraform_data records how many hosts the last run built, so a change in host count shows up as an in-place update you can watch:

vim main.tf

Paste the four resources. None of them talks to a remote API, so plans and applies stay fast and cost nothing:

resource "random_pet" "web" {
  count  = var.web_count
  length = 2
  prefix = var.environment
}

resource "random_password" "db" {
  length  = 20
  special = true
}

resource "local_file" "inventory" {
  filename        = "${path.module}/out/inventory.ini"
  file_permission = "0644"
  content         = join("\n", concat(["[web]"], [for i, pet in random_pet.web : "${pet.id} ansible_host=10.0.1.${50 + i}"], [""]))
}

resource "terraform_data" "build" {
  input = {
    environment = var.environment
    hosts       = length(random_pet.web)
  }
}

Outputs come last. Marking db_password as sensitive hides it on screen, and only on screen, which is the whole reason the encryption section exists:

vim outputs.tf

Add the four outputs. Each one becomes a value you can read later with tofu output, from a shell or a script:

output "web_hosts" {
  description = "Generated web host names"
  value       = random_pet.web[*].id
}

output "inventory_file" {
  value = local_file.inventory.filename
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true
}

output "build_info" {
  value = terraform_data.build.output
}

Initialize the directory next. tofu init downloads the providers into .terraform and writes the lock file, which took 1.2 seconds here:

tofu init

Read the two Installed lines closely. The key ID at the end of each one is the part to check:

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to OpenTofu
- Finding hashicorp/local versions matching "~> 2.9"...
- Finding hashicorp/random versions matching "~> 3.9"...
- Installing hashicorp/random v3.9.1...
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/random v3.9.1 (signed, key ID 0C0AF313E5FD9F80)
- Installed hashicorp/local v2.9.1 (signed, key ID 0C0AF313E5FD9F80)

Providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://opentofu.org/docs/cli/plugins/signing/

OpenTofu has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that OpenTofu can guarantee to make the same selections by default when
you run "tofu init" in the future.

OpenTofu has been successfully initialized!

That key ID is the tail of the OpenTofu fingerprint you checked when adding the repository, so the providers are signed with the same key you already placed in /etc/apt/keyrings. Commit .terraform.lock.hcl, because it pins the exact provider versions and hashes for everyone who runs the project. Keep .terraform out of Git, since it is the 36 MB provider cache that tofu init rebuilds on any machine. Before the first plan, run the two cheap checks that belong in CI later:

tofu fmt -check
tofu validate

With the files exactly as above, fmt -check prints nothing and validate prints Success! The configuration is valid. The format check earns its place in a pipeline. When we fed it a main.tf with misaligned = signs, it printed main.tf and exited 3, so the CI step fails without anyone reading a diff. A plain tofu fmt then rewrote the file in place.

Plan, apply and destroy the project

A plan shows what OpenTofu would change without changing anything. Keep the habit even on throwaway projects, because the summary line is where a replacement or a destroy shows up before it happens:

tofu plan

Expect all five resources in the full plan. Read the terraform_data input, the summary line and the outputs, where the password already shows as sensitive:

  # terraform_data.build will be created
  + resource "terraform_data" "build" {
      + id     = (known after apply)
      + input  = {
          + environment = "lab"
          + hosts       = 2
        }
      + output = (known after apply)
    }

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

Changes to Outputs:
  + build_info     = (known after apply)
  + db_password    = (sensitive value)
  + inventory_file = "./out/inventory.ini"
  + web_hosts      = [
      + (known after apply),
      + (known after apply),
    ]

Apply it. -auto-approve skips the yes prompt, which is fine on a lab project and something you would drop on anything that matters:

tofu apply -auto-approve

On our VM the whole apply took under half a second (0.475 s). Your random pet names will differ from ours:

random_pet.web[1]: Creating...
random_pet.web[0]: Creating...
random_pet.web[1]: Creation complete after 0s [id=lab-awaited-flamingo]
random_pet.web[0]: Creation complete after 0s [id=lab-vocal-locust]
random_password.db: Creating...
terraform_data.build: Creating...
terraform_data.build: Creation complete after 0s [id=93f6302c-ba0d-fa07-d7ad-bd448aacd5b6]
local_file.inventory: Creating...
local_file.inventory: Creation complete after 0s [id=8ae43afca00696828c6aa57a336da2c230bd2bef]
random_password.db: Creation complete after 0s [id=none]

Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

Outputs:

build_info = {
  "environment" = "lab"
  "hosts" = 2
}
db_password = <sensitive>
inventory_file = "./out/inventory.ini"
web_hosts = [
  "lab-vocal-locust",
  "lab-awaited-flamingo",
]

Read back the generated file, since it is the real deliverable of this project:

cat out/inventory.ini

Each host gets one line, ready to hand to an Ansible inventory. The file came out at 90 bytes with mode 0644, exactly as main.tf asked:

[web]
lab-modern-bird ansible_host=10.0.1.50
lab-boss-lion ansible_host=10.0.1.51

Here are that apply and the generated file as they looked on our test box, run from the tofu-demo directory:

tofu apply with the random and local providers and the generated inventory file in the tofu-demo project with OpenTofu 1.13.8 on Ubuntu 26.04

Now try a value the validation rule should catch. Nine hosts is outside the 1 to 5 range, so the plan has to fail before anything is touched, and the command exits 1:

tofu plan -var web_count=9

Your own message appears in the error, along with the variable, its value and the line of the rule:

Planning failed. OpenTofu encountered an error while generating this plan.

â•·
│ Error: Invalid value for variable
│
│   on variables.tf line 7:
│    7: variable "web_count" {
│     ├────────────────
│     │ var.web_count is 9
│
│ web_count must be between 1 and 5.
│
│ This was checked by the validation rule at variables.tf:12,3-13.
╵

A legal change shows the three kinds of update OpenTofu makes. Scale to three hosts:

tofu apply -var web_count=3 -auto-approve

The summary line reports 2 added, 1 changed and 1 destroyed. The two additions are the third pet name and a new inventory file. The one change is terraform_data.build, updated in place because only its input moved. The one destruction is the old inventory file, because local_file cannot edit its content in place and replaces the whole resource instead. On real infrastructure that last category is the one to read slowly in every plan, since destroy and recreate on a database or a disk means downtime or lost data.

Tear the project down once you have read the summary. Pass the same variable you applied with:

tofu destroy -auto-approve -var web_count=3

The destroy removes all six resources, and tofu state list comes back empty afterwards. Keep the directory, though, because the next section reuses it to show what an existing plaintext state looks like and how to encrypt it.

Encrypt the OpenTofu state file

Terraform’s own documentation says it stores state in a plaintext file and leaves encryption at rest to the backend. OpenTofu can encrypt the state and plan files itself, with a key derived from a passphrase, before any backend sees them. On a project that already has a plaintext state, work in this order, so a readable copy exists until the encrypted state has proven itself:

  1. Copy the plaintext state somewhere safe
  2. Apply a migration config with a fallback
  3. Switch to the final config with encryption enforced
  4. Plan from a fresh shell to prove the state decrypts
  5. Delete the plaintext backup and the copy

Most readers arrive here with a plaintext state already, so start from that situation. Recreate the resources with a normal, unencrypted state:

tofu apply -auto-approve

Your terminal shows db_password = <sensitive> again, but that only hides the value on screen. This one-liner reads the password straight out of terraform.tfstate and prints only its length, so you can see the problem without printing a secret:

python3 -c 'import json; s=json.load(open("terraform.tfstate")); print("db_password in state:", len(s["outputs"]["db_password"]["value"]), "plaintext chars")'

It reports all 20 characters of db_password sitting in the file as plaintext. Anyone who can read that file, or the bucket it gets pushed to, has the password. The encryption key comes from a passphrase, and the OpenTofu encryption docs are blunt that encrypted state and plan files are unrecoverable without it. So pick one of at least 16 characters and save it in your password manager or CI secret store before you use it. Then load it with read, which keeps it off the command line and out of your shell history:

read -rsp "State passphrase: " TF_VAR_state_passphrase; echo
export TF_VAR_state_passphrase

The prompt doesn’t echo what you type. Every later command in this section expects the variable in your shell, so run the same two lines again if you open a new terminal. In a CI job, set TF_VAR_state_passphrase from the pipeline’s secret store instead.

Before you change anything, copy the plaintext state somewhere safe. The docs ask for exactly this temporary backup, because a typo in the passphrase or a lost key during the migration would otherwise leave you without a readable state. Keep the copy readable by your user only:

cp terraform.tfstate ~/tofu-demo.tfstate.pre-encryption
chmod 600 ~/tofu-demo.tfstate.pre-encryption

That copy holds the same secrets in plaintext, so move it off a shared box, and plan to delete it at the end once the encrypted state has been read back successfully.

failed to write backup file: encountered unencrypted payload without unencrypted method configured

Once encryption is configured, OpenTofu refuses to read plaintext unless you allow it. If you add an encryption block with no fallback to a project like this one, the very next tofu plan stops with this error:

â•·
│ Error: Error acquiring the state lock
│
│ Error message: failed to write backup file: encountered unencrypted payload
│ without unencrypted method configured
│
│ OpenTofu acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.
╵

Despite the Error acquiring the state lock title, nothing is wrong with the lock. OpenTofu failed while writing its backup copy, because it read a plaintext state and no configured method is allowed to accept one. The fix is the migration form of the config, which declares an unencrypted method and uses it as a fallback for both state and plan:

vim encryption.tf

Paste the migration version. Compared with the final file further down, it adds the unencrypted method and the two fallback blocks and leaves out enforced:

variable "state_passphrase" {
  description = "Passphrase for state and plan encryption (min. 16 characters)"
  type        = string
  sensitive   = true
}

terraform {
  encryption {
    method "unencrypted" "migrate" {}

    key_provider "pbkdf2" "main" {
      passphrase = var.state_passphrase
    }

    method "aes_gcm" "main" {
      keys = key_provider.pbkdf2.main
    }

    state {
      method = method.aes_gcm.main
      fallback {
        method = method.unencrypted.migrate
      }
    }

    plan {
      method = method.aes_gcm.main
      fallback {
        method = method.unencrypted.migrate
      }
    }
  }
}

Apply once with the passphrase loaded. Expect a warning, because the unencrypted method is still in the config:

tofu apply -auto-approve

More surprising is that the apply reports no changes yet still rewrites the state, now encrypted:

â•·
│ Warning: Unencrypted method configured
│
│   on encryption.tf line 8, in terraform:
│    8:   encryption {
│
│ Method unencrypted is present in configuration. This is a security risk and
│ should only be enabled during migrations.
╵
random_pet.web[0]: Refreshing state... [id=lab-light-coyote]
random_pet.web[1]: Refreshing state... [id=lab-exciting-wildcat]
random_password.db: Refreshing state... [id=none]
terraform_data.build: Refreshing state... [id=f294c09c-df54-de98-fb53-6bbb8d3a7b22]
local_file.inventory: Refreshing state... [id=415a50807a7cb2627551515274e255385a3baa36]

No changes. Your infrastructure matches the configuration.

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

Don’t take the apply’s word for it. Check the first bytes of the state file:

head -c 300 terraform.tfstate; echo

Only the serial, the lineage, the key metadata and the encryption version stay readable. Everything else, resources and outputs included, is inside encrypted_data:

{"serial":4,"lineage":"9f210098-e885-1416-4557-10efa08805eb","meta":{"key_provider.pbkdf2.main":"eyJzYWx0IjoicUtqeXJPdnNWSG5kd2MwYXFUSFR4UG8vTEdJRThMV2JFRWIvOFFwZkVvcz0iLCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ3RoIjozMn0="},"encrypted_data":"11U3fncRTR03x1oGWLVHbfCdrz

Decoding the base64 meta value shows the key derivation settings OpenTofu stores next to the data:

python3 -c "import json,base64; d=json.load(open(\"terraform.tfstate\")); print(base64.b64decode(d[\"meta\"][\"key_provider.pbkdf2.main\"]).decode()); print(\"encryption_version:\", d[\"encryption_version\"])"

You get a random salt, 600,000 PBKDF2 iterations with SHA-512 and a 32-byte key, which are the defaults the OpenTofu docs list.

{"salt":"qKjyrOvsVHndwc0aqTHTxPo/LGIE8LWbEEb/8QpfEos=","iterations":600000,"hash_function":"sha512","key_length":32}
encryption_version: v0

Because those settings travel inside the file, OpenTofu rebuilds the key from your passphrase alone on the next run. The metadata is filed under key_provider.pbkdf2.main, the name from your config, which is why the docs warn you not to rename key providers or methods once data is encrypted.

Remove the fallback and enforce encryption

Leaving the fallback in place would let a plaintext state back in, so replace the file with the final form. It drops the unencrypted method and adds enforced = true, which the docs suggest considering once the migration is done, because it stops OpenTofu writing unencrypted state or plan data:

vim encryption.tf

Replace the contents with the final version. It is the migration file minus the unencrypted method and the fallbacks, plus the two enforced lines:

variable "state_passphrase" {
  description = "Passphrase for state and plan encryption (min. 16 characters)"
  type        = string
  sensitive   = true
}

terraform {
  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
    }
  }
}

Now prove the encrypted state can be read with nothing left over from the migration. Open a new terminal, load the passphrase from your password manager the same way, and plan:

cd ~/tofu-demo
read -rsp "State passphrase: " TF_VAR_state_passphrase; echo
export TF_VAR_state_passphrase
tofu plan

No warning appears this time, just No changes. Your infrastructure matches the configuration. That line is the proof you need before deleting any plaintext copy. Every command now pays for the key derivation, though, so measure what that costs on your hardware before you put it in a pipeline:

time tofu state list >/dev/null

On our 2 vCPU VM it reported real 0m1.648s for a plain state list, against under half a second for the entire first apply without encryption. That is a fair price for one command, but it adds up in a pipeline that calls tofu output a dozen times. Read the outputs once with tofu output -json and reuse the result, so you pay the 600,000 iterations once per job instead of once per value.

Saved plans are covered by the plan block, which matters because a plan file contains the same secrets as the state. Save one and ask file what it is:

tofu plan -out=tfplan
file tfplan

The file command reports JSON text data. The plan has become the same envelope as the state, with the PBKDF2 metadata on top and the plan itself sealed inside. An encrypted plan like this applied without complaint through tofu apply tfplan, as long as the passphrase was set.

Delete the plaintext copies last

Only terraform.tfstate gets encrypted by the migration. A terraform.tfstate.backup that existed before it stays plain JSON through the migration apply and the final plan, and so does the copy you made. Whether there is a backup at all depends on the project’s history. A test project with a single earlier apply had none, and in the walkthrough above it held only the empty state left by the earlier destroy. Check yours without printing it:

ls -l terraform.tfstate terraform.tfstate.backup
head -c 120 terraform.tfstate.backup; echo

Our November replay of the walkthrough on 1.13.8 matched that description exactly. The backup was 309 bytes, held the empty post-destroy state and carried "terraform_version": "1.13.8". The dangerous case is a project with more history. On a second test project from our October run on 1.13.1, with two plain applies behind it, the backup was still the older plaintext state after the final plan, and a length check counted all 20 characters of db_password in it:

-rw-rw-r-- 1 josphat josphat 8418 Oct 15 08:01 terraform.tfstate
-rw-rw-r-- 1 josphat josphat 5671 Oct 15 08:00 terraform.tfstate.backup
{
  "version": 4,
  "terraform_version": "1.13.1",
  "serial": 1,
  "lineage": "6d7a3464-31c7-0776-b3ca-ed8f640d2b55",

With the final-config plan passed in a fresh shell, delete both plaintext copies. If ls showed no backup file, leave it out of the command, and if you moved the pre-encryption copy elsewhere, delete it there instead:

rm terraform.tfstate.backup ~/tofu-demo.tfstate.pre-encryption

The next apply that changes something writes a new backup, and on our test project that one was encrypted, starting with {"serial":2,"lineage": instead of plain JSON. Copies your backup jobs took before the migration are a different matter, because they are still plaintext and need the same cleanup.

OpenTofu encrypts before the state leaves the machine. That means the same encryption block applies when state lives in a remote backend such as Consul, although we only tested the local backend here. When you’re done with the lab, destroy the resources with the passphrase still loaded, since a destroy has to read the encrypted state too:

tofu destroy -auto-approve

All five resources are destroyed, and the state file stays encrypted with an empty state inside. You still need the passphrase to read it, so keep the passphrase for as long as you keep the directory.

Fix state encryption passphrase errors

We provoked three passphrase failures on purpose, using a wrong one, a missing one and one that was too short. Each stopped OpenTofu before it touched a resource, but the messages don’t always say “passphrase”, so the headings below quote the exact text to search for.

failed to write backup file: decryption failed for all provided methods

A wrong passphrase derives a different key. AES-GCM’s authentication check then fails, and OpenTofu refuses to use the state:

Acquiring state lock. This may take a few moments...
â•·
│ Error: Error acquiring the state lock
│
│ Error message: failed to write backup file: decryption failed for all
│ provided methods
│ attempted decryption failed for state: decryption failed: cipher: message
│ authentication failed
│
│ OpenTofu acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.
╵

Set TF_VAR_state_passphrase to the value the state was encrypted with and rerun. You will see the same message when a teammate encrypted the state with a passphrase you were never given. If nobody has that value any more, the state cannot be recovered, which is why it belongs in a secret store from day one rather than in someone’s shell history.

Error: Unable to compute static value

The detail line names the culprit: encryption.key_provider.pbkdf2.main depends on var.state_passphrase which is not available. It means the variable was not set at all and OpenTofu could not ask for it. With -input=false, which is how a CI job usually runs, each of these two errors prints twice:

â•·
│ Error: Failed to request input from user for variable var.state_passphrase
│
│   on encryption.tf line 1:
│    1: variable "state_passphrase" {
│
╵
â•·
│ Error: Unable to compute static value
│
│   on encryption.tf line 8, in terraform:
│    8:   encryption {
│
│ encryption.key_provider.pbkdf2.main depends on var.state_passphrase which
│ is not available
╵

In an interactive terminal, tofu plan prompts for var.state_passphrase and doesn’t echo what you type, but tofu state list never prompts and fails with these same errors. So load the variable before you run any tofu command in an encrypted project, interactive or not. A passphrase under 16 characters fails even earlier, with key_provider.pbkdf2.main failed with error: passphrase is too short (minimum 16 characters). Lengthen it rather than looking for a setting to lower the limit, because the docs set 16 as the minimum.

Test the project with tofu test

A test file turns “I looked at the plan” into a check that runs in CI and fails the job when someone breaks the module. The one below runs against a copy of the first project’s four files, without encryption.tf, which is how we tested it. OpenTofu picks up test files from the current directory or a tests directory, ending in .tftest.hcl, .tftest.json, .tofutest.hcl or .tofutest.json, and we used the first combination:

mkdir -p ~/tofu-test-demo/tests
cp ~/tofu-demo/versions.tf ~/tofu-demo/variables.tf ~/tofu-demo/main.tf ~/tofu-demo/outputs.tf ~/tofu-test-demo/
cd ~/tofu-test-demo
vim tests/inventory.tftest.hcl

Paste the test file, which sets its own variables and defines three runs:

variables {
  environment = "test"
  web_count   = 3
}

run "plan_has_three_hosts" {
  command = plan

  assert {
    condition     = length(random_pet.web) == 3
    error_message = "Expected 3 web hosts."
  }
}

run "inventory_lists_every_host" {
  command = apply

  assert {
    condition     = length(regexall("ansible_host=", local_file.inventory.content)) == 3
    error_message = "Inventory should have one line per host."
  }

  assert {
    condition     = startswith(output.web_hosts[0], "test-")
    error_message = "Host names must start with the environment name."
  }
}

run "rejects_too_many_hosts" {
  command = plan

  variables {
    web_count = 9
  }

  expect_failures = [var.web_count]
}

Each run covers a different kind of check:

  • plan_has_three_hosts: a plan-only assertion on the resource count
  • inventory_lists_every_host: a real apply, then checks on the file and an output
  • rejects_too_many_hosts: passes only if the validation rule fires

Of the three, the negative run is the one people forget. Testing that bad input fails is how you know the validation rule still exists after someone refactors variables.tf, so keep one negative case per rule. Initialize the copy and run the tests:

tofu init
tofu test

Init prints the same provider lines as before. The test output is what to read, and the whole run took 1.1 seconds:

tests/inventory.tftest.hcl... pass
  run "plan_has_three_hosts"... pass
  run "inventory_lists_every_host"... pass
  run "rejects_too_many_hosts"... pass

Success! 3 passed, 0 failed.

During the apply run, tofu test created real files and destroyed them again afterwards, as the tofu test docs describe, so the only trace left was an empty out directory. To see a failure, we changed the expected count in the second run to 4. The output names the run, the expression, the actual value and the diff, and the command exits 1, which is what fails the CI job:

tests/inventory.tftest.hcl... fail
  run "plan_has_three_hosts"... pass
  run "inventory_lists_every_host"... fail
  run "rejects_too_many_hosts"... pass
â•·
│ Error: Test assertion failed
│
│   on tests/inventory.tftest.hcl line 19, in run "inventory_lists_every_host":
│   19:     condition     = length(regexall("ansible_host=", local_file.inventory.content)) == 4
│     ├────────────────
│     │ local_file.inventory.content is "[web]\ntest-clean-leech ansible_host=10.0.1.50\ntest-set-sturgeon ansible_host=10.0.1.51\ntest-pure-possum ansible_host=10.0.1.52\n"
│     ├────────────────
│     │ Diff:
│     │     3 -> 4
│
│ Inventory should have one line per host.
╵

Failure! 2 passed, 1 failed.

The passing run looked like this in the tofu-test-demo directory on our test box:

tofu test passing all three runs of inventory.tftest.hcl in the tofu-test-demo project with OpenTofu 1.13.8 on Ubuntu 26.04

From here the next step is a provider with real infrastructure behind it. Our guide to deploying OpenStack VMs with Terraform modules makes a good next project, since OpenTofu reads the same HCL. Swap each terraform command for tofu as you go, and run tofu init first so the providers come from the OpenTofu registry.

Upgrade, pin and remove OpenTofu

Because tofu comes from an APT repository, it upgrades with the rest of the system, and nothing else tells you an update is waiting. The older release we installed to test this printed no out-of-date notice from tofu version, so check the apt way:

sudo apt-get update
apt list --upgradable 2>/dev/null | grep tofu

A pending update shows up as tofu/any 1.13.8 amd64 [upgradable from: 1.13.1], with the new version first and yours in brackets. Upgrade only tofu, leaving the rest of the system alone, then confirm the version:

sudo apt-get install -y --only-upgrade tofu
tofu -v

We ran exactly that on the lab VM after pinning it back to 1.13.1, the release the previous version of this guide was tested on. apt unpacked 1.13.8 over 1.13.1 and touched nothing else:

apt list --upgradable showing tofu 1.13.8 upgradable from 1.13.1 and apt-get install --only-upgrade tofu on Ubuntu 26.04

Note the line count: one upgraded package and nothing removed. tofu has no dependencies to drag along, so this upgrade is safe to run mid-project, provided nobody is in the middle of an apply against the same state.

Move to 1.13.2 or later

If tofu version prints 1.13.0, 1.13.1 or anything below 1.12.9, upgrade now rather than at the next maintenance window. 1.13.2 carried two security advisories, and both affect how tofu fetches providers and modules. tofu init could burn excessive CPU when it fetched dependencies from an attacker-controlled HTTP/2 server (#4681). The same release also started filtering out compromised intermediate certificates (#4750). Neither changes how your configuration behaves, so the upgrade is a package swap with no HCL edits. Teams pinned to the 1.12 line got the HTTP/2 fix in 1.12.8 and the certificate fix in 1.12.9, so anything from 1.12.9 up has both.

There is a smaller reason too. 1.13.0 broke the -v short flag, and 1.13.8 lists the fix (#4769) along with a fix for a panic on invalid state encryption configuration (#4783). On our box 1.13.1 and 1.13.2 printed the usage text, failed with flag provided but not defined: -v and exited 1, which is enough to break a CI step that greps tofu -v:

tofu -v failing with flag provided but not defined: -v and exit code 1 on OpenTofu 1.13.1 on Ubuntu 26.04

The APT repository also carries 1.13.6 and 1.13.7 builds that have no GitHub release page of their own, and both already answered -v correctly when we installed them. Take 1.13.8 regardless, since it is the release the changelog documents.

Hold or pin a release

Once you’re on a fixed release, a box that several people or a CI runner share usually wants the opposite of automatic upgrades. The CLI should stay put until you decide to move it, so everyone runs the same version against the same state. apt-mark hold does that, showhold lists what is held, and unhold releases it when you’re ready:

sudo apt-mark hold tofu
apt-mark showhold
sudo apt-mark unhold tofu

apt answers with tofu set on hold., lists tofu under the held packages, and confirms the release with Canceled hold on tofu. To run one specific release instead, for example to match what a team already uses or to back out of a bad upgrade, pick it from the apt-cache policy tofu list and install it by version. --allow-downgrades is only needed when a newer version is already installed:

sudo apt-get install -y --allow-downgrades tofu=1.13.2 #https://github.com/opentofu/opentofu/releases

apt says so explicitly, listing tofu as DOWNGRADED and ending on Setting up tofu (1.13.2) ..., so there’s no doubt which binary you have. Follow it with the hold above, otherwise the next routine upgrade quietly undoes the pin. 1.13.2 is only the example we tested: it still rejects tofu -v, so pin your team’s release, and when you are done with an old one run sudo apt-mark unhold tofu and the upgrade command above to get back to 1.13.8.

Remove OpenTofu

To remove OpenTofu cleanly, undo each thing the install added. That means the completion line, the package, then the repository and both keys. Destroy or move any state you still need first, because purging the package does not touch your project directories:

tofu -uninstall-autocomplete
sudo apt-get purge -y tofu
sudo rm -f /etc/apt/sources.list.d/opentofu.list /etc/apt/keyrings/opentofu.gpg /etc/apt/keyrings/opentofu-repo.gpg
sudo apt-get update

Our purge freed 114 MB and /etc/apt/keyrings was empty again afterwards. The one leftover was an empty ~/.terraform.d directory that tofu creates on first run. Remove it with rmdir ~/.terraform.d, never rm -rf: the OpenTofu CLI config docs use ~/.terraform.d/plugins as a provider mirror and ~/.terraform.d/plugin-cache as the example plugin cache, and rmdir refuses with Directory not empty if anything like that is inside. Anything else under your home directory is a project you created, and if it holds an encrypted state, keep the passphrase for as long as you keep the files.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian How to Install Apache Tika on Ubuntu 22.04|20.04|18.04 Apache How to Install Apache Tika on Ubuntu 22.04|20.04|18.04

Leave a Comment

Press ESC to close