CoursesOpenTofuInstalling OpenTofu & the tofu CLI

Installing OpenTofu & the tofu CLI

A drop-in for the terraform command.

Beginner10 min · lesson 2 of 12

OpenTofu ships as one file. A single binary named tofu, with no runtime to install underneath it, no service sitting in the background, nothing to restart at 3am. It behaves like a hand tool rather than an appliance: you pick it up, use it, put it down, and nothing about it is running while it sits in the drawer. Wherever your hands used to type terraform, they now type tofu, and the same HCL (HashiCorp Configuration Language, the .tf syntax you write infrastructure in), the same providers, and the same state files are waiting on the other side.

So installing it is two jobs, not one. Put the file somewhere on your PATH (the list of directories your shell searches when you type a command name), and prove the file is the genuine article before you hand it your cloud credentials. Most guides cover the first job in a single line. The second is where the interesting failures live, because tofu runs with whatever permissions you give it, and a tampered copy reads your cloud keys the first time you run it.

What You Are Actually Putting On Disk

The release binary is compiled Go, linked statically. Static linking is the difference between a traveller who packs everything into one suitcase and one who turns up expecting to borrow a toothbrush: this file carries every library it needs inside itself and asks your distribution for nothing. That is why it is large. Around 115 MB. It opens no network ports, installs no systemd unit, and creates no service account. You can check every part of that yourself once it lands.

terminal
file "$(command -v tofu)"
ls -l "$(command -v tofu)"
output
/usr/bin/tofu: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=pcocp8YyAd5206I0pbZb/y0885tuFSgSfPzucFPGz/QO5nHv6BiwkQRlwbLp3O/OHvyzPPXKsPN4BZR5RKO, BuildID[sha1]=740fd193cb89b36ad59d0084ae88adca675780e2, stripped
-rwxr-xr-x 1 root root 114978978 Jul 13 20:59 /usr/bin/tofu

ELF (Executable and Linkable Format) is the standard shape of a program file on Linux. The phrase that matters operationally is statically linked: this binary will run inside a container image that holds nothing else at all, not even a C library. stripped means the debug symbol table was thrown away to save space, so 115 MB is the small version. Sitting idle, the hardening picture is dull in the best possible way. Nothing runs unless you run it. The risk is squeezed into a single moment instead. The second you execute it, it reads your credentials, reaches a registry over the network, downloads provider plugins, and executes those plugins as your user. Two questions fall out of that. Where did this file come from, and which copy of it does your shell actually pick?

The Package Manager Path

On a workstation, a package manager is the right answer. It drops tofu on your PATH and, more usefully, keeps it current when the next release lands. One naming quirk catches nearly everyone: the package is called opentofu, the command it installs is called tofu. Asking Homebrew for tofu does not get you OpenTofu.

terminal
# macOS or Linux (Homebrew): formula is 'opentofu', command is 'tofu'
brew update && brew install opentofu
# Linux (Snap): classic confinement is required
sudo snap install --classic opentofu
# Windows (winget): --exact means you get this exact package id, no fuzzy match
winget install --exact --id=OpenTofu.Tofu

The Snap needs --classic because the default strict sandbox would block it from reading .tf files in your working directory and from reaching provider registries over the network. Classic confinement switches that sandbox off. Worth understanding rather than pasting past, because it means the Snap gets the same run of your home directory as any ordinary program.

On Debian 12, or Ubuntu 22.04 and newer, the apt repository is what you will actually run on servers and build agents. Set it up by hand once so you can see exactly what you are choosing to trust.

terminal
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
# root-owned, world-readable keyring directory
sudo install -m 0755 -d /etc/apt/keyrings
# two keys: OpenTofu's own, and the one the hosting service signs metadata with
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 --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
ls -l /etc/apt/keyrings/
output
total 8
-rw-r--r-- 1 root root 2892 Jul 21 09:12 opentofu-repo.gpg
-rw-r--r-- 1 root root 2292 Jul 21 09:12 opentofu.gpg

GPG here is GNU Privacy Guard, the standard tool for signing files with a key and checking those signatures later. The first key arrives already in binary form, so tee writes it straight to disk. The second arrives ASCII-armoured, which is a plain-text wrapper that lets a key survive being pasted into an email, and that is the one gpg --dearmor has to unwrap. Now the repository definition, which is where the real security decision lives.

/etc/apt/sources.list.d/opentofu.list
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

signed-by= is the whole point of writing the file this way. It is a guest list pinned to one door: these two keys, and only these two, may vouch for packages from this repository. The old habit of apt-key add worked the other way, throwing every key into one master list that every repository on the machine could draw on. A key you added years ago for some unrelated vendor could then sign a package claiming to be tofu, and apt would accept it without complaint. Scoping keys to a single repository shuts that door.

