OpenTofu for Kubernetes Bootstrapping: GitOps with Argo CD, Hands-On

A hands-on sysadmin guide to bootstrapping Kubernetes with OpenTofu and Argo CD: what the open-source Terraform fork actually is, why IaC owns day zero and GitOps owns everything after, the exact provider and Helm configuration to install Argo CD, the app-of-apps handoff, and the honest limits of the pattern.

OpenTofu for Kubernetes Bootstrapping: GitOps with Argo CD, Hands-On

If you have been following the cluster conversation here, you know the verdict from the Docker Compose vs Kubernetes post: Kubernetes earns its keep once a second host exists, and k3s is the on-ramp when it does. What that post only hinted at is the job that appears the moment the cluster is real: bootstrap it the same way every time, install the GitOps layer, and then get out of its way. That is where OpenTofu earns its keep.

This post is the hands-on version of what OpenTofu actually is and how it relates to Terraform, why the bootstrap job splits cleanly into an IaC half and a GitOps half, the exact configuration that provisions a k3s cluster and installs Argo CD, the app-of-apps handoff that makes Argo CD own everything after, and the honest limits of the pattern.

The short version

  • OpenTofu is the open-source fork of Terraform, licensed MPL-2.0 and governed by the Linux Foundation. It is a drop-in CLI replacement: same HCL, same providers, same state format.
  • Bootstrap splits into two stages. OpenTofu does day zero: the cluster itself and a one-time install of Argo CD. Argo CD does day one onward: every application, reconciled continuously from git.
  • The handoff is the app-of-apps pattern. One bootstrap Application in Argo CD points at a git repository that declares the rest of the cluster.
  • The failure mode is two sources of truth. Decide what OpenTofu owns and what Argo CD owns, and never manage the same release from both.
  • State discipline matters. Your kubeconfig lands inside the state file, which is sensitive, so use state encryption or a remote backend before you share it.
Verified
  • OpenTofuv1.12.6 (latest; 1.12.0 GA 2026-05-14)
  • OpenTofu 1.11 linev1.11.14 (final patch)
  • Argo CDv3.5.1 (2026-08-12); 3.5 GA 2026-08-04
  • Argo CD Helm chart (argo/argo-cd)10.4.0
  • k3sv1.36.3+k3s1
  • hashicorp/kubernetes and hashicorp/helm providers2.x current on the tofu registry

Checked 2026-08-25 against the OpenTofu releases page (v1.12.6 latest; v1.12.0 announcement of 2026-05-14), opentofu.org What's new in 1.12, the Argo CD releases page and endoflife.date/argo-cd (v3.5.1, 3.5 GA 2026-08-04), the artifacthub argo-cd chart page (10.4.0), and the k3s releases page (v1.36.3+k3s1). Provider versions on the registries move independently; re-check before you pin.

What OpenTofu actually is

OpenTofu started in August 2023, when HashiCorp moved Terraform from the MPL-2.0 license to the Business Source License. A fork was created from the last open-licensed release, it joined the Linux Foundation, and it has been developed in the open ever since, with the same HCL language, the same provider ecosystem, and a state format that Terraform reads without complaint. The license is MPL-2.0, which means anyone can use it commercially, fork it, and ship it in products, and nobody has to buy a seat to run it.

The practical differences from Terraform are smaller than the marketing implies, and they matter in specific places:

  • The binary is tofu, and it is deliberately drop-in. Existing Terraform workflows, modules, and providers mostly work unchanged.
  • There is no seat licensing and no per-user pricing. The cost is the infrastructure and your time.
  • The tofu registry at search.opentofu.org mirrors the provider ecosystem and hosts community modules, including several k3s bootstrap modules for common providers.
  • OpenTofu ships a few CLI extras Terraform lacks, most usefully -exclude (skip one resource in a plan or apply) and provider-defined functions, plus built-in state encryption that predates Terraform's.

What has not changed is the provider model. OpenTofu consumes the same HashiCorp providers from the same registries, which is exactly what makes this post's configuration valid under either binary.

Why bootstrap with IaC at all

The bootstrap problem is a coordination problem. A cluster needs nodes, a kubeconfig, a network, the GitOps operator, and a first application, in that order, reproducibly. A hand-rolled checklist works once and fails on the second environment. IaC gives you three properties a checklist cannot: repeatability (the same config produces the same cluster), reviewability (the bootstrap is a diff, not a memory), and destroyability (tear it down and rebuild from nothing, which is how you find the steps you forgot to write down).

The honest counterweight, and I will say it before the vendors do: for a single throwaway test cluster, a shell script is faster. IaC earns its keep when the cluster is the platform, not the experiment, which is exactly the boundary the Compose vs Kubernetes post drew. If Kubernetes earns its keep on your hardware, the bootstrap earns IaC.

