Build a Local Kubernetes Lab with kind and Terraform

Build a reproducible multi-node Kubernetes lab on Ubuntu using kind, Terraform and Ansible, with automated setup and host port mappings.

This post is also available in: Spanish

Running Kubernetes locally is easy when all you need is a temporary cluster. The challenge begins when you want the environment to be reproducible, accessible from another computer, and simple enough to rebuild without repeating a long list of manual commands.

In this first part of the series, we will prepare an Ubuntu host with Ansible and provision a three-node Kubernetes cluster with Terraform and kind. The result will be a small lab that we can destroy and recreate consistently as we add a private registry, an original API and UI, GitOps, and observability in later articles.

The goal is not to pretend that kind is a production platform. It is to create an inexpensive and practical environment for learning, testing automation, and reproducing Kubernetes workflows.

By the end, we will have:

  • An Ubuntu host prepared automatically with Ansible.

  • Docker, Terraform, kubectl, Helm and kind installed.

  • One kind control-plane node and two workers.

  • A kubeconfig written to ~/.kube/config.

  • Stable host-to-NodePort mappings for future services.

  • Repeatable apply, validation and destroy workflows.

  • An optional Portainer installation for visual cluster inspection.

Architecture

Ubuntu host
|
v
Ansible host setup
|
v
Terraform
|
v
+-------------+
| kind cluster |
+-------------+
|
+------------------+------------------+------------------+
| | | |
v v v v
Control plane Worker 1 Worker 2 Portainer (optional)

Ansible prepares the operating system. Terraform owns the cluster lifecycle. kind runs the Kubernetes nodes as Docker containers. This separation gives each tool a clear responsibility and makes troubleshooting much easier.

Why kind?

kind, short for Kubernetes IN Docker, runs Kubernetes nodes as containers. It was created primarily for testing Kubernetes, but it is also a good fit for local labs because it is fast, supports multi-node topologies, and can be recreated easily.

For this project, kind gives us three useful characteristics:

  1. Portability: the lab does not require a cloud account.

  2. Reproducibility: the complete cluster definition lives in code.

  3. Low operational cost: destroying the lab removes the Kubernetes nodes without leaving cloud resources running.

This environment is for development and learning. A production design would normally use a managed service such as Amazon EKS, Azure Kubernetes Service or Google Kubernetes Engine, or a purpose-built on-premises distribution.

Why combine Ansible and Terraform?

There is some overlap between both tools, but they solve different parts of this lab well:

Tool

Responsibility

Ansible

Install and configure host-level prerequisites

Terraform

Create, update and destroy the kind cluster

Docker

Run the Kubernetes node containers

kubectl

Inspect and validate Kubernetes

Helm

Install platform applications in later phases

The workflow is intentionally layered:

Ubuntu → Ansible → Terraform → kind → Kubernetes workloads

Requirements

Use a fresh Ubuntu 22.04 or 24.04 host when possible.

Recommended resources:

Resource

Minimum

Recommended

CPU

4 cores

6 or more cores

Memory

8 GB

16 GB

Free disk

30 GB

50 GB

Eight gigabytes is enough for the base cluster and a few small workloads. Harbor, observability components and application workloads will need more headroom. During my own tests, insufficient memory caused worker nodes to become NotReady, so I recommend 16 GB for the complete series.

You also need:

  • Internet access to download packages and container images.

  • A user with sudo privileges.

  • Git to clone the project.

Repository structure

Create the following structure:

didgii-local-k8s-platform/
├── ansible/
│ ├── inventory.ini
│ ├── setup-host.yml
│ └── manage-kind-cluster.yml
├── terraform/
│ └── kind-cluster/
│ ├── versions.tf
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── .gitignore

The application, Harbor and GitOps directories will be added in later articles.

Step 1: Create the Ansible inventory

Create ansible/inventory.ini:

[local]
localhost ansible_connection=local

The playbooks run directly on the Ubuntu host, so no SSH connection is needed.

