The HashiCorp apt repository now ships Terraform under the native Ubuntu 26.04 codename, resolute. That means you can install Terraform on Ubuntu 26.04 with a handful of apt commands and no borrowed noble packages, and the same commands work unchanged on 24.04. OpenTofu, the MPL-licensed fork now hosted by the CNCF under the Linux Foundation, installs from its own repository next to it, so both binaries share one machine without conflict.
The steps below add both repositories, push one configuration through terraform and then tofu, drive Docker with the kreuzwerker provider, inspect state and add Terragrunt. The last two sections cover what the new release changes after install and where the two engines now disagree. The headline result came from our test box: a plan with -minimal-refresh refreshed 1 of 201 resources instead of all 201 (Terraform 1.17.0, tested October 2026 on Ubuntu 26.04 LTS, repository steps repeated on 24.04).
Prerequisites
The install itself needs very little, because each tool ships as a single Go binary wrapped in a .deb. On our 2 vCPU, 4 GB test VM du -sh put the terraform binary at 124 MiB and tofu at 109 MiB, so even a small CI runner has room for both. The real requirements are network reach and, for one step, a container engine:
- OS: Ubuntu 26.04 LTS or 24.04 LTS, server or desktop, with a sudo user
- Network: outbound HTTPS to
apt.releases.hashicorp.comandpackages.opentofu.orgfor the packages;registry.terraform.io,registry.opentofu.org,releases.hashicorp.comandgithub.comfor provider downloads;get.opentofu.orgfor the OpenTofu installer; Docker Hub for thenginx:alpinepull in Step 6;api.github.comfor the Terragrunt step - Docker: only for Step 6, which installs the
docker.iopackage from the Ubuntu archive. Docker CE (install Docker on Ubuntu 26.04) works just as well if it is already there
A fresh box should get the initial server setup first. Nothing here touches the firewall, since Terraform and OpenTofu only make outbound connections.
Step 1: Install Terraform on Ubuntu 26.04 from the HashiCorp Repository
Terraform is not in the Ubuntu archive, so it comes from the signed HashiCorp apt repository. The repository carries a native resolute suite, which means the codename read from /etc/os-release works unchanged on 26.04 and 24.04. Start with the tools the key import needs; minimal and container images often ship without wget:
sudo apt-get update
sudo apt-get install -y gnupg curl wget
Import the signing key into a dedicated keyring and write the source line with your own codename:
. /etc/os-release
wget -qO- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com ${UBUNTU_CODENAME} main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
The tee echo should name resolute on 26.04 and noble on 24.04. Before installing, refresh the index and ask apt which version it would pick, because this is the one check that tells you whether HashiCorp has published for your codename at all. An empty candidate means the suite does not exist yet, and the install command would fail, so stop there rather than pointing the source at another release:
sudo apt-get update
apt-cache policy terraform | head -7
A candidate pulled from resolute/main confirms the native suite is live. We cut the table at seven lines because the full list runs back to 0.11.14, which is useful in its own way: a project pinned to an old release can still install that exact build from the same source. The top of the table on 26.04:
terraform:
Installed: (none)
Candidate: 1.17.0-1
Version table:
1.17.0-1 500
500 https://apt.releases.hashicorp.com resolute/main amd64 Packages
1.16.7-1 500
With the candidate confirmed, install the package:
sudo apt-get install -y terraform
Then confirm the binary on your PATH and its version.
terraform version
On our test box the package reported the release below. Nothing else needs configuring for local work, because providers, lock files and state all live in each project directory, and the CLI config at ~/.terraformrc is optional.
Terraform v1.17.0
on linux_amd64
The whole of Step 1 touches three paths, which is what you remove later if you ever back the repository out:
- /usr/share/keyrings/hashicorp-archive-keyring.gpg: the dearmored signing key, scoped to this one source by
signed-by, so it cannot vouch for packages from any other repository - /etc/apt/sources.list.d/hashicorp.list: one line naming your codename. It also makes Vault, Packer and Consul installable
- /usr/bin/terraform: the binary itself, 124 MiB for this release; the package adds only
/usr/share/doc/terraform/LICENSE.txtbesides it
To hold a project on one release instead of the newest, install it by version and pin it, otherwise the next apt upgrade moves it. We did exactly that on the 24.04 box, starting from the previous minor release:
sudo apt-get install -y terraform=1.16.7-1
sudo apt-mark hold terraform
The hold is enforced even when you ask for the upgrade by name. Running sudo apt-get install -y --only-upgrade terraform against the held package refused with this error and left the older release in place:
E: Held packages were changed and -y was used without --allow-change-held-packages.
That is the behaviour you want on a pinned CI image, because a blanket apt-get -y in a provisioning script cannot move Terraform behind your back. When you are ready to move up, release the hold first; the same --only-upgrade command then unpacked the current release over it in one step:
sudo apt-mark unhold terraform
sudo apt-get install -y --only-upgrade terraform
Machines that never held the package need only the second line.
Step 2: Install OpenTofu
OpenTofu publishes an installer script that adds its two signing keys and an apt source, then installs the tofu package. Its source line uses the suite any, so it does not depend on Ubuntu’s codename at all. Download the script over a pinned TLS connection and run it with the deb method:
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
chmod +x install-opentofu.sh
sudo ./install-opentofu.sh --install-method deb
The script finishes with Checking if OpenTofu is installed correctly... and leaves /etc/apt/sources.list.d/opentofu.list behind, so later upgrades come through apt like any other package. Remove the installer:
rm -f install-opentofu.sh
Check the installed version.
tofu version
The test box pulled the newest stable OpenTofu:
OpenTofu v1.13.2
on linux_amd64
Locked-down CI runners that cannot run a downloaded script can follow the manual repository steps in the OpenTofu deb install docs, which produce the same source file.
Step 3: Write a First Configuration
The quickest proof that an install works is a configuration that needs no cloud account. This one uses the local provider to write a file on disk, and the same file runs unchanged through OpenTofu in Step 5. Create a project directory:
mkdir -p ~/terraform-demo && cd ~/terraform-demo
Open the configuration file. It lives in your home directory, so no sudo is needed:
vim main.tf
Add the following HCL:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "hello" {
content = "Hello from Terraform on Ubuntu 26.04!\n"
filename = "${path.module}/hello.txt"
}
output "file_path" {
value = local_file.hello.filename
}
The ~> 2.5 constraint accepts any 2.x release from 2.5 up, which is why init below picks the newest 2.x today and will pick a later one next quarter. The lock file written on the first init is what freezes that choice, so the constraint sets the range and the lock file sets the build. The resource writes hello.txt next to the configuration and the output prints its path.
Step 4: Run Init, Plan, Apply and Destroy
Every Terraform project goes through the same four commands. We ran each one below against the file from Step 3, and the output blocks are what the test box printed, trimmed only where Terraform repeats its own help text.
Initialize the working directory
terraform init resolves the provider constraint, downloads the plugin into .terraform/ and writes .terraform.lock.hcl:
terraform init
Look for the resolved provider version and the success line:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
Terraform 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 Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
Commit .terraform.lock.hcl with the configuration. Without it, a teammate who runs init next month gets whatever the newest 2.x is by then, so the plan they see comes from a provider nobody tested. With it, init keeps installing the recorded build until someone runs terraform init -upgrade on purpose.
Preview changes with plan
terraform plan compares the configuration with state and prints what it would do, without touching anything:
terraform plan
The plan renders the trailing \n in content as an EOT block rather than an escaped string, and every hash is (known after apply) because the file does not exist yet:
# local_file.hello will be created
+ resource "local_file" "hello" {
+ content = <<-EOT
Hello from Terraform on Ubuntu 26.04!
EOT
+ content_base64sha256 = (known after apply)
+ content_base64sha512 = (known after apply)
+ content_md5 = (known after apply)
+ content_sha1 = (known after apply)
+ content_sha256 = (known after apply)
+ content_sha512 = (known after apply)
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./hello.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ file_path = "./hello.txt"
Read the summary line on every run. One add and nothing changed or destroyed is exactly what a first apply should do. A plan that wants to destroy something you did not touch is the signal to stop.
Apply the configuration
-auto-approve skips the yes/no prompt. That suits a throwaway demo and CI jobs that already reviewed a saved plan, but on shared infrastructure read the plan first:
terraform apply -auto-approve
Terraform creates the file and prints the output value:
local_file.hello: Creating...
local_file.hello: Creation complete after 0s [id=2138eaf75c666a9671d08eaebbf8d80bb2347ad1]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello.txt"
Confirm the file landed on disk.
cat hello.txt
It holds the string from main.tf:
Hello from Terraform on Ubuntu 26.04!
Terraform now records the file in terraform.tfstate, so deleting hello.txt by hand would make the next plan offer to create it again.
Tear down resources
terraform destroy removes everything this state file tracks:
terraform destroy -auto-approve
The last lines confirm the single resource is gone:
local_file.hello: Destroying... [id=2138eaf75c666a9671d08eaebbf8d80bb2347ad1]
local_file.hello: Destruction complete after 0s
Destroy complete! Resources: 1 destroyed.
An emptied state file stays behind, which is how Terraform knows the project exists but manages nothing.
Step 5: Run the Same Example with OpenTofu
OpenTofu reads the same HCL and resolves hashicorp/local through the OpenTofu registry, so only the binary name changes. Copy the configuration into a separate directory, because the two tools should never share one state file:
mkdir -p ~/tofu-demo && cd ~/tofu-demo
cp ~/terraform-demo/main.tf .
Initialize it the same way.
tofu init
The provider version matches Terraform’s pick. Only the signature line differs: OpenTofu prints the GPG key ID it checked instead of “signed by HashiCorp”:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.9.1...
- 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 been successfully initialized!
Apply the configuration with OpenTofu.
tofu apply -auto-approve
The resource ID is byte-for-byte the one Terraform produced, since local_file uses the SHA-1 of the content as its ID:
local_file.hello: Creating...
local_file.hello: Creation complete after 0s [id=2138eaf75c666a9671d08eaebbf8d80bb2347ad1]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
file_path = "./hello.txt"
Clean up before moving on, since Step 6 starts a fresh project.
tofu destroy -auto-approve
Identical output on a simple config does not make every project portable. The lock file is per registry: the one tofu init wrote on our box records registry.opentofu.org/hashicorp/local, while the Terraform one records registry.terraform.io/hashicorp/local, so each binary needs its own init. The OpenTofu migration guide has you back up state, run tofu init and tofu plan, and expect no changes before the first tofu apply, which may rewrite the state format. Each side also adds language features the other lacks; the 1.17 section below shows two where the same file behaves differently under each binary, and that is the point where running both against one repository stops being safe.
Step 6: Manage Docker Containers with Terraform
The Docker provider is a more realistic test, because it talks to a live API over the Docker socket. Install the engine from the Ubuntu archive and add your user to the docker group, so the provider can reach the socket without sudo. If Docker CE is already installed, skip the first line, because docker.io conflicts with the docker-ce and containerd.io packages:
sudo apt-get install -y docker.io
sudo usermod -aG docker $USER
Log out and back in so the new group applies to your session, then create a project directory:
mkdir -p ~/docker-demo && cd ~/docker-demo
Open the configuration file in that directory.
vim main.tf
Add a configuration that pulls the Nginx Alpine image and publishes it on port 8080:
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.0"
}
}
}
provider "docker" {}
resource "docker_image" "nginx" {
name = "nginx:alpine"
keep_locally = false
}
resource "docker_container" "nginx" {
image = docker_image.nginx.image_id
name = "terraform-nginx"
ports {
internal = 80
external = 8080
}
}
output "container_id" {
value = docker_container.nginx.id
}
output "container_name" {
value = docker_container.nginx.name
}
Initialize and apply in one go.
terraform init
terraform apply -auto-approve
Init resolves the constraint to kreuzwerker/docker v4.6.0 and reports it as self-signed, which is normal for a community provider whose key is not held by HashiCorp. We first ran this file on the older ~> 3.0 line, and it applied unchanged on 4.x, so the 4.0 major brought no code changes for a config this simple. Apply creates the image before the container, because the container references docker_image.nginx.image_id and Terraform orders the graph from that reference. On this run the image pull took 6 s and the container 1 s more:
docker_image.nginx: Creating...
docker_image.nginx: Creation complete after 6s [id=sha256:a3554c0d0b48177b1413c26e01f695334428020d9df01243fbae3e11cfd17417nginx:alpine]
docker_container.nginx: Creating...
docker_container.nginx: Creation complete after 1s [id=182f54704d1b1c9147a9155a6c28deecd8de61c26d23bdb32ff8d5070162bd27]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
container_id = "182f54704d1b1c9147a9155a6c28deecd8de61c26d23bdb32ff8d5070162bd27"
container_name = "terraform-nginx"
Check the container from the Docker side:
docker ps
It runs under the name from main.tf, with port 8080 on the host mapped to 80 inside:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
182f54704d1b a3554c0d0b48 "/docker-entrypoint.…" 2 seconds ago Up 1 second 0.0.0.0:8080->80/tcp terraform-nginx
Request the default page to prove the port mapping works end to end.
curl -s http://localhost:8080 | head -4
Nginx answers with its welcome page:
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
Leave the container running for Step 7, which reads its state.
Step 7: Inspect State
Terraform records every managed object in terraform.tfstate, and the next plan diffs against it. Two read-only commands cover most day-to-day inspection. List what this directory tracks:
terraform state list
The Docker project tracks the image and the container:
docker_container.nginx
docker_image.nginx
Show one resource with every attribute the provider stored:
terraform state show docker_container.nginx | head -12
Attributes the provider leaves unset print as null, which makes the output longer but easier to diff:
# docker_container.nginx:
resource "docker_container" "nginx" {
attach = false
bridge = null
command = [
"nginx",
"-g",
"daemon off;",
]
container_read_refresh_timeout_milliseconds = 15000
cpu_set = null
cpu_shares = 0
Never edit the state file by hand. Terraform reads a hand-edited local file as-is, so a typo becomes a wrong view of your infrastructure, and with a remote backend a stale copy pushed back can overwrite newer state. Use terraform state mv and terraform state rm when a resource has to move. Once you are done with the demo, remove both Docker resources:
terraform destroy -auto-approve
Because keep_locally = false, the destroy also deletes the nginx:alpine image from the host, so the next apply pulls it again.
Step 8: Format and Validate
Both checks need no credentials and finish in well under a second once init has run, so they belong in every pre-commit hook and CI job. Running them there catches a typo before anyone spends a cloud API call on it. Check that the files follow canonical style:
terraform fmt -check
No output and exit code 0 mean the files are already formatted; drop -check to rewrite them in place. Then validate references and types:
terraform validate
A clean configuration returns one line:
Success! The configuration is valid.
OpenTofu has the same pair as tofu fmt and tofu validate. If you write providers in Go on Ubuntu 26.04, run both against the HCL that consumes them as well.
Step 9: Install Terragrunt
Terragrunt wraps either engine to keep backend settings and module inputs DRY across environments. It is not packaged for apt, so it comes as one binary from GitHub. The command below asks the releases API for the newest tag instead of hardcoding one, which keeps it correct after the next release:
TGVER=$(curl -sL https://api.github.com/repos/gruntwork-io/terragrunt/releases/latest | grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
echo "Installing Terragrunt v${TGVER}"
sudo wget -q "https://github.com/gruntwork-io/terragrunt/releases/download/v${TGVER}/terragrunt_linux_amd64" -O /usr/local/bin/terragrunt
sudo chmod +x /usr/local/bin/terragrunt
Verify the binary.
terragrunt --version
The API returned the latest tag and the binary reports it:
terragrunt version v1.2.1
With all three installed, the test box looked like this:

The Terragrunt v1 line prefers tofu and falls back to terraform when only Terraform is on the PATH. With both installed, as on this box, it picks OpenTofu; to force Terraform, set terraform_binary = "terraform" in terragrunt.hcl, export TG_TF_PATH=terraform, or pass --tf-path terraform to terragrunt run. Ansible on Ubuntu 26.04 handles the configuration that happens after Terragrunt provisions the hosts, and the Terraform and Ansible tutorial wires the two together.
What Terraform 1.17 Changes After You Install It
The release that apt installs today shipped on 21 October 2026, and the changelog lists no upgrade notes. Four changes were reproduced on the test box, and two of them make a configuration behave differently under OpenTofu:
- -minimal-refresh: plan refreshes only resources with proposed changes. Measured: 201 refreshes down to 1
- Provider requirements:
versionandsourceaccept const variables and locals. OpenTofu rejects the same file - log() and pow(): an infinite result is now an error. OpenTofu still returns
+Inf - version -json: gains a
format_versionfield
Two more items are in the changelog but were not reproduced here. Terraform Policy is now generally available, so plan -policies no longer needs an experimental build (see the policy docs). Diagnostics raised while renewing an ephemeral resource are now shown, so a config that uses ephemeral resources may print warnings it used to swallow.
-minimal-refresh: 201 refreshes down to 1
A normal plan refreshes every resource in state before it diffs, which is the slow part on any cloud account with a few hundred objects. To measure the flag, our test project held 201 local_file resources (a count = 200 fleet plus one motd file), and only the motd content was changed after apply. Count the refresh lines each mode prints:
terraform plan -no-color | grep -c "Refreshing state"
terraform plan -minimal-refresh -no-color | grep -c "Refreshing state"
The full plan refreshed all 201 and the minimal plan only the edited resource:
201
1
Wall-clock time per plan, from /usr/bin/time over four runs on the 2 vCPU test VM, came out like this:
| Plan mode | Resources refreshed | Wall clock per run (s) | Caught a deleted file? |
|---|---|---|---|
terraform plan | 201 | 1.30, 1.21, 1.24, 1.22 | Yes |
terraform plan -minimal-refresh | 1 | 1.11, 1.02, 1.01, 1.01 | No |
Local files refresh in microseconds, so the saving we measured is only about 0.2 s per plan. Against a cloud provider each refresh is an API call, and the gap grows with resource count. The last column is the catch. After deleting out/node-5.txt by hand, the full plan reported Plan: 2 to add, 0 to change, 1 to destroy. and the minimal plan reported Plan: 1 to add, 0 to change, 1 to destroy., because it never looked at fleet[5]. Use the flag for fast inner-loop plans on a resource you are editing, and keep a full plan in CI so drift still shows up.
Variables in required_providers need const = true
Provider versions can now come from a variable, which lets one module tree pin different provider versions per environment without copy-pasted terraform blocks. Init runs before any plan values exist, so the variable must be declared const. The test file:
variable "local_version" {
type = string
default = "~> 2.5"
const = true
}
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = var.local_version
}
}
}
Override the default at init time like any other variable.
terraform init -var "local_version=2.8.0"
Init honours the override and installs exactly 2.8.0 instead of the newest 2.x:
- Finding hashicorp/local versions matching "2.8.0"...
- Installing hashicorp/local v2.8.0...
- Installed hashicorp/local v2.8.0 (signed by HashiCorp)
Leave out the const = true line and init stops before it touches the registry:
Error: Unknown provider version
on main.tf line 10, in terraform:
10: version = var.local_version
Only literal values and const variables can be evaluated during init.
Running tofu init on the same file with const = true in place fails differently, because OpenTofu does not accept an expression in this field:
Error: Invalid version constraint
on main.tf line 11, in terraform:
11: version = var.local_version
Version must be specified as a string.
A module that adopts this feature is Terraform-only from that commit on, because the error comes from tofu init and nothing later in the workflow can run. Teams that run both binaries, or that publish modules other people consume, should keep literal version strings in required_providers and pass environment differences through -var on resources instead.
log() and pow() now fail on infinity
The changelog carries two fixes for these functions: they no longer panic when the result is not a number, and they now raise an error when the result is positive or negative infinity. terraform console shows the second one:
echo "pow(10, 400)" | terraform console
The call fails with a function error instead of returning a value:
Error: Error in function call
on <console-input> line 1:
(source code not available)
Call to function "pow" failed: result is not a number.
log(0, 10) fails the same way, while finite results such as pow(2, 10) still return 1024. OpenTofu evaluates the same two expressions to +Inf and -Inf, so a module that feeds those functions an unchecked input can plan under tofu and fail under terraform.
terraform version -json gains format_version
Scripts that parse the JSON version output get a new top-level field. We checked it on the fresh install.
terraform version -json
format_version appears first in the object:
{
"format_version": "1.0",
"terraform_version": "1.17.0",
"platform": "linux_amd64",
"provider_selections": {},
"terraform_outdated": false
}
A parser that ignores unknown keys keeps working, so most CI version checks need no change. One that validates the object against a strict schema needs the new key added. The release note also asks tooling to read format_version first in future releases, so the field is worth checking now, before the shape of the output changes.
Terraform vs OpenTofu: Which One to Use
OpenTofu forked from Terraform 1.5.7, and for plain resources the two still produce identical plans and IDs, as Step 5 showed. The gaps that matter now are licensing and the newer language features each side adds alone, including the two divergences measured above:
| Criteria | Terraform | OpenTofu |
|---|---|---|
| License | BSL 1.1 (Business Source License) | MPL-2.0 (Mozilla Public License) |
| Governance | HashiCorp (IBM) | CNCF (Linux Foundation) |
| Ubuntu apt source | apt.releases.hashicorp.com, per codename | packages.opentofu.org, suite any |
| Provider registry | registry.terraform.io | OpenTofu registry, same provider addresses |
| State file | Native | Reads Terraform state; back up, then tofu plan must show no changes |
| Lock file | Records registry.terraform.io | Records registry.opentofu.org; run init again |
Variables in required_providers | Yes, with const = true | No: “Version must be specified as a string” |
pow(10, 400) | Error: result is not a number | +Inf |
| Unique features | HCP Terraform, Terraform Policy, -minimal-refresh | Client-side state encryption, OCI registry for providers and modules |
| Commercial use | Restricted for competing products | No restrictions |
For an internal platform team either binary works, and the deciding factor is usually what is already in the stack. HCP Terraform or Terraform Enterprise in place points to Terraform, since moving means giving up those services. A product that competes with HashiCorp has to use OpenTofu because of the BSL, and teams that want state encrypted before it reaches S3 or GCS get that only from OpenTofu. Whichever you pick, stop using both against one codebase once it adopts a feature from the table above, because the shared syntax is shrinking with each release. The Fedora install guide covers the same pair on the RPM side, the getting started guide goes further into modules and remote state, and the Terraform book list and Terraform Associate study books are the next step for anyone heading toward certification.