01

Bootstrap Kubernetes and Argo CD as Code

Build a disposable Kubernetes control plane and its GitOps controller from reviewed, pinned code—without creating infrastructure by hand.

The enterprise problem and today’s slice

Enterprise problem: A platform team needs Argo CD before Argo CD can reconcile anything, but an undocumented sequence of local commands creates an unrepeatable cluster, hides ownership, and leaves recovery dependent on one operator's terminal history.

Whole-course context: This course starts with Docker and an empty directory, establishes the Kubernetes and Argo CD bootstrap boundary today, then lets Argo CD pull application and platform desired state from reviewed Git on Days 02 and 03.

Today’s slice: An operator uses one OpenTofu state to create a local Kind cluster and a second state to install a pinned Argo CD Helm release, while every mutation remains declarative and every inspection command remains read-only.

End-of-day evidence: Two successful OpenTofu applies, two committed dependency lock files, a ready Kind node, an argocd namespace, healthy Argo CD workloads, and state listings prove exactly which reconciler owns each bootstrap resource.

Still unsolved: No application repository is registered, no AppProject or ApplicationSet exists, no workload has been deployed, and production identity, secrets, promotion, recovery, observability, and upgrades remain deferred.

Customer use cases

Teams often treat “install Argo CD” as one task even though cluster creation and in-cluster bootstrapping fail at different times and require different recovery paths. These use cases make the two customer outcomes and their evidence explicit.

Use case IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D01-UC-01Platform engineerCreate a reproducible Kubernetes cluster from an empty workstation directoryA named Kind cluster and kubeconfig are created by the pinned Kind provider, and the cluster state lists only cluster-lifecycle resourcesA failed plan or apply leaves provider diagnostics and state evidence; retry or destroy concerns only the cluster state
D01-UC-02GitOps platform operatorInstall Argo CD declaratively after the Kubernetes API is reachableThe namespace and pinned argo-cd chart release become ready through the bootstrap state, with no manual resource creationA failed Helm release remains attributable to the bootstrap plan; the operator can inspect events, correct code, and re-apply without changing cluster ownership

Actor-centred user stories

A tool checklist cannot prove that operators know what they own or how to recover it. These stories define visible acceptance conditions for both halves of the bootstrap.

Story IDUse case IDsUser storyObservable acceptance conditions
D01-US-01D01-UC-01As a platform engineer, I want the local cluster created from pinned IaC so that every teammate can reproduce and remove it safely.tofu state list in infra/cluster shows one Kind cluster; kubectl get nodes through the generated kubeconfig reports Ready; .terraform.lock.hcl is versioned.
D01-US-02D01-UC-02As a GitOps platform operator, I want Argo CD installed from a separate pinned plan so that chart changes never pretend to own the cluster.tofu state list in infra/bootstrap shows the namespace and Helm release; Argo CD pods become ready; no imperative create command appears in the runbook.

End-to-end product flows

The safe path has a hard readiness boundary: providers that call the Kubernetes API are configured only after that API exists. The recovery path preserves the same ordering instead of repairing resources by hand.

Flow IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D01-FLOW-01D01-UC-01, D01-UC-02HappyOperator runs the cluster apply, then the bootstrap apply1. Docker reports a running engine.<br>2. OpenTofu initializes the cluster root and selects the locked Kind provider.<br>3. The Kind resource creates a control-plane container and writes kubeconfig.<br>4. Read-only Kubernetes inspection proves the node is ready.<br>5. OpenTofu initializes the bootstrap root and configures Kubernetes and Helm providers from that existing kubeconfig.<br>6. The namespace and pinned Argo CD chart become ready.Separate state listings, lock files, a ready node, ready Argo CD pods, and Helm release metadata bind each result to its owner.
D01-FLOW-02D01-UC-01, D01-UC-02RecoveryBootstrap apply fails or the environment must be removed1. Inspect the failed plan, release status, pods, and events without mutating them.<br>2. Correct provider, chart values, or workstation prerequisites in Git.<br>3. Re-run the bootstrap plan and apply.<br>4. For teardown, destroy bootstrap while the API still exists.<br>5. Destroy the Kind cluster only after the bootstrap state is empty.The corrected commit and successful plan replace terminal folklore; both states finish empty in dependency order during teardown.

