Provision a Local Kubernetes Cluster
Use Terraform to spin up a multi-node Kubernetes cluster locally with ingress.
Introduction
30 Second Summary
Setting up servers and networking by hand is slow, error-prone, and impossible to undo cleanly. That is why platform engineers write code that creates infrastructure for them.
In this project, you will use Terraform to provision a multi-node Kubernetes cluster on your local machine, install an ingress controller automatically, and serve a live web application at http://localhost.
What You'll Build
You will run terraform apply and watch a complete Kubernetes platform stand up in under 60 seconds, then open your browser and see your web app responding at localhost.
By the end of this project, you'll have:
- A multi-node cluster you can create and destroy with a single command. One terraform apply builds the world. One terraform destroy tears it all down cleanly.
- An NGINX Ingress Controller installed automatically through Terraform's Helm provider, routing traffic from your Mac into the cluster without any manual port-forwarding.
- A live web application accessible at http://localhost, proving end-to-end connectivity through declarative Infrastructure as Code.
- Secret Mission: Add a second worker node and deploy a second app with path-based routing, so two different services respond at two different URL paths behind one ingress.
Are there any prerequisites?
No prior experience with Terraform or Kubernetes is needed. This project walks you through installing every tool from scratch, including Docker Desktop, Terraform, and kubectl.
Before We Start
Before you write any Terraform code, let's lock in what you're about to build and why it matters. This is your moment to commit to the project before any hands-on work begins.
Set Up Your Terraform Project
Your tools are installed and ready to go. Now it's time to create the actual Terraform project that will hold your infrastructure code.
Every Terraform project starts with a provider declaration. Providers are plugins that connect Terraform to a specific platform. You'll declare the Kind provider, which lets Terraform create Kubernetes clusters using Docker containers.
In this step, get ready to:
- Create a project directory on your Desktop.
- Declare the Kind provider in providers.tf.
- Run terraform init to download the provider plugin.
Create your project directory
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir kind-platform
cd kind-platform
- Confirm the folder exists by running:
ls ~/Desktop | grep kind-platform
You should see kind-platform in the output.
Write the provider declaration
Terraform reads .tf files to understand what infrastructure you want. The first file every project needs is a provider declaration.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the kind-platform folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, hover over the KIND-PLATFORM heading and click the new file icon.
- Name the file providers.tf and press Enter.
- Paste the following code into providers.tf:
terraform {
required_version = ">= 1.0.0"
required_providers {
kind = {
source = "tehcyx/kind"
version = "0.11.0"
}
}
}
provider "kind" {}
What does this code do?
- The terraform block sets the minimum Terraform version (1.0.0) and declares which providers the project needs.
- The required_providers block tells Terraform to download the Kind provider from tehcyx/kind on the Terraform Registry, pinned to version 0.11.0.
- The provider "kind" {} block activates the provider with its default settings. This provider embeds the Kind library directly, so you do not need to install the kind CLI separately.
- Save providers.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you have saved providers.tf before moving on.
ⓧ I'd like to double check the full code
Your providers.tf should look exactly like this:
terraform {
required_version = ">= 1.0.0"
required_providers {
kind = {
source = "tehcyx/kind"
version = "0.11.0"
}
}
}
provider "kind" {}
File not showing in the sidebar?
- Make sure you opened the kind-platform folder itself, not its parent (Desktop). The sidebar title should read KIND-PLATFORM.
- Confirm the file extension is .tf and not .tf.txt. If Cursor added an extra extension, rename the file.
Why isn't my providers.tf file appearing in Cursor?
Initialize Terraform
With the provider declared, Terraform needs to download the plugin before it can do anything else. This is what terraform init does.
Before you run this, what do you think Terraform will do when it reads your providers.tf file?
- Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Initialize your Terraform project by running:
terraform init
You should see Terraform download the Kind provider and print a success message ending with:
Terraform has been successfully initialized!
What did terraform init do?
- It read providers.tf and found the tehcyx/kind provider requirement.
- It downloaded the provider plugin into a hidden .terraform directory inside your project folder.
- It created .terraform.lock.hcl to lock the exact provider version so future runs are consistent.
- Confirm the initialization created the expected files by running:
ls -a
You should see .terraform, .terraform.lock.hcl, and providers.tf listed in the output.
Seeing "Could not retrieve the list of available versions"?
- Check your internet connection. Terraform downloads providers from the Terraform Registry.
- Verify the source in providers.tf is exactly tehcyx/kind (not hashicorp/kind or any other value).
- Make sure the version is "0.11.0" with quotes around it.
Why is terraform init failing to download my provider?
Your Terraform project is initialized and ready to create real infrastructure. Next, you'll use it to provision a running Kubernetes cluster from a few lines of code.
Create Your First Kind Cluster
Your tools are installed and the Kind provider is initialized. Now it is time to use Terraform to create real infrastructure: a running Kubernetes cluster on your machine.
Before you can deploy applications, you need somewhere for them to run. In this step, you will write a single Terraform resource that spins up a Kind cluster, and learn the core plan/apply workflow that platform engineers use every day.
In this step, get ready to:
- Write cluster.tf defining a Kind cluster resource.
- Run terraform plan and terraform apply to create the cluster.
- Verify the cluster is running with kubectl.
Write the cluster resource
In Terraform, a resource block tells Terraform to create and manage a piece of infrastructure. You will define one resource that represents your entire Kubernetes cluster.
- Create a new file called cluster.tf in your project folder by running:
touch cluster.tf
- Open cluster.tf in Cursor and paste the following code:
# Define a Kind cluster managed by Terraform
resource "kind_cluster" "default" {
name = "platform-cluster" # Name for the cluster in Docker
kubeconfig_path = pathexpand("~/.kube/config") # Write credentials where kubectl looks
wait_for_ready = true # Block until the node is healthy
}
What does this code do?
- resource "kind_cluster" "default" declares a Kind cluster that Terraform will create and track.
- name = "platform-cluster" gives the cluster a name. You will see this in Docker containers.
- kubeconfig_path tells Kind to write cluster credentials to ~/.kube/config, the default location kubectl checks for cluster access.
- wait_for_ready = true makes Terraform wait until the cluster node reports healthy before continuing.
- Save cluster.tf.
- Confirm the file exists by running:
ls *.tf
You should see both cluster.tf and providers.tf listed.
Only seeing providers.tf?
Make sure you created the file in the same directory where providers.tf lives. Run pwd to check your current location.
Why isn't my cluster.tf file showing up?
Preview and create the cluster
Terraform's workflow has two stages. First, plan shows you exactly what Terraform intends to do. Then, apply makes it happen.
- Preview what Terraform will create by running:
terraform plan
You should see output with a + symbol next to kind_cluster.default, and a summary line: Plan: 1 to add, 0 to change, 0 to destroy.
Why plan before apply?
In production, a plan is your safety check. It shows exactly what resources will be created, modified, or destroyed before anything happens. This is one of the key benefits of Infrastructure as Code: you always know what is about to change.
- Create the cluster by running:
terraform apply
What will Terraform ask you?
Terraform will show the plan again and then prompt: Do you want to perform these actions? Type yes and press Enter to confirm. Only the word yes is accepted.
Terraform will now pull a Docker image and spin up a container that acts as a Kubernetes control-plane node. This takes roughly 30 seconds.
When it finishes, you will see Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Seeing a Docker error?
Make sure Docker Desktop is running. Check by running docker info in your terminal. If you see an error about the Docker daemon, open Docker Desktop and wait until the whale icon in your menu bar is steady (not animating).
Why is terraform apply failing with a Docker error?
Verify your cluster is running
Terraform says it created a cluster. Now you will confirm it using kubectl, the Kubernetes command-line tool that talks directly to the cluster.
Before you run the next command, what do you expect to see? Think about how many nodes you defined in your resource block.
- Check the cluster nodes by running:
kubectl get nodes
You should see one node named platform-cluster-control-plane with a Ready status. That is your entire Kubernetes cluster, created from five lines of Terraform code.
- Get more details about where the cluster is running by running:
kubectl cluster-info
This shows the Kubernetes control plane URL and the CoreDNS service address.
- Inspect what Terraform is tracking by running:
terraform show
This prints the full state of your kind_cluster.default resource, including computed values like the node image and internal endpoint.
What is Terraform state?
Terraform keeps a local file called terraform.tfstate that records every resource it manages. This is how Terraform knows what already exists when you run plan or apply again later.
Without state, Terraform would not know whether the cluster already exists or needs to be created from scratch.
kubectl get nodes shows no resources?
This means kubectl is not pointing at your new cluster. Run cat ~/.kube/config and check that it contains a cluster named kind-platform-cluster. If the file is empty or points elsewhere, re-run terraform apply.
Why isn't kubectl finding my Kind cluster?
✔️ Awesome, I've got everything!
Great. Double check you have saved cluster.tf and that kubectl get nodes shows a Ready node.
ⓧ I'd like to double check the full code
Here is the complete cluster.tf file:
resource "kind_cluster" "default" {
name = "platform-cluster"
kubeconfig_path = pathexpand("~/.kube/config")
wait_for_ready = true
}
And here is providers.tf (unchanged from the previous step):
terraform {
required_version = ">= 1.0.0"
required_providers {
kind = {
source = "tehcyx/kind"
version = "0.11.0"
}
}
}
provider "kind" {}
You now have a running Kubernetes cluster created entirely through code. Next up, you will try to access an application inside this cluster from your browser and discover why a cluster alone is not enough.
Hit the Wall — No External Access
Your cluster is up and running. kubectl confirms it, Terraform tracks it in state. But a cluster that only talks to itself is not much of a platform.
In this step, you will deploy a simple app inside the cluster and then try to reach it from your Mac. The result reveals a fundamental gap that every platform engineer must solve before their cluster can serve real traffic.
In this step, get ready to:
- Deploy a sample NGINX application inside the cluster.
- Attempt to access the app from outside the cluster and observe the failure.
Deploy a sample NGINX app inside the cluster
To test whether traffic can flow into your cluster from the outside, you first need something running inside it. You will create a simple NGINX web server pod and expose it as a ClusterIP service.
- Create an NGINX deployment and expose it as a service by running these commands:
kubectl create deployment sample-app --image=nginx:stable
kubectl expose deployment sample-app --port=80
What do these commands do?
- kubectl create deployment sample-app --image=nginx:stable tells Kubernetes to run one pod with the NGINX web server image inside your cluster.
- kubectl expose deployment sample-app --port=80 creates a ClusterIP Service that routes traffic to that pod on port 80. A ClusterIP Service is only reachable from inside the cluster.
- Confirm the pod is running by running:
kubectl get pods
You should see one pod with a name starting with sample-app- and a STATUS of Running.
Pod not showing Running?
If the STATUS shows ContainerCreating, wait 10-15 seconds for Docker to pull the NGINX image and run kubectl get pods again.
If you see ErrImagePull or ImagePullBackOff, check that Docker Desktop is running and has internet access.
help me debug why my Kubernetes pod is stuck and not reaching Running status
Try to reach the app from your Mac
Your pod is running and the service is ready inside the cluster. The real question is: can your Mac reach it?
Before you run the next command, predict what will happen. Do you think curl localhost:80 will return a web page, or will it fail? Why?
- Attempt to reach the app from your Mac by running:
curl localhost:80
You see an error:
curl: (7) Failed to connect to localhost port 80: Connection refused
Connection refused. The app is running inside the cluster, but your Mac has no path to reach it. This is the expected result.
Why does this fail?
Kind runs your cluster nodes as Docker containers. By default, no ports are mapped from those containers to your Mac's network.
The ClusterIP Service only routes traffic within the cluster's internal network. Without port mappings on the Kind node and an Ingress controller to route requests, there is no way for external traffic to reach your pod.
What about kubectl port-forward?
You could run kubectl port-forward to tunnel traffic from your Mac to the pod. It works for quick debugging, but it is a fragile manual workaround. It dies when you close the terminal and does not scale to multiple services.
A platform solution needs declarative port mappings and an ingress controller so that traffic routing is automatic and reproducible.
You have hit the wall. Your cluster runs an app, but nobody outside can reach it. Next up, you will evolve your Terraform code to fix this gap with port mappings and an ingress controller, then rebuild the entire platform in one command.
Rebuild with Declarative Ingress
In the previous step, you proved that your cluster has no path for external traffic. curl localhost:80 fails because the cluster has no port mappings and no ingress controller.
A manual patch would fix it once, but the fix would be fragile and unrepeatable. The Infrastructure as Code approach is different: you evolve the Terraform code to declare what the platform should look like, destroy the broken cluster, and rebuild the world correctly in a single command.
In this step, get ready to:
- Update Terraform configuration with port mappings, a worker node, and new provider declarations.
- Create an ingress controller configuration using the Helm provider.
- Rebuild the entire cluster from scratch with a single command.
Declare new providers and configure the cluster
Your current providers.tf only declares the Kind provider. To install an ingress controller, you need the Helm, Kubernetes, and Null providers as well.
- Replace the entire contents of providers.tf with the following code:
terraform {
required_version = ">= 1.0.0"
required_providers {
kind = {
source = "tehcyx/kind"
version = "0.11.0"
}
helm = {
source = "hashicorp/helm"
version = ">= 2.12.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.25.0"
}
null = {
source = "hashicorp/null"
version = ">= 3.2.0"
}
}
}
provider "kind" {}
What does this code do?
- helm lets Terraform install Helm charts (like the NGINX ingress controller) directly.
- kubernetes lets Terraform interact with Kubernetes resources inside your cluster.
- null provides a generic resource that can run local commands. You will use it to wait for the ingress controller to become ready.
- Save providers.tf.
- Download the new providers by running this command:
terraform init
You should see Terraform downloading the Helm, Kubernetes, and Null provider plugins. The output ends with a success message confirming all providers are installed.
Seeing a provider error?
- Make sure you saved providers.tf before running the command.
- Check that your version strings match exactly (>= 2.12.0 for Helm, >= 2.25.0 for Kubernetes, >= 3.2.0 for Null).
- help me fix a terraform init provider download error
Now you need to evolve the cluster configuration. The current cluster.tf creates a bare single-node cluster with no port mappings.
- In cluster.tf, find the line that says wait_for_ready = true.
- Add the following kind_config block directly below it (still inside the kind_cluster resource):
kind_config {
kind = "Cluster"
api_version = "kind.x-k8s.io/v1alpha4"
node {
role = "control-plane"
kubeadm_config_patches = [
"kind: InitConfiguration\nnodeRegistration:\n kubeletExtraArgs:\n node-labels: \"ingress-ready=true\"\n"
]
extra_port_mappings {
container_port = 80
host_port = 80
protocol = "TCP"
}
extra_port_mappings {
container_port = 443
host_port = 443
protocol = "TCP"
}
}
node {
role = "worker"
}
}
What does this configuration do?
- kubeadm_config_patches labels the control-plane node with ingress-ready=true so the ingress controller knows where to schedule.
- extra_port_mappings maps host ports 80 and 443 into the control-plane container. This is the bridge that lets traffic from your Mac reach the cluster.
- The second node block with role = "worker" adds a dedicated worker node, giving your cluster a production-like multi-node topology.
- After the closing } of the kind_cluster resource, add these provider configurations at the bottom of cluster.tf:
provider "kubernetes" {
config_path = kind_cluster.default.kubeconfig_path
}
provider "helm" {
kubernetes {
config_path = kind_cluster.default.kubeconfig_path
}
}
Why are the providers configured here?
Both providers need the cluster's kubeconfig to authenticate. By referencing kind_cluster.default.kubeconfig_path, Terraform automatically connects them to whichever cluster the Kind provider creates.
- Save cluster.tf.
- Preview the changes Terraform will make by running:
terraform plan
You should see Terraform planning to destroy the existing kind_cluster.default and create a new one. This is because the kind_config change forces the cluster to be recreated.
Plan shows errors instead of changes?
- Make sure both code blocks are in the correct positions: the kind_config block inside the resource, and the provider blocks outside it.
- Confirm cluster.tf has matching opening and closing braces for every block.
- help me fix my terraform plan error for cluster.tf
Create the ingress controller configuration
With the cluster configured for ingress-ready networking, you need an Ingress controller to actually route traffic. The NGINX Ingress Controller is the most widely used option, and you will install it via a Helm chart managed entirely by Terraform.
- Create a new file called ingress.tf in your project directory.
- Add the first part of the Helm release resource:
resource "helm_release" "ingress_nginx" {
name = "ingress-nginx"
repository = "https://kubernetes.github.io/ingress-nginx"
chart = "ingress-nginx"
namespace = "ingress-nginx"
create_namespace = true
set {
name = "controller.hostPort.enabled"
value = "true"
}
set {
name = "controller.service.type"
value = "NodePort"
}
set {
name = "controller.nodeSelector.ingress-ready"
value = "true"
}
What does this code do?
- helm_release tells Terraform to install a Helm chart as a managed resource.
- hostPort.enabled = true lets the controller bind directly to the node's ports (80 and 443 that you mapped earlier).
- service.type = NodePort avoids creating a cloud LoadBalancer (which does not exist locally).
- nodeSelector.ingress-ready = true ensures the controller schedules only on the control-plane node where your port mappings live.
- Directly below the code you just added (still inside the same resource block), add the remaining configuration:
set {
name = "controller.tolerations[0].key"
value = "node-role.kubernetes.io/control-plane"
}
set {
name = "controller.tolerations[0].operator"
value = "Equal"
}
set {
name = "controller.tolerations[0].effect"
value = "NoSchedule"
}
set {
name = "controller.publishService.enabled"
value = "false"
}
set {
name = "controller.extraArgs.publish-status-address"
value = "localhost"
}
depends_on = [kind_cluster.default]
}
What do these settings do?
- The tolerations allow the ingress controller to run on the control-plane node (which normally rejects workloads).
- publishService.enabled = false and publish-status-address = localhost tell the controller to advertise localhost as its address instead of looking for a cloud load balancer IP.
- depends_on ensures Terraform creates the cluster before attempting to install the chart.
- Save ingress.tf and confirm your configuration is syntactically valid by running:
terraform validate
You should see the message: Success! The configuration is valid.
Validation failed?
- Check that the helm_release resource has a single closing } at the end. The first code chunk opens the resource and the second chunk closes it.
- Confirm all set blocks have matching braces and correct quoting.
- help me fix my ingress.tf validation error
The last piece is a resource that waits for the ingress controller pod to become ready before Terraform reports success.
- Add the following resource at the bottom of ingress.tf, below the helm_release closing brace:
resource "null_resource" "wait_for_ingress" {
provisioner "local-exec" {
command = <<-EOT
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
EOT
}
depends_on = [helm_release.ingress_nginx]
}
What does this resource do?
The null_resource runs kubectl wait as a local shell command after the Helm chart is installed. It blocks until the ingress controller pod reports Ready, ensuring your infrastructure is fully operational before Terraform finishes.
- Save ingress.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved both cluster.tf and ingress.tf.
ⓧ I'd like to double check the full code
Your ingress.tf should contain these sections in order:
- The helm_release "ingress_nginx" resource opening with name, repository, chart, namespace, and create_namespace fields.
- Eight set blocks configuring hostPort, service type, nodeSelector, tolerations, publishService, and publish-status-address.
- A depends_on referencing kind_cluster.default, followed by the closing brace.
- The null_resource "wait_for_ingress" with a local-exec provisioner running kubectl wait and depends_on referencing helm_release.ingress_nginx.
Your cluster.tf should contain:
- The kind_cluster "default" resource with kind_config containing a control-plane node (with kubeadm_config_patches and two extra_port_mappings blocks) and a worker node.
- A provider "kubernetes" block and a provider "helm" block, both using kind_cluster.default.kubeconfig_path.
Destroy and rebuild the platform
This is the moment that makes IaC worth it. Instead of debugging the broken cluster, you tear it down and let Terraform rebuild everything correctly from your updated code.
- Destroy the existing cluster by running:
terraform destroy
- When prompted, type yes and press Enter to confirm.
Terraform removes the old single-node cluster. Your slate is clean.
- Rebuild the entire platform with your new configuration by running:
terraform apply
- When prompted, type yes and press Enter to confirm.
What is Terraform doing?
Terraform creates resources in dependency order: the Kind cluster first (with port mappings and the worker node), then the Helm release (the NGINX ingress controller), and finally the null_resource (which waits for the controller pod to become ready).
This takes about 60-90 seconds. The null_resource blocks until the ingress pod passes its readiness check, so when terraform apply finishes, everything is truly ready.
Before you verify, what do you expect to see when you check the ingress-nginx namespace for running pods?
- Confirm the ingress controller is running by executing:
kubectl get pods -n ingress-nginx
You should see the ingress controller pod with a status of Running and 1/1 in the READY column.
Pod not showing Running status?
- If the pod shows ContainerCreating, wait 30 seconds and run the command again. The image may still be pulling.
- If no pods appear at all, confirm terraform apply completed without errors. Re-run it if it timed out.
- Make sure Docker Desktop is running and has sufficient resources allocated (at least 4 GB memory).
- help me troubleshoot my ingress-nginx pod not starting
Your platform now has a multi-node cluster with ingress routing, all declared in code. Next up, you will deploy an application behind this ingress and prove traffic flows all the way from localhost into a running pod.
Deploy Your App Behind Ingress
Your platform infrastructure is fully operational. The cluster is running, the ingress controller is listening, and port mappings are in place.
Now it is time to prove the entire stack works end-to-end. You will deploy a web application and access it at http://localhost with no port-forwarding hacks. Then you will tear it all down with a single command to demonstrate the full power of disposable infrastructure.
In this step, get ready to:
- Deploy a sample NGINX app behind the ingress controller.
- Verify traffic flows from your Mac through the ingress into the cluster pod.
- Tear down the entire platform with one command.
Create your application manifest
A Kubernetes manifest file declares the desired state of your application. You will define three resources in one file: a Deployment (what to run), a Service (how pods find each other), and an Ingress (how external traffic reaches the app).
- Create a new file called app.yaml in your project directory.
- Add the Deployment section, which tells Kubernetes to run one NGINX container:
apiVersion: apps/v1
kind: Deployment
metadata:
name: sample-app
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: sample-app
template:
metadata:
labels:
app: sample-app
spec:
containers:
- name: nginx
image: nginx:stable
ports:
- containerPort: 80
What does this Deployment do?
The Deployment creates one pod running the nginx:stable container image. The selector and labels fields connect the Deployment to its pods so Kubernetes knows which pods belong to this Deployment.
The containerPort: 80 declaration tells Kubernetes the container listens on port 80. This does not expose the port externally. The Service and Ingress handle that.
- Below the Deployment section in app.yaml, add the Service and Ingress resources:
---
apiVersion: v1
kind: Service
metadata:
name: sample-app
namespace: default
spec:
selector:
app: sample-app
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sample-app
namespace: default
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sample-app
port:
number: 80
What do the Service and Ingress do?
The Service creates a stable internal address for your pods. It matches pods with the label app: sample-app and forwards traffic from port 80 to their container port 80.
The Ingress resource tells the NGINX Ingress Controller to route external requests hitting / to the sample-app Service. The ingressClassName: nginx field connects this rule to the controller you installed in the previous step.
- Save app.yaml.
- Apply the manifest to your cluster by running:
kubectl apply -f app.yaml
You should see three lines confirming each resource was created:
deployment.apps/sample-app created
service/sample-app created
ingress.networking.k8s.io/sample-app created
- Confirm the pod is running by running:
kubectl get pods
You should see one pod with a name starting with sample-app- in Running status. If it shows ContainerCreating, wait a few seconds and run the command again.
Pod stuck in ContainerCreating or ImagePullBackOff?
- Make sure Docker Desktop is running. The cluster nodes need Docker to pull container images.
- Check your internet connection. The nginx:stable image needs to be downloaded from Docker Hub.
- Run kubectl describe pod sample-app to see detailed error messages about why the pod cannot start.
Help me debug this issue
✔️ Awesome, I've got everything!
Great. Double check you have saved app.yaml and your pod shows Running status.
ⓧ I'd like to double check the full code
Your app.yaml file should contain three sections in this order:
- The Deployment section (starting with apiVersion: apps/v1 and kind: Deployment).
- The Service section (starting with --- then apiVersion: v1 and kind: Service).
- The Ingress section (starting with --- then apiVersion: networking.k8s.io/v1 and kind: Ingress).
Scroll up and confirm each chunk matches what you pasted. All three resources should use name: sample-app and namespace: default.
Verify end-to-end traffic flow
Your application pod is running, the Service routes to it, and the Ingress connects it to the outside world. The moment of truth: can you reach your app from your Mac without any port-forwarding workarounds?
Before you run the next command, predict: will curl localhost succeed this time, or fail like it did in Step 3?
- Test the full traffic path by running:
curl localhost
You should see the NGINX welcome page HTML returned in your terminal. The output starts with <!DOCTYPE html> and includes "Welcome to nginx!" in the body.
What just happened?
Traffic flowed through four layers: your Mac sent the request to localhost:80. Docker Desktop forwarded it into the Kind container via the port mapping you configured in cluster.tf. The NGINX Ingress Controller matched the request to your Ingress rule and forwarded it to the sample-app Service, which delivered it to the pod.
Compare this to Step 3, where curl localhost:80 returned "Connection refused". The difference is your Terraform code. You declared port mappings, an ingress controller, and an ingress rule. The platform made it happen.
Still seeing Connection refused?
- Confirm the ingress controller is still running: kubectl get pods -n ingress-nginx. The controller pod should show Running status.
- Check that your Ingress resource was accepted: kubectl get ingress. You should see sample-app listed with localhost as the address.
- Make sure nothing else on your Mac is using port 80. Run lsof -i :80 to check.
Help me debug my ingress routing
Tear down the entire platform
The real power of Infrastructure as Code is not just creating things. It is destroying them cleanly and rebuilding from scratch whenever you want.
- Destroy all Terraform-managed resources by running:
terraform destroy
What will Terraform prompt you?
Terraform shows a plan listing every resource it will delete (the cluster, the Helm release, and the null resource). It then asks you to confirm by typing yes. Type yes and press Enter to proceed.
Terraform removes the Kind cluster, which automatically destroys everything inside it: your Deployment, Service, Ingress, and the ingress controller.
Before you check, predict: will docker ps still show Kind containers, or will they be gone?
- Confirm the cluster containers are gone by running:
docker ps
You should see no containers with "kind" in their names. The entire platform is gone. Your Terraform code is all you need to rebuild it from scratch.
Still seeing kind containers in docker ps?
- If terraform destroy reported an error, re-run it. Transient Docker issues can interrupt the teardown.
- If containers persist, remove them manually with docker rm -f $(docker ps -aq --filter name=platform-cluster).
Help me clean up leftover containers
Secret mission
Multi-App Path-Based Routing
Real platforms host multiple services behind a single ingress. Can you add a second worker node, deploy a second application, and configure path-based routing so different URL paths hit different services?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Kind cluster running as Docker containers (provisioned by Terraform).
- Terraform project files on your Desktop (providers.tf, cluster.tf, ingress.tf, app.yaml, multi-app.yaml).
- Terraform state and plugin files (.terraform/ directory and terraform.tfstate).
- Docker Desktop (installed in Step 1).
- Terraform CLI (installed via Homebrew in Step 1).
- kubectl CLI (installed via Homebrew in Step 1).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with follow-up projects that use this cluster.
- Leave Docker Desktop running. The Kind cluster persists as Docker containers until you stop Docker or run terraform destroy.
- Your Terraform files remain on your Desktop, ready to modify or rebuild.
- Terraform, kubectl, and Docker Desktop remain installed on your machine for future projects.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay on disk so you can pick up where you left off.
- Click the Docker Desktop icon in your menu bar, then click Quit Docker Desktop. This stops all Kind cluster containers.
- When you are ready to resume, relaunch Docker Desktop. Your cluster containers restart automatically.
- Your Terraform files and state remain intact on your Desktop.
Delete - I don't want to use this again
Remove all project resources and reclaim disk space. This permanently deletes your cluster and project files.
- Destroy the Kind cluster by running this command in your project folder (the one containing providers.tf):
- This removes all Docker containers that make up the cluster and cleans up Terraform state.
terraform destroy
- When prompted, type yes and press Enter to confirm.
- Confirm no Kind containers remain by running:
docker ps
- You should see no containers with kindest/node in the IMAGE column.
- Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf platform-cluster
What if my folder has a different name?
Replace platform-cluster with whatever you named your project folder in Step 2. You can run ls ~/Desktop to find it.
- Uninstall Terraform by running:
brew uninstall hashicorp/tap/terraform
- Uninstall kubectl by running:
brew uninstall kubectl
- Uninstall Docker Desktop by opening Docker Desktop, clicking the Troubleshoot icon (bug icon) in the top-right corner, then clicking Uninstall. Confirm the uninstall when prompted.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully declarative local Kubernetes platform from scratch using Terraform.
You've learned how to:
- Built a Terraform project from scratch that provisions a multi-node Kind cluster locally, using the full IaC workflow: init, plan, apply, destroy.
- Experienced the IaC feedback loop firsthand: hit a limitation (no external access), evolved the code to include port mappings and ingress, then rebuilt the entire infrastructure in one command.
- Deployed an NGINX Ingress Controller via the Terraform Helm provider and proved end-to-end traffic routing from localhost into a cluster application.
- Secret Mission: Configured multi-app path-based routing on a 3-node cluster, serving two different applications from a single ingress.
Ready to quiz yourself?