Provision k3s with Terraform and Ansible: A Repeatable Cluster Blueprint

A k3s cluster can begin as a quick experiment: one server, a curl command, and a kubeconfig copied to a laptop. That is useful for learning, but it does not scale into a dependable platform. The moment a cluster runs shared services, hosts internal applications, or becomes part of a delivery pipeline, its creation needs to be repeatable.

The practical division of responsibility is simple:

  • Terraform creates the infrastructure: virtual machines, networks, IP assignments, DNS records, and firewall rules.
  • Ansible configures those machines: operating-system prerequisites, k3s installation, node joins, kubeconfig handling, and post-install validation.
  • k3s provides the lightweight Kubernetes distribution that runs the workload.

This combination turns cluster provisioning from a sequence of terminal commands into a versioned, reviewable system. Terraform draws the landscape; Ansible prepares the ground and assembles the cluster.

Why split provisioning between Terraform and Ansible?

Terraform is excellent at declaring resources exposed by an infrastructure API. It can create three virtual machines in Proxmox, attach them to a network, assign tags, and expose their addresses as outputs. It is less suitable for imperative host-level tasks such as waiting for cloud-init, modifying /etc/sysctl.conf, installing packages, or running a bootstrap command only on the first control-plane node.

Ansible is designed for exactly those operations. Its inventory can be generated from Terraform outputs, and its playbooks can express configuration in an idempotent way. A rerun should converge nodes toward the intended state rather than recreate everything blindly.

Keeping those tools focused also makes failures easier to understand. If Terraform fails, investigate the infrastructure provider, credentials, quota, or network configuration. If Ansible fails, inspect SSH connectivity, package repositories, service logs, or k3s tokens. The boundary is clear.

Start with an explicit cluster shape

Before writing code, define the cluster topology. For a small homelab or internal environment, a useful starting point is:

| Role | Count | Purpose | | --- | ---: | --- | | Server | 3 | Kubernetes control plane and embedded etcd quorum | | Agent | 2+ | Application workloads | | Bootstrap host | 1 | Optional runner for Terraform and Ansible |

Three server nodes provide resilience for embedded etcd. A single server is simpler but creates an obvious availability boundary: if that machine is unavailable, the control plane is unavailable. For non-production experimentation, one server plus one or more agents is completely reasonable; the important part is making that trade-off intentionally.

Also decide early how workloads will be exposed. k3s installs Traefik by default in many configurations, which is convenient for ingress. If a different ingress controller is required, disable the bundled component during installation instead of deploying two controllers that both expect to own ports 80 and 443.

Terraform: create predictable infrastructure

A Terraform project should make the physical or virtual shape of the cluster visible. In a Proxmox environment, that usually means cloning a cloud-init-enabled Linux template for each node. In cloud environments, the same pattern maps to virtual machine instances, security groups, subnets, and managed DNS zones.

A useful module interface describes intent rather than individual machines:

hcl

The exact provider syntax changes across platforms, but several practices remain valuable:

  1. Use immutable base images. Build or maintain a known Linux template with cloud-init and the required SSH configuration. Avoid manually modifying individual nodes after they are created.
  2. Assign stable addresses or stable DNS records. Kubernetes control-plane membership depends on reliable communication. DHCP reservations can work, but static addresses or managed DNS records are easier to reason about.
  3. Separate secrets from configuration. Provider passwords, API tokens, and SSH private keys should come from environment variables, a secret manager, or CI secrets—not committed .tfvars files.
  4. Expose machine data as outputs. IP addresses and hostnames are the handshake between Terraform and Ansible.

For example, Terraform can export a structured output:

hcl

Do not treat terraform apply as proof that the nodes are ready. A provider may report success before cloud-init finishes, before SSH is accepting connections, or before package repositories are reachable. That is an Ansible concern.

Generate inventory instead of maintaining it twice

The fastest path to configuration drift is creating the same host list in Terraform and in a hand-maintained Ansible inventory. Generate inventory from Terraform state or outputs instead.

One straightforward approach is to emit JSON and transform it into an inventory file in the pipeline:

bash

The generated inventory might look like this:

ini

Dynamic inventory plugins can eliminate the rendering script when the infrastructure platform supports them. The key principle is not the mechanism; it is preserving one source of truth for node identity.

Ansible: prepare every node before installing k3s

The first play should establish the baseline across all nodes. A typical Linux preparation role updates package metadata, installs dependencies, enables required kernel modules, configures sysctl values, and ensures swap is disabled.

yaml

The exact prerequisites depend on the operating system and networking model. k3s removes much of the operational weight of a full Kubernetes installation, but it still relies on a healthy host: correct time, working DNS, sufficient disk space, and reachable peers.

Bootstrap the first server deliberately

The first server is special because it creates the cluster. Install it with a fixed token and a stable API endpoint. For a highly available control plane, the endpoint should be a load balancer or virtual IP rather than the address of the first server.

A simple first-server command can be managed with Ansible’s command module and a creates guard:

yaml

In a mature implementation, pin the k3s version rather than always installing the latest release. Store the join token in Ansible Vault or an external secret manager. The token grants cluster membership, so it deserves the same treatment as infrastructure credentials.

After the bootstrap node is healthy, join the remaining servers using K3S_URL=https://<endpoint>:6443 and the same token. Then join agents with the same endpoint and token. Serial execution is helpful for servers: establish etcd membership one node at a time, validate it, and only then continue.

Validate the cluster as part of provisioning

A successful playbook run is not enough. Add explicit checks that confirm the outcome:

yaml

Also verify that CoreDNS is running, the expected number of nodes has registered, and the API endpoint is reachable from the machine that will run deployments. For environments using Traefik, deploy a small test service and confirm that ingress routing works before declaring the platform ready.

Make destruction and recovery intentional

Terraform makes deletion easy, which is valuable and dangerous. Protect persistent workloads by separating cluster infrastructure from application data where possible. Back up k3s embedded etcd snapshots, document restore procedures, and test them on a disposable cluster.

The most valuable outcome of this approach is not merely a faster installation. It is confidence. A failed node can be replaced from code. A test cluster can mirror production topology. A teammate can review changes before machines exist. Terraform provides the repeatable foundation, Ansible supplies the controlled bootstrap, and k3s turns that infrastructure into a compact Kubernetes platform ready for real workloads.

Comments (0)

No comments yet. Be the first to share your thoughts!

Provision k3s with Terraform and Ansible: A Repeatable Cluster Blueprint