# Deploy the Kubernetes cluster bootstrap template with OpenTofu

Source: https://docs.quake.ai/resources/deployments/deploy-k8s-cluster-template
Markdown: https://docs.quake.ai/resources/deployments/deploy-k8s-cluster-template.md
> Stand up a kubeadm control plane and worker nodes on a private subnet with one floating IP for SSH access using the k8s-cluster OpenTofu template.

---

# Deploy the Kubernetes cluster bootstrap template with OpenTofu

Stand up a `kubeadm` cluster on Compute instances using the [validated OpenTofu template](/docs/platform/validation#how-infrastructure-templates-are-checked) `k8s-cluster`. Cloud-init runs `kubeadm init` and installs Flannel on the bootstrap control plane; you join workers after apply and run `kubectl` on the control plane over SSH because the API listens on the private subnet only.

This template bootstraps Kubernetes with `kubeadm` on Compute instances. It is separate from the managed Kubernetes service (Magnum). For a Magnum-based cluster, see [Deploy your first app on Kubernetes](/resources/deployments/deploy-first-app).

<PricingCompanion
  components={[
    { kind: "template", slug: "k8s-cluster", required: true },
  ]}
/>

<Figure size="md" caption="Kubernetes cluster topology: private subnet, control plane with floating IP for SSH, and worker nodes joined after apply">

```d2
direction: right

cloud: Quake AI {
  fip: Floating IP\nSSH :22
  private: Private network\n192.168.70.0/24 {
    cp: Control plane\nkubeadm init
    worker: Worker\nkubeadm join
  }
  router: Router\nto PublicStatic
}

cloud.fip -> cloud.private.cp: SSH
cloud.private.cp -> cloud.private.worker: join
cloud.router -> cloud.private
```

</Figure>

## Prerequisites

You need:

- A Quake AI account with [application credentials](/docs/tools/generate-app-credentials)
- OpenTofu 1.6.0 or later ([installation guide](https://opentofu.org/docs/intro/install/))
- OpenStack credentials sourced into the shell (`source openrc.sh`). See [the OpenStack CLI guide](/docs/tools/openstack-cli).
- `kubectl` installed on your workstation ([Kubernetes install docs](https://kubernetes.io/docs/tasks/tools/))
- An SSH key pair already uploaded to the project. See [Add an SSH key](/docs/tools/add-ssh-key).
- A copy of the `k8s-cluster` template from [the template reference page](/resources/iac-templates/k8s-cluster)
- Enough project quota for one `m2a.xlarge` control plane, one `s1a.medium` worker, two 40 GB boot volumes, and one floating IP



The template default is three workers. Set `worker_count = 1` in `terraform.tfvars` to reduce quota use while you learn the join workflow. Production clusters need more workers and often multiple control plane nodes.



## Step 1: Configure variables

Copy `terraform.tfvars.example` to `terraform.tfvars` and set:

```hcl
key_name     = "YOUR_KEY_NAME"
worker_count = 1
```

Defaults for flavors, Kubernetes version, and network CIDR are documented on the [Kubernetes Cluster Bootstrap](/resources/iac-templates/k8s-cluster) reference page.

## Step 2: Apply the template

From the template directory, run:

```bash
tofu init
tofu plan
tofu apply
```

Type `yes` when prompted. Expect 10 to 15 minutes before the API responds while cloud-init runs `kubeadm init` and applies Flannel.

When the run finishes, note `api_endpoint` from the outputs.

## Step 3: Wait for the control plane to finish bootstrapping

SSH to the control plane through the floating IP and wait for cloud-init and `kubeadm init`:

```bash
API_HOST=$(tofu output -raw api_endpoint | cut -d: -f1)
ssh -i YOUR_PRIVATE_KEY_PATH -o StrictHostKeyChecking=accept-new ubuntu@"${API_HOST}" \
  'cloud-init status --wait && test -f /etc/kubernetes/admin.conf && echo control-plane-ready'
```

Confirm the API listener responds locally on the control plane:

```bash
ssh -i YOUR_PRIVATE_KEY_PATH ubuntu@"${API_HOST}" \
  'sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf get nodes'
```

The control plane node should report `Ready` before you join a worker.

## Step 4: Run kubectl on the control plane

Define a shell helper that runs `kubectl` on the control plane over SSH:

```bash
k8s() {
  ssh -i YOUR_PRIVATE_KEY_PATH ubuntu@"${API_HOST}" \
    "sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf $*"
}

k8s get nodes
```

## Step 5: Join the worker node

Cloud-init on workers installs Kubernetes packages but does not run `kubeadm join`. SSH to the worker uses `ProxyJump` through the control plane because worker nodes have no public address:

```bash
WORKER_IP=$(tofu output -json worker_ips | python3 -c 'import json,sys; print(json.load(sys.stdin)[0])')

ssh -i YOUR_PRIVATE_KEY_PATH \
  -o StrictHostKeyChecking=accept-new \
  -J ubuntu@"${API_HOST}" \
  ubuntu@"${WORKER_IP}" \
  'cloud-init status --wait && command -v kubeadm'

JOIN_CMD=$(ssh -i YOUR_PRIVATE_KEY_PATH ubuntu@"${API_HOST}" 'sudo kubeadm token create --print-join-command')

ssh -i YOUR_PRIVATE_KEY_PATH \
  -J ubuntu@"${API_HOST}" \
  ubuntu@"${WORKER_IP}" \
  "sudo ${JOIN_CMD}"
```

Run `k8s get nodes -o wide`. Both nodes should read `Ready`.

## Step 6: Deploy a test workload

```bash
k8s create deployment hello-k8s --image=nginx:1.27-alpine
k8s rollout status deployment/hello-k8s --timeout=120s
k8s get pods -o wide
k8s delete deployment hello-k8s
```

## Next steps

- [Kubernetes Cluster Bootstrap template](/resources/iac-templates/k8s-cluster)
- [Deploy your first app on Kubernetes](/resources/deployments/deploy-first-app)
- [Private Network + VPN template](/resources/iac-templates/private-network-vpn)
- [Kubernetes platforms](/resources/solutions/kubernetes-platforms)

## Clean up

Run `tofu destroy` from the project directory when finished. Type `yes` to confirm. Verify in the Console that the instances and floating IP are gone.