The bootstrap loop: where OpenTofu stops and Argo CD starts

This is the mental model that keeps the whole thing clean. There are exactly three stages, and each one has one owner:

  1. Day zero, OpenTofu: provision the nodes (directly, or through a k3s module on the tofu registry) and write the kubeconfig.
  2. Day zero, still OpenTofu: use the helm provider to install Argo CD once. This is the last time OpenTofu touches an application.
  3. Day one onward, Argo CD: a bootstrap Application points at your git repository, and everything in that repo is reconciled continuously. You never run tofu apply for an app again.

Argo CD's own documentation calls the pattern cluster bootstrapping, and it offers two shapes for it: the app-of-apps pattern, where one Application points at a directory of further Application manifests, and ApplicationSets, which generate Applications from a generator. The app-of-apps pattern is the simpler default for a homelab; ApplicationSets pay off once you have many clusters or many apps with the same shape.

Hands-on: install OpenTofu

The installer is a shell script, and the package path is the one I use on Debian and Ubuntu. It adds the OpenTofu apt repository, installs tofu, and the binary is yours:

install OpenTofu on Debian/Ubuntu
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh | sh -s -- --install-method deb

tofu --version
OpenTofu v1.12.6

# optional shell completion
tofu -install-autocomplete

Homebrew, winget, and the other package managers are covered in the official install docs, and the verified block above has the version I checked against. Pin the version you build on, and track releases the way the release-tracking post recommends, because OpenTofu ships often and the patch line (1.11.14 was the final 1.11 release) moves faster than you expect.

Hands-on: the bootstrap configuration

Assume the cluster exists and you have a kubeconfig, which is the realistic middle ground. If you are starting from bare VMs, point OpenTofu at a k3s module from the tofu registry instead of the inline provider block below; the rest of the pattern is identical.

Two providers do the work. The kubernetes provider reads your kubeconfig, and the helm provider uses the same kubeconfig to install charts. The kubeconfig path is a variable, because it is the one thing that differs between environments:

# providers.tf
terraform {
  required_version = ">= 1.12"
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.0"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.0"
    }
  }
}

variable "kubeconfig_path" {
  type    = string
  default = "~/.kube/config"
}

provider "kubernetes" {
  config_path = var.kubeconfig_path
}

provider "helm" {
  kubernetes {
    config_path = var.kubeconfig_path
  }
}

The version constraints above are deliberately loose because the registries move independently. Before you rely on exact numbers, check what the tofu registry reports today and pin that, the same discipline as every other dependency in this stack.

Now the resource that does the whole job: one helm_release that installs Argo CD into its own namespace. Everything Argo CD needs after this is declared in git, not in OpenTofu:

# argocd.tf
resource "helm_release" "argocd" {
  name             = "argocd"
  repository       = "https://argoproj.github.io/argo-helm"
  chart            = "argo-cd"
  namespace        = "argocd"
  create_namespace = true
  version          = "10.4.0"

  set {
    name  = "server.insecure"
    value = "true"
  }
}

create_namespace = true is the piece that saves you the ordering headache, because the namespace must exist before the chart can install into it, and OpenTofu handles that dependency for you. The server.insecure override is for the plain HTTP bootstrap; put a real ingress and TLS in front of it before you expose it anywhere, exactly as you would for any self-hosted service.

Hands-on: the app-of-apps handoff

After tofu apply finishes, Argo CD is running but it owns nothing yet. The handoff is one manifest. Create a bootstrap Application that points at a directory of further Application manifests in your git repository, usually called apps:

# apps/bootstrap.yaml, committed to your cluster-config repo
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: bootstrap
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/you/cluster-config.git
    targetRevision: HEAD
    path: apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

The resources-finalizer.argocd.argoproj.io finalizer is the Argo CD convention for an app-of-apps parent: when Argo CD deletes the parent, it deletes the children too, instead of leaving orphans behind. From here on, every new service is a manifest dropped into apps/ and pushed to the repo. No kubectl, no tofu, no SSH into the box. That is the whole payoff of the pattern.

Hands-on: run it

The sequence is boring on purpose:

bootstrap the cluster and Argo CD
tofu init
tofu plan -out bootstrap.tfplan
tofu apply bootstrap.tfplan

# grab the initial admin password from the cluster
kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d

# log in with the CLI (or open the web UI)
argocd login argocd.example.com --grpc-web

# register the repo, then apply the bootstrap app by hand once
argocd app create apps \
  --repo https://github.com/you/cluster-config.git \
  --path apps \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace argocd
argocd app sync apps

After that first sync, Argo CD is self-managing: the bootstrap Application, and everything it points at, lives in the repo, and the only human step left is merging pull requests.