Step 2: Prepare Ubuntu with Ansible

Create ansible/setup-host.yml:

---
- name: Prepare the Didgii Kubernetes lab host
hosts: local
become: true
gather_facts: true
vars:
target_user: "{{ ansible_env.SUDO_USER | default(ansible_user_id, true) }}"
system_arch: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' }}"
kind_version: "v0.33.0"
tasks:
- name: Validate the host architecture
ansible.builtin.assert:
that:
- ansible_architecture in ['x86_64', 'aarch64', 'arm64']
fail_msg: "This playbook currently supports AMD64 and ARM64 hosts."
- name: Install prerequisite packages
ansible.builtin.apt:
name:
- ca-certificates
- curl
- git
- gnupg
- unzip
state: present
update_cache: true
- name: Create the APT keyring directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
- name: Download the Docker signing key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
- name: Configure the Docker repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ system_arch }} signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/ubuntu
{{ ansible_distribution_release }} stable
filename: docker
state: present
update_cache: true
- name: Install Docker
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
- name: Start and enable Docker
ansible.builtin.systemd_service:
name: docker
state: started
enabled: true
- name: Add the local user to the Docker group
ansible.builtin.user:
name: "{{ target_user }}"
groups: docker
append: true
register: docker_group
- name: Download the HashiCorp signing key
ansible.builtin.get_url:
url: https://apt.releases.hashicorp.com/gpg
dest: /etc/apt/keyrings/hashicorp.asc
mode: "0644"
- name: Configure the HashiCorp repository
ansible.builtin.apt_repository:
repo: >-
deb [signed-by=/etc/apt/keyrings/hashicorp.asc]
https://apt.releases.hashicorp.com
{{ ansible_distribution_release }} main
filename: hashicorp
state: present
update_cache: true
- name: Install Terraform
ansible.builtin.apt:
name: terraform
state: present
- name: Download kubectl
ansible.builtin.get_url:
url: >-
https://dl.k8s.io/release/{{ lookup('ansible.builtin.url',
'https://dl.k8s.io/release/stable.txt') | trim }}/bin/linux/{{ system_arch }}/kubectl
dest: /usr/local/bin/kubectl
mode: "0755"
- name: Download kind
ansible.builtin.get_url:
url: "https://kind.sigs.k8s.io/dl/{{ kind_version }}/kind-linux-{{ system_arch }}"
dest: /usr/local/bin/kind
mode: "0755"
- name: Download the Helm installer
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
dest: /tmp/get_helm.sh
mode: "0700"
- name: Install Helm
ansible.builtin.command:
cmd: /tmp/get_helm.sh
creates: /usr/local/bin/helm
- name: Remove the Helm installer
ansible.builtin.file:
path: /tmp/get_helm.sh
state: absent
- name: Explain the Docker group change
ansible.builtin.debug:
msg: >-
Docker group membership changed. Log out and back in before continuing.
when: docker_group.changed

First install Ansible using the Ubuntu package manager:

sudo apt update
sudo apt install -y ansible

Then run the playbook:

cd didgii-local-k8s-platform/ansible
ansible-playbook -i inventory.ini setup-host.yml

If your user was just added to the Docker group, log out and back in. For a temporary shell refresh, you can also run:

newgrp docker

Validate the tools:

docker info
terraform version
kind version
kubectl version --client
helm version

Do not continue until docker info works without sudo.

Step 3: Configure the Terraform provider

Create terraform/kind-cluster/versions.tf:

terraform {
required_version = ">= 1.5.0"
required_providers {
kind = {
source = "tehcyx/kind"
version = "~> 0.11.0"
}
}
}
provider "kind" {}

The provider source is important. Terraform does not publish an official hashicorp/kind provider. If the source is omitted or declared incorrectly, terraform init may try to download a provider that does not exist.

Step 4: Define the cluster variables

Create terraform/kind-cluster/variables.tf:

variable "cluster_name" {
description = "Name of the local kind cluster"
type = string
default = "didgii-lab"
}
variable "kubeconfig_path" {
description = "Path where kind writes the kubeconfig"
type = string
default = "~/.kube/config"
}
variable "extra_port_mappings" {
description = "Ports published from the kind control-plane container"
type = list(object({
container_port = number
host_port = number
listen_address = string
protocol = string
}))
default = [
{
container_port = 30779
host_port = 9443
listen_address = "127.0.0.1"
protocol = "TCP"
},
{
container_port = 30443
host_port = 8443
listen_address = "127.0.0.1"
protocol = "TCP"
},
{
container_port = 30080
host_port = 8080
listen_address = "127.0.0.1"
protocol = "TCP"
}
]
}

These mappings reserve ports for Portainer, Argo CD and the example application used later in the series. Binding them to 127.0.0.1 keeps them accessible only from the Ubuntu host.

If this lab runs inside a remote VM and you need to access it from your laptop, change listen_address to 0.0.0.0. That exposes the ports on every host interface, so protect the VM with a firewall and never expose these management interfaces directly to the internet.

Port mappings are applied when kind creates the node container. Changing them later requires recreating the cluster.

Step 5: Create the multi-node cluster

Create terraform/kind-cluster/main.tf:

resource "kind_cluster" "lab" {
name = var.cluster_name
wait_for_ready = true
kubeconfig_path = pathexpand(var.kubeconfig_path)
kind_config {
kind = "Cluster"
api_version = "kind.x-k8s.io/v1alpha4"
node {
role = "control-plane"
dynamic "extra_port_mappings" {
for_each = var.extra_port_mappings
content {
container_port = extra_port_mappings.value.container_port
host_port = extra_port_mappings.value.host_port
listen_address = extra_port_mappings.value.listen_address
protocol = extra_port_mappings.value.protocol
}
}
}
node {
role = "worker"
}
node {
role = "worker"
}
}
}

This creates three Docker containers: one Kubernetes control plane and two workers. A multi-node lab is useful for testing scheduling, node failures and workload distribution, but it does not make a single-host environment highly available. If the Ubuntu host or Docker daemon fails, the complete cluster becomes unavailable.

Create terraform/kind-cluster/outputs.tf:

output "cluster_name" {
description = "Name of the kind cluster"
value = kind_cluster.lab.name
}
output "kubeconfig_path" {
description = "Kubeconfig generated for the cluster"
value = kind_cluster.lab.kubeconfig_path
}

Step 6: Orchestrate Terraform with Ansible

Create ansible/manage-kind-cluster.yml:

---
- name: Manage the Didgii kind cluster
hosts: local
connection: local
gather_facts: false
vars:
action: apply
terraform_directory: "{{ playbook_dir }}/../terraform/kind-cluster"
tasks:
- name: Validate the requested action
ansible.builtin.assert:
that:
- action in ['apply', 'destroy']
fail_msg: "Use -e action=apply or -e action=destroy."
- name: Initialize Terraform
ansible.builtin.command:
cmd: terraform init -input=false
chdir: "{{ terraform_directory }}"
when: action == 'apply'
- name: Check Terraform formatting
ansible.builtin.command:
cmd: terraform fmt -check -recursive
chdir: "{{ terraform_directory }}"
when: action == 'apply'
- name: Validate Terraform
ansible.builtin.command:
cmd: terraform validate
chdir: "{{ terraform_directory }}"
when: action == 'apply'
- name: Create the cluster
ansible.builtin.command:
cmd: terraform apply -auto-approve -input=false
chdir: "{{ terraform_directory }}"
when: action == 'apply'
- name: Destroy the cluster
ansible.builtin.command:
cmd: terraform destroy -auto-approve -input=false
chdir: "{{ terraform_directory }}"
when: action == 'destroy'

Terraform remains the owner of the cluster. Ansible only provides a consistent entry point and will later orchestrate the other platform layers in the correct order.

