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:
Portability: the lab does not require a cloud account.
Reproducibility: the complete cluster definition lives in code.
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 workloadsRequirements
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
sudoprivileges.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└── .gitignoreThe 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=localThe 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.changedFirst install Ansible using the Ubuntu package manager:
sudo apt updatesudo apt install -y ansibleThen run the playbook:
cd didgii-local-k8s-platform/ansibleansible-playbook -i inventory.ini setup-host.ymlIf your user was just added to the Docker group, log out and back in. For a temporary shell refresh, you can also run:
newgrp dockerValidate the tools:
docker infoterraform versionkind versionkubectl version --clienthelm versionDo 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/ansibleansible-playbook -i inventory.ini manage-kind-cluster.yml -e action=applyValidate the result:
kubectl cluster-info --context kind-didgii-labkubectl get nodes -o widekubectl get pods --all-namespacesdocker ps --filter label=io.x-k8s.kind.cluster=didgii-labExpected node state:
didgii-lab-control-plane Ready control-planedidgii-lab-worker Ready <none>didgii-lab-worker2 Ready <none>Confirm that all nodes become ready:
kubectl wait \ --for=condition=Ready \ nodes \ --all \ --timeout=180sOptional: 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 5mOpen:
https://localhost:9443If the cluster runs inside a VM and the port is bound to 0.0.0.0, use:
https://HOST_IP:9443Retrieve the initial setup token without publishing its value:
kubectl logs deployment/portainer \ --namespace portainer \ --since=10m | grep setup_tokenPortainer 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 portainerkubectl rollout status deployment/portainer -n portainer --timeout=120sTroubleshooting
Docker works only with sudo
Confirm your effective groups:
idgetent group dockerIf the account appears in the Docker group but the current shell does not, log out and back in or run:
newgrp dockerWorker nodes remain NotReady
Start with host capacity:
free -hdocker stats --no-streamkubectl describe node didgii-lab-workerkubectl get pods -A -o wideMemory 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 -lntpdocker 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-clusterterraform apply -replace=kind_cluster.labThis deletes the existing cluster data. Use it only when rebuilding the lab is acceptable.
Destroy the lab
Run:
cd ../../ansibleansible-playbook -i inventory.ini manage-kind-cluster.yml -e action=destroyDestroying 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 authorityFrequently 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.
Comments