The limits that bite

  • OpenTofu is a provisioner, not a reconciler. After tofu apply returns, nothing watches for drift. That is by design and it is exactly why Argo CD takes over for applications. Expecting OpenTofu to be the GitOps layer is the mistake this whole post is built to prevent.
  • Single-node k3s is still a single failure domain. The bootstrap pattern does not change the hardware math from the Compose vs Kubernetes post: the cluster earns its keep when a second node exists.
  • Provider versioning is on you. The shared providers mean Terraform blog posts mostly work, but the registries move fast and a provider that lags the newest Kubernetes API can fail in confusing ways. Pin versions, and read release notes before upgrading.
  • Not every Terraform feature exists in OpenTofu. The CLI is drop-in, but cloud-only enterprise features like Sentinel policies and the hosted run environment are HashiCorp-side, and OpenTofu's answer to those is its own open tooling. Check the What's new page before you promise a Terraform Cloud feature on OpenTofu.
  • The first bootstrap Application has a chicken-and-egg moment. Argo CD will not sync a repo it cannot read, so register the repository and apply the app-of-apps once by hand before the automation takes over. That single manual step is the cost of the pattern.

OpenTofu vs Terraform at a glance

OpenTofu vs Terraform at a glance
FeatureOpenTofuTerraform
LicenseMPL-2.0BUSL-1.1 since August 2023
GovernanceLinux Foundation, community-drivenHashiCorp-led
CLI1tofu, drop-in compatibleterraform
Seat licensingNonePaid tiers for Terraform Cloud
Registry2search.opentofu.org plus the shared provider registriesregistry.terraform.io
State encryptionBuilt in, shipped earlierAdded in 1.9
Notable CLI extras-exclude, provider-defined functionsCloud-first features and policies
  • License

    OpenTofu
    MPL-2.0
    Terraform
    BUSL-1.1 since August 2023
  • Governance

    OpenTofu
    Linux Foundation, community-driven
    Terraform
    HashiCorp-led
  • CLI1

    OpenTofu
    tofu, drop-in compatible
    Terraform
    terraform
  • Seat licensing

    OpenTofu
    None
    Terraform
    Paid tiers for Terraform Cloud
  • Registry2

    OpenTofu
    search.opentofu.org plus the shared provider registries
    Terraform
    registry.terraform.io
  • State encryption

    OpenTofu
    Built in, shipped earlier
    Terraform
    Added in 1.9
  • Notable CLI extras

    OpenTofu
    -exclude, provider-defined functions
    Terraform
    Cloud-first features and policies
  1. Same HCL, providers, and state format
  2. Providers are shared

The honest framing: for this bootstrap workflow, the choice barely matters, because the configuration is identical. The decision is about governance and licensing, not about features. If you want open governance, MPL licensing, and no seat costs, OpenTofu. If your organization already standardized on HashiCorp tooling and Terraform Cloud, Terraform, and the pattern below works unchanged under both.

Which should you pick?

  • Choose OpenTofu when: you want the open-licensed fork, you self-host your state and your pipelines, you dislike per-seat pricing, or you want the tofu registry's community k3s modules.
  • Choose Terraform when: your team already runs Terraform Cloud, your compliance story depends on HashiCorp enterprise features, or your modules assume a specific Terraform version and you have not tested the migration.
  • Use neither when: the cluster is a throwaway test box. A shell script is faster, and IaC is a tool you adopt when the platform becomes the product, not before.

My own rule, after running both: the bootstrap pattern in this post is the default for any k3s box I expect to still exist in a year, and OpenTofu is the binary I reach for, because the fork is the one that stays open. Everything after the Argo CD handoff is git, and git does not care which binary applied the chart.

Official sources

  • OpenTofu: https://opentofu.org/
  • OpenTofu releases: https://github.com/opentofu/opentofu/releases
  • What's new in OpenTofu 1.12: https://opentofu.org/docs/intro/whats-new/
  • OpenTofu registry: https://search.opentofu.org/
  • Argo CD releases: https://github.com/argoproj/argo-cd/releases
  • Argo CD cluster bootstrapping docs: https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/
  • Argo CD Helm chart: https://artifacthub.io/packages/helm/argo/argo-cd
  • k3s releases: https://github.com/k3s-io/k3s/releases
  • Our Compose vs Kubernetes post: https://systhoughts.com/posts/docker-compose-vs-kubernetes-self-hosted-apps
  • Our release-tracking post: https://systhoughts.com/posts/tracking-software-releases-across-forges

Are you bootstrapping your clusters with OpenTofu, Terraform, or plain scripts, and where did you draw the line between IaC and Argo CD? Drop it in the comments.

Until next time, keep your systems thoughtful.

No comments yet