terminal
sudo chmod a+r /etc/apt/sources.list.d/opentofu.list
sudo apt-get update
sudo apt-get install -y tofu
tofu version
output
Get:5 https://packages.opentofu.org/opentofu/tofu/any any InRelease [24.0 kB]
Get:6 https://packages.opentofu.org/opentofu/tofu/any any/main amd64 Packages [12.1 kB]
Fetched 36.1 kB in 1s (32.8 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following NEW packages will be installed:
tofu
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 34.8 MB of archives.
After this operation, 115 MB of additional disk space will be used.
Get:1 https://packages.opentofu.org/opentofu/tofu/any any/main amd64 tofu amd64 1.12.4 [34.8 MB]
Fetched 34.8 MB in 3s (11.6 MB/s)
Selecting previously unselected package tofu.
(Reading database ... 187432 files and directories currently installed.)
Preparing to unpack .../tofu_1.12.4_amd64.deb ...
Unpacking tofu (1.12.4) ...
Setting up tofu (1.12.4) ...
OpenTofu v1.12.4
on linux_amd64

Installing Where There Is No Package Manager

CI runners (continuous integration, the automated system that builds and tests your code every time someone pushes), scratch container images, and locked-down hosts often have no repository you are allowed to add. OpenTofu publishes a standalone installer script for exactly that case.

terminal
curl --proto '=https' --tlsv1.2 -fsSL \
https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
less install-opentofu.sh # actually read it before you run it
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method standalone
rm -f install-opentofu.sh

--proto '=https' tells curl to refuse anything that is not HTTPS, including a redirect trying to drop you down to plain HTTP, and --tlsv1.2 sets the minimum encryption version it will accept. The standalone method unpacks the binary under /opt/opentofu and links it into /usr/local/bin, which is already on your PATH.

terminal
command -v tofu
readlink -f "$(command -v tofu)"
output
/usr/local/bin/tofu
/opt/opentofu/tofu
Never pipe an installer straight into a root shell
curl ... | sudo bash runs code you have never read, as root, chosen by a server at the moment you ask. A server can hand one script to a browser and a different one to curl by reading the User-Agent header, and because the shell executes the stream as it arrives, it can change what it sends partway through the download. Save the file, read it, confirm the host really is get.opentofu.org and not a lookalike, then run it. Credit where it is due: this particular script will not let you skip verification by accident. If neither cosign nor GPG is present, --install-method standalone refuses to install, exits 1, and tells you to install one of them. The danger is what a tired engineer does at 6pm, which is add --skip-verify to make the pipeline go green. On a slim container image with no cosign, that one flag is the entire supply-chain control being switched off.

Proving The Download Is Genuine

A release is a parcel with a packing list taped to the outside. The file tofu_1.12.4_SHA256SUMS is that packing list: one line per released file, each carrying a SHA-256 fingerprint, a 64-character value that changes completely if a single byte of the file changes. The .sig and .pem files beside it are the wax seal on the envelope, proving who wrote the list. Two checks, in this order. Is the list authentic, and does the parcel match the list?

terminal
VER=1.12.4
BASE="https://github.com/opentofu/opentofu/releases/download/v${VER}"
curl -fsSLO "${BASE}/tofu_${VER}_linux_amd64.zip"
curl -fsSLO "${BASE}/tofu_${VER}_SHA256SUMS"
curl -fsSLO "${BASE}/tofu_${VER}_SHA256SUMS.sig"
curl -fsSLO "${BASE}/tofu_${VER}_SHA256SUMS.pem"
ls -1
output
tofu_1.12.4_SHA256SUMS
tofu_1.12.4_SHA256SUMS.pem
tofu_1.12.4_SHA256SUMS.sig
tofu_1.12.4_linux_amd64.zip

OpenTofu does not keep a signing key lying around on somebody's laptop. Its release job on GitHub Actions asks for a short-lived identity token (OIDC, OpenID Connect, a standard way one service proves who it is to another), hands that to Sigstore's certificate authority, and gets back a certificate valid for a few minutes that records exactly which workflow in which repository asked for it. That certificate is the .pem. The signature made with its matching key is the .sig. cosign, the verification tool from the Sigstore project, checks the two together.

terminal
cosign verify-blob \
--certificate-identity 'https://github.com/opentofu/opentofu/.github/workflows/release.yml@refs/heads/v1.12' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--signature tofu_1.12.4_SHA256SUMS.sig \
--certificate tofu_1.12.4_SHA256SUMS.pem \
tofu_1.12.4_SHA256SUMS
output
Verified OK

--certificate-identity is the flag doing the real work. It demands the signature came from release.yml in the opentofu/opentofu repository, on the v1.12 branch that ships the 1.12.x line. Note that the branch tracks the major and minor version, so installing 1.11.x means pinning refs/heads/v1.11 instead. Leave that flag loose, or paste in a regular expression matching any github.com URL, and a signature produced by any workflow in any repository on GitHub sails straight through, including one an attacker created this morning on a free account. --certificate-oidc-issuer pins who was allowed to vouch for that identity in the first place, so a certificate minted through some other login provider is rejected.

Now bind your actual download to the list you have decided to trust.

terminal
sha256sum --ignore-missing -c tofu_1.12.4_SHA256SUMS
unzip -o tofu_1.12.4_linux_amd64.zip tofu
sudo install -o root -g root -m 0755 tofu /usr/local/bin/tofu
tofu version
output
tofu_1.12.4_linux_amd64.zip: OK
Archive: tofu_1.12.4_linux_amd64.zip
inflating: tofu
OpenTofu v1.12.4
on linux_amd64

--ignore-missing stops sha256sum complaining about the other thirty-nine builds in that list, because a single release covers Linux, macOS, Windows, FreeBSD and more, in both zip and tarball form, plus deb, rpm and apk packages. Watch for the failure mode where you mistype the zip's name: with nothing left to check, sha256sum reports no file was verified and exits non-zero, which is very easy to skim past in a noisy CI log. Read the OK line, not the exit code alone. And install -o root -g root -m 0755 places the binary root-owned, so a later process running as you cannot quietly rewrite it.

The chain from download to the binary that runs
1Download release assets
zip, SHA256SUMS, .sig and .pem, all pinned to one version
2Authenticate the packing list
cosign verify-blob, identity pinned to release.yml on v1.12
3Match the parcel to the list
sha256sum -c ties your zip to the trusted checksums
4Install root-owned on PATH
install -o root -g root -m 0755 /usr/local/bin/tofu
5Confirm what actually runs
which -a tofu, then hash the copy that wins

Terraform Habits That Carry Over, And Three That Do Not

The command surface mirrors Terraform one for one. tofu init, tofu plan, tofu apply, tofu state list, tofu fmt -check, the same flags in the same shapes. The .terraform working directory keeps its name, so do terraform.tfstate and your .tf files. One small step makes the CLI feel native.

terminal
tofu -install-autocomplete
tail -n 1 ~/.bashrc
output
complete -C /usr/bin/tofu tofu

Three differences matter in practice. First, the default registry host is registry.opentofu.org, not registry.terraform.io. If your network has an egress allowlist or a forward proxy, that hostname needs adding, and a tofu init that hangs forever on a locked-down runner is almost always this. Second, the CLI configuration file is ~/.tofurc, falling back to ~/.terraformrc for compatibility, and if both exist .tofurc wins. There is an XDG variant too: a file named tofurc in $XDG_CONFIG_HOME/opentofu, and when you use that location the old .terraformrc is ignored outright. TF_CLI_CONFIG_FILE still overrides all of it.

Third, and this is the one worth installing OpenTofu for, it can encrypt its own state and plan files. Terraform cannot. State files hold secrets in plain text, whatever your providers happened to read along the way: database passwords, generated private keys, access tokens. You configure encryption in a terraform { encryption {} } block, or feed the same HCL in through the TF_ENCRYPTION environment variable, which is the usual route on a build agent where the key comes out of a secrets manager at run time. If the machine you are installing on ever runs tofu apply, turn this on before you write your first resource.

For hosts not allowed to reach the internet at all, point the CLI at a local copy of the providers instead.

/home/dev/.tofurc
provider_installation {
filesystem_mirror {
path = "/opt/tofu-providers"
include = ["registry.opentofu.org/*/*"]
}
direct {
exclude = ["registry.opentofu.org/*/*"]
}
}

OpenTofu deliberately does not create a terraform command for you. That choice is yours, and how you make it decides whether the migration is real or cosmetic.

An alias is not tofu on PATH
alias terraform=tofu only rewrites what you type into an interactive shell. It does not exist for Makefiles, for exec and subprocess calls inside scripts, for cron jobs, or for CI runners, all of which look up the real terraform binary by name. A pipeline running terraform plan carries on running Terraform, or dies with command not found, while your laptop feels migrated. To route a whole host to OpenTofu, either update the callers to say tofu or create a genuine symlink: sudo ln -s /usr/bin/tofu /usr/local/bin/terraform. Then prove it with command -v terraform and readlink -f "$(command -v terraform)" rather than trusting that it looked fine once in your own terminal.

Which Tofu Actually Runs

PATH is a row of doors your shell knocks on in order, and it takes the first answer it gets. An attacker who can write to any directory sitting earlier in your PATH than /usr/bin needs no root at all. They drop a twenty-line shell script named tofu that copies ~/.aws/credentials somewhere quiet and then hands off to the real binary, so every command you run behaves exactly as expected. Checking for this takes one command.

terminal
which -a tofu
readlink -f "$(command -v tofu)"
sha256sum "$(command -v tofu)"
IFS=: read -ra dirs <<< "$PATH"
for d in "${dirs[@]}"; do [ -d "$d" ] && [ -w "$d" ] && printf 'writable by you: %s\n' "$d"; done
output
/home/dev/.local/bin/tofu
/usr/bin/tofu
/home/dev/.local/bin/tofu
9c1f4b7e0d2a6538ae0b47c9f1d38e5206b7a4c3f89e1d0b7a52c6e3f4081b9d /home/dev/.local/bin/tofu
writable by you: /home/dev/.local/bin

Two tofu entries, and the one that wins lives in a home directory any process running as you can write to. The hash is the tell. The binary from the 1.12.4 release measures e11e783ab8ee0a029da32c2ab1817952121208d0ae9d6cf2d91fa0687f573a88, and this is not that. Maybe you installed something last year and forgot. Maybe it is a shim. Either way the file you verified is not the file being executed, and tofu version will cheerfully print the same version string from both. On a machine installed from apt, you have one more check available.

terminal
dpkg -V tofu
output
??5?????? /usr/bin/tofu

Silence from dpkg -V means every file the package installed still matches the digest recorded at install time. Nine characters of status get printed per changed file, and the 5 in the third column is the digest column, so here something has replaced /usr/bin/tofu since apt put it there. Read the result with two limits in mind. dpkg records MD5 only, an algorithm nobody should rely on to stop a determined forger, and the recorded list lives in /var/lib/dpkg/info/tofu.md5sums, which anyone holding root can rewrite to match their replacement. It also covers nothing apt did not install, so it is blind to a standalone binary in /usr/local/bin or a shim in a home directory. That is why your own recorded SHA-256 still earns its keep.

Quick check
01cosign verify-blob prints Verified OK for tofu_1.12.4_SHA256SUMS. What have you proven at that exact moment?
Incorrect — No. cosign inspected the checksum file and nothing else. Nothing has compared the zip to anything yet.
Correct — The signature authenticates the list, and the hash check is the step that binds your download to it.
Incorrect — No. A signature tells you who produced a file, never that the file is safe to run.
Incorrect — No. TLS protects the connection. It says nothing about which bytes the server chose to hand you.
02The apt source line for OpenTofu is written deb [signed-by=/etc/apt/keyrings/opentofu.gpg,...] .... Compared with the older apt-key add approach, what does signed-by= actually protect against?
Incorrect — Signing proves authenticity, not confidentiality; the package is not encrypted, and transit protection comes from TLS instead.
Correct — apt-key add pooled every key into one global trust set any repo could draw on; scoping keys to a single repository shuts that door.
Incorrect — Transport is decided by the https:// in the repository URL, not by signed-by, which only governs which keys may sign the metadata.
Incorrect — apt validates GPG repository signatures; the Sigstore/cosign check is a separate step on the standalone release download.
03On a shared build host, which -a tofu lists /home/dev/.local/bin/tofu first, then /usr/bin/tofu. The first file's SHA-256 does not match the official 1.12.4 release hash, yet tofu version still prints OpenTofu v1.12.4. What is the most likely situation?
Incorrect — dpkg only tracks the package copy in /usr/bin; the home-directory file was never installed by apt and sits outside its checks.
Incorrect — Any binary can print whatever version string it likes, so tofu version matching proves nothing about the actual bytes.
Incorrect — PATH takes the first match in order, and here the writable home directory comes first, so its copy wins.
Correct — First-on-PATH wins, the hash mismatch is the tell that it is not the release build, and a wrapper can steal creds then hand off to the real tofu.

On servers and build agents, stop the version moving underneath you without anyone noticing.

terminal
sudo apt-mark hold tofu
apt-mark showhold
output
tofu set on hold.
tofu

Record the version string and the SHA-256 of the binary in the same runbook where you pin provider versions, and re-run the hash check after every deliberate upgrade so the recorded value stays current. When tofu version changes on a build agent and no change request explains it, you will have something concrete to compare against instead of a hunch.

Try this

Run file "$(command -v tofu)" on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: never pipe an installer straight into a root shell. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related