Post
PT EN

OpenTofu in CI/CD: Swapping the Engine Without Rewriting the Pipeline

OpenTofu in CI/CD: Swapping the Engine Without Rewriting the Pipeline

Introduction

A mature CI/CD pipeline tends to be the most stable part of infrastructure operations. It’s been running for months, has approvals configured, secrets tuned, and a run history nobody wants to touch without a good reason.

That’s why the most common question from anyone considering OpenTofu is almost never about the tool itself. It’s about the pipeline: how much of it needs rewriting?

The short answer: almost nothing. In most cases, the change comes down to the step that installs the binary and the name of the command you run. There’s one spot that tends to break on the first run, the lock file, and it gets its own section further down.

If you’re not yet familiar with the tool’s architecture, it’s covered in detail in How OpenTofu Works: Architecture, Commands, and Terraform Compatibility.

What Actually Changes in the Pipeline

Worth starting by scoping the problem. A typical IaC pipeline runs four steps: install the binary, authenticate to the cloud provider, generate the plan, and apply it.

Of those four, only two are affected by the swap:

  • Installing the binary. You swap the action or step that downloads Terraform for OpenTofu’s equivalent.
  • The command name. terraform becomes tofu. Everything else stays the same. Provider authentication, remote backend configuration, repository secrets, approval policies, and job structure remain exactly as they are. None of those elements know the difference between the two binaries.

There’s also a third point, not a pipeline step itself, but usually the first thing to break: the lock file. It gets its own section below, since it deserves attention.

Installing OpenTofu on the Runner

The project maintains an official action for GitHub Actions. The swap is direct:

1
2
3
4
5
6
7
8
9
# Before
- uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: 1.9.0

# After
- uses: opentofu/setup-opentofu@v2
  with:
    tofu_version: 1.12.0

Two behavior details are worth noting.

The first is integrity verification. By default, the action checks the downloaded file against the SHA-256 checksum published in the release, and only skips that check, with a warning, when the checksum can’t be obtained. You can also pin the expected hashes manually if your security policy requires it.

The second is binary caching. It can be enabled, but it only makes a real difference on self-hosted runners, since hosted runners are ephemeral and don’t preserve cache between runs:

1
2
3
4
- uses: opentofu/setup-opentofu@v2
  with:
    tofu_version: 1.12.0
    cache: true

For other CI platforms, the principle is the same: swap the step that installs the binary. In container-based pipelines, you swap the base image. In pipelines using a version manager, you adjust the tool declaration.

The Lock File: What Needs Attention

Here’s the most common failure on the first run, and it has a specific cause.

OpenTofu downloads providers from its own registry. The provider is the same binary, but the source address recorded in the .terraform.lock.hcl file changes, and so do the hashes. A lock file generated by Terraform tends, as a result, to fail verification.

The fix is to regenerate the lock and commit the result:

1
2
3
4
rm .terraform.lock.hcl
tofu init
git add .terraform.lock.hcl
git commit -m "Regenerate lock file for OpenTofu"

There’s a second, subtler problem that shows up when your workstation and the runner run different systems. The lock stores hashes per platform. If it was generated on macOS, it’ll only have the matching hashes, and the pipeline running on Linux will fail.

The fix is to generate hashes for every platform involved:

1
2
3
4
tofu providers lock \
  -platform=linux_amd64 \
  -platform=linux_arm64 \
  -platform=darwin_arm64

It’s also worth making sure nobody forgets to commit the updated file. A simple step handles that:

1
2
- name: Check that the lock file is committed
  run: git diff --exit-code .terraform.lock.hcl

The Full Pipeline

Putting it all together, a pipeline that validates on pull request and applies on the main branch looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
name: Infrastructure

on:
  pull_request:
  push:
    branches: [main]

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_version: 1.12.0

      - name: Initialize
        run: tofu init

      - name: Check formatting
        run: tofu fmt -check -recursive

      - name: Validate configuration
        run: tofu validate

      - name: Generate plan
        run: tofu plan -no-color -out=plan.tfplan

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_version: 1.12.0

      - name: Apply
        run: |
          tofu init
          tofu apply -auto-approve

Compare this file with your current Terraform pipeline. The differences come down to the installation action and the command name. Job structure, conditions, protected environment, and step order stay identical.

When Terragrunt Is in the Mix

If your pipeline uses Terragrunt, the situation is even more comfortable, because the orchestration layer was designed to be independent of the execution binary.

Current Terragrunt versions already call tofu by default. Still, it’s worth declaring the choice explicitly, so the configuration doesn’t depend on whatever happens to be installed on the runner:

1
2
# root.hcl
terraform_binary = "tofu"

The environment-variable alternative helps when the decision needs to vary per pipeline:

1
export TG_TF_PATH=$(which tofu)

To confirm which binary is actually in use, there’s a diagnostic command:

1
terragrunt info print

One practical detail that saves debugging time: the OpenTofu install action creates, by default, a wrapper around the binary to expose output and exit code. That wrapper can confuse Terragrunt’s version detection. When the two coexist in the same job, disable it:

1
2
3
- uses: opentofu/setup-opentofu@v2
  with:
    tofu_wrapper: false

The entire structure of units, dependencies, and environments stays untouched. That’s exactly the benefit of keeping orchestration separate from execution.

Gradual Migration: One Repository at a Time

The natural temptation is to convert everything at once. That’s the wrong approach, for a simple reason: if something breaks, you won’t know which change caused it.

A sequence that works:

  1. Start with the least critical repository. Preferably a development environment with few resources.
  2. Swap only the plan job. Keep apply on Terraform for a few days and compare the outputs of both plans.
  3. Promote the apply once the plans match. With no resources flagged for recreation, the swap is safe.
  4. Repeat for the remaining repositories. Each round goes faster, since the problems are already known. This cadence lets you revert at any point at the cost of one changed line in the pipeline file.

Conclusion

Swapping your pipeline’s execution engine is, in most cases, a two-line change: the action that installs the binary and the command name. The lock file is the only point that needs real attention, and it’s resolved by regenerating the file with the right platform hashes.

This post is licensed under CC BY 4.0 by the author.