System design derived from the flows

Combining both use cases in one state creates a provider-bootstrap dependency: planning in-cluster resources may require an API endpoint that the same plan has not created yet. The design gives each entry point one state owner and an explicit filesystem handoff.

Use case IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D01-UC-01make cluster-applyOpenTofu CLI, tehcyx/kind provider, Docker engine, Kind node containerinfra/cluster OpenTofu state owns cluster lifecycle; generated kubeconfig is a non-secret local handoffOpenTofu diagnostics, Docker container status, Kind provider state, and read-only node inspection
D01-UC-02make bootstrap-applyOpenTofu CLI, hashicorp/kubernetes provider, hashicorp/helm provider, Kubernetes API, official Argo CD Helm chartinfra/bootstrap OpenTofu state owns the argocd namespace and Helm release; Kubernetes stores their live objectsOpenTofu diagnostics, Helm release status, Kubernetes workload status, events, and bootstrap state

Data model and ownership

Bootstrap data is operational metadata, not application-domain data. Treating kubeconfig, IaC state, Helm release records, and future application records as one store would blur access and deletion responsibilities.

Generated-application database: Not created in this slice — Day 01 creates only a local Kubernetes control plane and Argo CD bootstrap resources; future application data remains outside both OpenTofu states.

Record or entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
KindClusterCluster OpenTofu state owned by platform engineeringResource address kind_cluster.gitopsDocker container and generated kubeconfig are provider-managed referencesLocal environment name gitops-labCluster lifecycle appears only in cluster stateDestroyed last, after every API-dependent bootstrap resource is removedD01-UC-01
KubeconfigHandoffLocal ignored file owned by the workstation operatorPath .local/kubeconfigReferences cluster endpoint, CA, and client credentialsLocal environment name gitops-labNever committed; file permissions and workstation access protect credentialsRecreated with cluster apply and removed with local environment cleanupD01-UC-01, D01-UC-02
ArgoNamespaceBootstrap OpenTofu state and Kubernetes API owned by GitOps platform engineeringResource address kubernetes_namespace_v1.argocdUses KubeconfigHandoff only for provider authenticationKubernetes cluster gitops-labNamespace ownership remains separate from future workload namespacesDestroyed before cluster; retained while the Argo CD release existsD01-UC-02
ArgoCDReleaseBootstrap OpenTofu state and Helm release storage owned by GitOps platform engineeringResource address helm_release.argocdReferences namespace and chart argo-cd version 9.5.17Kubernetes cluster gitops-labChart version and values are explicit; no second reconciler mutates release objectsUpgraded through reviewed bootstrap code and destroyed before namespace and clusterD01-UC-02

Start with the ownership contract

IaC succeeds only when each live resource has one desired-state writer. If OpenTofu and Argo CD both reconcile the same object, the systems can fight forever and neither state is trustworthy.

Use this contract throughout the course:

LayerMutating ownerInputsWhat it must not own
Local cluster lifecycleCluster OpenTofu stateReviewed HCL, locked Kind provider, DockerArgo CD release or application workloads
Argo CD installationBootstrap OpenTofu stateExisting kubeconfig, locked providers, pinned chart and valuesKind cluster lifecycle or Day 02 applications
Application deliveryArgo CD from Day 02 onwardReviewed Git manifests and policiesCluster creation or its own bootstrap release
Inspectionkubectl, OpenTofu show/state commandsLive API and stateAny mutation

This is inversion of control at the delivery boundary: CI will validate and publish Git changes, while Argo CD pulls them with in-cluster credentials. CI does not push manifests into Kubernetes and does not hold a production kubeconfig.

Prepare the workstation without creating resources

Bootstrap failure is often a missing prerequisite disguised as an infrastructure bug. Check tools before planning so Docker availability, OpenTofu syntax, and Kubernetes inspection fail early.

Install Docker, OpenTofu, and kubectl through your operating system's trusted package path, then run only version and status checks:

docker version
tofu version
kubectl version --client
git --version

Docker must report a reachable engine, not only an installed client. OpenTofu creates the cluster through the provider, so do not run a separate Kind create command.

Create a repository with two state roots