Step 7: Create and validate the cluster

Run:

cd didgii-local-k8s-platform/ansible
ansible-playbook -i inventory.ini manage-kind-cluster.yml -e action=apply

Validate the result:

kubectl cluster-info --context kind-didgii-lab
kubectl get nodes -o wide
kubectl get pods --all-namespaces
docker ps --filter label=io.x-k8s.kind.cluster=didgii-lab

Expected node state:

didgii-lab-control-plane Ready control-plane
didgii-lab-worker Ready <none>
didgii-lab-worker2 Ready <none>

Confirm that all nodes become ready:

kubectl wait \
--for=condition=Ready \
nodes \
--all \
--timeout=180s

Optional: Install Portainer

Portainer is not required to operate Kubernetes, but it provides a useful visual check while learning how resources relate to each other.

Add the repository and install it as a NodePort service:

helm repo add portainer https://portainer.github.io/k8s/
helm repo update
helm upgrade --install portainer \
portainer/portainer \
--namespace portainer \
--create-namespace \
--set service.type=NodePort \
--set service.httpsNodePort=30779 \
--wait \
--timeout 5m

Open:

https://localhost:9443

If the cluster runs inside a VM and the port is bound to 0.0.0.0, use:

https://HOST_IP:9443

Retrieve the initial setup token without publishing its value:

kubectl logs deployment/portainer \
--namespace portainer \
--since=10m | grep setup_token

Portainer limits the initial configuration window for security. If the page reports that the installation timed out, restart the Deployment and try again:

kubectl rollout restart deployment/portainer -n portainer
kubectl rollout status deployment/portainer -n portainer --timeout=120s

Troubleshooting

Docker works only with sudo

Confirm your effective groups:

id
getent group docker

If the account appears in the Docker group but the current shell does not, log out and back in or run:

newgrp docker

Worker nodes remain NotReady

Start with host capacity:

free -h
docker stats --no-stream
kubectl describe node didgii-lab-worker
kubectl get pods -A -o wide

Memory pressure is common when several Kubernetes nodes and platform applications share one small VM. Increase the VM memory before trying random Kubernetes changes.

A host port is already in use

Check the listener before recreating the cluster:

sudo ss -lntp
docker ps --format 'table {{.Names}}\t{{.Ports}}'

Choose a different host_port or stop the process that legitimately owns it. Avoid killing processes until you identify what started them.

Port mapping changes do not appear

extraPortMappings are Docker container settings. Terraform must recreate the kind cluster for changes to take effect:

cd ../terraform/kind-cluster
terraform apply -replace=kind_cluster.lab

This deletes the existing cluster data. Use it only when rebuilding the lab is acceptable.

Destroy the lab

Run:

cd ../../ansible
ansible-playbook -i inventory.ini manage-kind-cluster.yml -e action=destroy

Destroying the cluster removes its workloads and locally stored Kubernetes data. Terraform configuration remains in Git, so the environment can be recreated later.

What comes next?

We now have a repeatable Kubernetes foundation, but the cluster does not yet contain our application platform.

In Part 2, we will deploy Harbor as a private container registry, generate a local certificate authority, configure HTTPS, and make Docker and every kind node trust the registry. We will also reproduce and solve one of the most common local-registry errors:

x509: certificate signed by unknown authority

Frequently asked questions

Is kind suitable for production?

No. kind is primarily designed for testing Kubernetes and is excellent for local development, CI and labs. Use a production-oriented Kubernetes platform for real workloads.

Why use three nodes on one host?

It allows us to test scheduling and multi-node Kubernetes behavior. It does not provide host-level high availability because every node still runs on the same Ubuntu machine.

Why use Terraform instead of only kind commands?

Terraform gives the cluster a declarative lifecycle and makes its topology, kubeconfig path and port mappings reviewable in code.

Why do port changes require cluster recreation?

kind implements these mappings as published Docker container ports. Docker assigns them when the node container is created.

References

Comments