A single root is tempting because it removes one command, but it couples provider configuration to a not-yet-existing API. Two roots apply SRP to state: cluster owns the API endpoint, while bootstrap consumes that endpoint and owns in-cluster installation.

Create this layout and keep application delivery outside both state roots:

Commit both .terraform.lock.hcl files after initialization. Ignore state, provider caches, plan files, and the credential-bearing kubeconfig:

**/.terraform/*
**/*.tfstate
**/*.tfstate.*
**/*.tfplan
.local/*
!.local/.gitkeep

The .local path is an explicit local interface between roots, not a shared state backend. In a team environment, each state belongs in a separately permissioned remote backend with locking and encryption.

gitops-platform/
├── .gitignore
├── Makefile
├── infra/
│   ├── cluster/
│   │   ├── .terraform.lock.hcl
│   │   ├── versions.tf
│   │   └── main.tf
│   └── bootstrap/
│       ├── .terraform.lock.hcl
│       ├── versions.tf
│       ├── providers.tf
│       ├── main.tf
│       └── argocd-values.yaml
├── platform/
│   └── README.md
└── .local/
    └── kubeconfig

Build the cluster state

Cluster creation is replace-oriented because the Kind provider does not update an existing cluster in place. This consequence is acceptable for a disposable lab and makes the production substitution boundary visible.

Put the following in infra/cluster/versions.tf:

terraform {
  required_version = ">= 1.9.0"
  required_providers {
    kind = {
      source  = "tehcyx/kind"
      version = "0.11.0"
    }
  }
}

Put the cluster resource in infra/cluster/main.tf:

provider "kind" {}

locals {
  kubeconfig_path = abspath("${path.module}/../../.local/kubeconfig")
}

resource "kind_cluster" "gitops" {
  name            = "gitops-lab"
  wait_for_ready  = true
  kubeconfig_path = local.kubeconfig_path

  kind_config {
    kind        = "Cluster"
    api_version = "kind.x-k8s.io/v1alpha4"

    node {
      role = "control-plane"
    }
  }
}

Initialize, review, and apply from that root:

mkdir -p .local
tofu -chdir=infra/cluster init
tofu -chdir=infra/cluster fmt -check
tofu -chdir=infra/cluster validate
tofu -chdir=infra/cluster plan -out=cluster.tfplan
tofu -chdir=infra/cluster apply cluster.tfplan

Review the generated lock file before committing it. The lock records the selected provider build and checksums, while versions.tf records the allowed selection.

Prove the cluster without mutating it

A successful apply proves provider completion, not that the operator is using the intended kubeconfig or that the node stayed healthy. Read-only queries connect state evidence to the live Kubernetes API.

export KUBECONFIG="$PWD/.local/kubeconfig"
kubectl config current-context
kubectl get nodes -o wide
kubectl get --raw=/readyz
tofu -chdir=infra/cluster state list
tofu -chdir=infra/cluster show

Expected state contains kind_cluster.gitops; expected node status is Ready; /readyz returns ok. These commands inspect only—OpenTofu remains the sole mutating owner of bootstrap infrastructure.

Build the bootstrap state

The Kubernetes and Helm providers must connect during planning and applying, so configuring them beside the cluster resource creates a temporal dependency the graph cannot safely solve. The separate root starts only after the kubeconfig contract exists.

Put the provider constraints in infra/bootstrap/versions.tf:

terraform {
  required_version = ">= 1.9.0"
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "3.2.1"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "3.2.0"
    }
  }
}

Configure both providers from the same explicit handoff in infra/bootstrap/providers.tf:

locals {
  kubeconfig_path = abspath("${path.module}/../../.local/kubeconfig")
}

provider "kubernetes" {
  config_path    = local.kubeconfig_path
  config_context = "kind-gitops-lab"
}

provider "helm" {
  kubernetes = {
    config_path    = local.kubeconfig_path
    config_context = "kind-gitops-lab"
  }
}

The file is ignored because it contains client credentials. In production, use short-lived workload identity or an exec credential plugin rather than copying a long-lived administrator kubeconfig into CI.

Install the pinned official chart

An unpinned chart can change CRDs, permissions, defaults, and controller images between identical applies. Pin chart 9.5.17, review its changelog before upgrades, and keep its values in a separately reviewable file.

Put the namespace and release in infra/bootstrap/main.tf:

resource "kubernetes_namespace_v1" "argocd" {
  metadata {
    name = "argocd"

    labels = {
      "app.kubernetes.io/part-of"    = "argocd"
      "app.kubernetes.io/managed-by" = "opentofu"
    }
  }
}

resource "helm_release" "argocd" {
  name       = "argocd"
  namespace  = kubernetes_namespace_v1.argocd.metadata[0].name
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  version    = "9.5.17"

  atomic          = true
  cleanup_on_fail = true
  timeout         = 600
  wait            = true
  wait_for_jobs   = true

  values = [file("${path.module}/argocd-values.yaml")]
}

Keep the first infra/bootstrap/argocd-values.yaml deliberately small:

global:
  logging:
    format: json

configs:
  params:
    server.insecure: false

server:
  service:
    type: ClusterIP

This local release is not a production topology. Production values must add reviewed SSO, RBAC, network policy, ingress and TLS, resource requests and limits, high availability, metrics, notifications, and external secret references based on the organization's threat model.

Apply the bootstrap and collect evidence

Manual recovery commands create live drift that the next plan may undo or misinterpret. Fix configuration in Git, re-plan, and let only the bootstrap state mutate its namespace and Helm release.

tofu -chdir=infra/bootstrap init
tofu -chdir=infra/bootstrap fmt -check
tofu -chdir=infra/bootstrap validate
tofu -chdir=infra/bootstrap plan -out=bootstrap.tfplan
tofu -chdir=infra/bootstrap apply bootstrap.tfplan

Then collect read-only proof:

export KUBECONFIG="$PWD/.local/kubeconfig"
kubectl get namespace argocd
kubectl -n argocd get pods
kubectl -n argocd get deployments,statefulsets
kubectl -n argocd get events --sort-by=.metadata.creationTimestamp
kubectl -n argocd get secrets -l owner=helm,status=deployed
tofu -chdir=infra/bootstrap state list
tofu -chdir=infra/bootstrap show

Do not print secret contents or the initial administrator password into logs. A deployed Helm metadata Secret proves the release exists without exposing its data.

Make the safe path repeatable

Operators under pressure will bypass a runbook if the safe path is harder than the unsafe one. Small Make targets keep ordering visible while preserving normal OpenTofu review and state semantics.

.PHONY: cluster-init cluster-plan cluster-apply bootstrap-init bootstrap-plan bootstrap-apply verify destroy

cluster-init:
	tofu -chdir=infra/cluster init

cluster-plan:
	tofu -chdir=infra/cluster plan -out=cluster.tfplan

cluster-apply:
	tofu -chdir=infra/cluster apply cluster.tfplan

bootstrap-init:
	tofu -chdir=infra/bootstrap init

bootstrap-plan:
	tofu -chdir=infra/bootstrap plan -out=bootstrap.tfplan

bootstrap-apply:
	tofu -chdir=infra/bootstrap apply bootstrap.tfplan

verify:
	KUBECONFIG=.local/kubeconfig kubectl get nodes
	KUBECONFIG=.local/kubeconfig kubectl -n argocd get pods

destroy:
	tofu -chdir=infra/bootstrap destroy
	tofu -chdir=infra/cluster destroy

The targets do not hide planning. A human or CI job still reviews the saved plan before applying it, and policy should prevent an unreviewed apply to shared environments.

Apply DRY, SRP, and IoC at the repository boundary

Best-practice labels become empty slogans unless they change file ownership and runtime authority. Here each principle removes a specific failure mode from the bootstrap.

  • DRY: one kubeconfig path local defines the handoff in each root, one values file defines the release configuration, and later environments call shared modules rather than copying full provider stacks.
  • SRP: cluster lifecycle, Argo CD bootstrap, platform declarations, and application declarations have different roots and owners; changing an app never plans the cluster.
  • IoC: once bootstrapped, Argo CD pulls reviewed desired state and reconciles it from inside the cluster; delivery CI validates Git but does not push Kubernetes objects.
  • One reconciler: OpenTofu owns only cluster and bootstrap resources. Argo CD owns later application and platform workloads. Read-only kubectl proves state but never repairs it.

No-clickops is stronger than “we rarely use the UI.” The Argo CD UI is an observational surface; durable desired-state changes enter through reviewed code, and emergency actions must be reconciled back to Git with evidence.

Tear down in dependency order

Destroying the cluster first removes the API that the bootstrap providers need to uninstall the chart and namespace. The result is stale bootstrap state and a recovery exercise that teaches the wrong habit.

Run the reverse of creation:

tofu -chdir=infra/bootstrap plan -destroy -out=bootstrap-destroy.tfplan
tofu -chdir=infra/bootstrap apply bootstrap-destroy.tfplan
tofu -chdir=infra/bootstrap state list

tofu -chdir=infra/cluster plan -destroy -out=cluster-destroy.tfplan
tofu -chdir=infra/cluster apply cluster-destroy.tfplan
tofu -chdir=infra/cluster state list

Both final state listings should be empty. Remove ignored local plans and kubeconfig after confirming no process still depends on them; never commit those files.

Substitute a production cloud cluster module

Kind is a development implementation of the cluster contract, not a production recommendation. A production platform swaps the cluster root for an EKS, GKE, or AKS module while preserving the downstream bootstrap interface and reconciler boundary.

The production cluster module should output only what bootstrap requires:

output "cluster_endpoint" {
  value     = module.cluster.endpoint
  sensitive = true
}

output "cluster_ca_certificate" {
  value     = module.cluster.cluster_ca_certificate
  sensitive = true
}

output "cluster_name" {
  value = module.cluster.name
}

Prefer provider authentication through short-lived cloud identity in the bootstrap pipeline. Separate remote state backends, encryption, locking, least-privilege roles, network access controls, policy checks, and protected approvals replace the local filesystem handoff without merging responsibilities.

Review the supply-chain pins

Pins make builds repeatable only until maintainers publish a security fix that the platform fails to adopt. Treat version updates as small reviewed changes with changelog, plan, compatibility, and rollback evidence.

This lesson pins the publication-date examples:

DependencyPinReview action before change
OpenTofuTeam-supported >= 1.9.0 constraintPin the executable in CI and test state compatibility
Kind providertehcyx/kind 0.11.0Read provider release notes and recreate the disposable lab
Kubernetes providerhashicorp/kubernetes 3.2.1Check Kubernetes client compatibility and plan namespace changes
Helm providerhashicorp/helm 3.2.0Check provider schema and release-upgrade behavior
Argo CD chartargo-cd 9.5.17Read official chart changelog, rendered diff, CRD notes, and Argo CD upgrade guide

Commit versions.tf, both generated .terraform.lock.hcl files, chart values, and reviewed manifests. Never commit state, saved plans, kubeconfig, tokens, passwords, private keys, or secret values.

Validate the day-one contract

A green pod list can coexist with uncommitted locks, mixed ownership, or a hidden imperative step. Finish only when code, state, live status, and repository hygiene agree.

  • [ ] Docker was the only local substrate created outside OpenTofu.
  • [ ] Cluster state contains the Kind cluster and no Argo CD resources.
  • [ ] Bootstrap state contains the namespace and pinned Helm release and no cluster-lifecycle resource.
  • [ ] Both .terraform.lock.hcl files are committed and their checksum changes were reviewed.
  • [ ] The chart is exactly 9.5.17; both API providers use the declared versions.
  • [ ] Every mutating action was an OpenTofu apply against reviewed code.
  • [ ] kubectl was used only for current-context, readiness, listing, and event inspection.
  • [ ] No kubeconfig, state, plan, password, token, or secret value is staged.
  • [ ] Teardown succeeds bootstrap first, cluster second.
  • [ ] The production module seam can replace Kind without giving Argo CD ownership of its own cluster or release.

Read the primary sources

Examples age, and bootstrap code controls privileged components. Verify provider schemas, chart release notes, and Argo CD behavior against primary documentation before adapting this lab to a shared cluster.

Day 01 ends with a controller ready to observe Git, but deliberately no application declarations. Day 02 will introduce the repository contract, Kustomize overlays, AppProject, and ApplicationSet so Argo CD—not CI or a browser—becomes the application reconciler.