Zero Trust Networking in Kubernetes

Implement zero trust networking on a local Kubernetes cluster using Terraform.

Introduction

30 Second Summary

Imagine locking every door in a building and only handing out keys to specific people for specific rooms. That is the idea behind zero trust networking. Instead of trusting everything inside your network, you verify every single connection.

In this project, you will build a local Kubernetes cluster running a 3-tier application, then lock down all communication between services using Zero Trust network policies managed entirely through Terraform.

What You'll Build

A local Kubernetes environment running a 3-tier application (frontend, API, database) across separate namespaces, secured with default-deny NetworkPolicies and explicit allow-list rules, all managed as Infrastructure as Code.

By the end of this project, you'll have:

  • A running local Kubernetes cluster with a 3-tier microservice application deployed entirely through Terraform.
  • A working zero trust network security posture where all traffic is denied by default and only explicitly allowed connections succeed.
  • A complete set of Terraform-managed network policies that can be version-controlled, audited, and reapplied at will.
  • Secret Mission: Add RBAC restrictions so a developer service account can only access resources in the frontend namespace.

Are there any prerequisites?

You should have basic Kubernetes knowledge (pods, namespaces, deployments, services). You also need Docker Desktop, Terraform, and kubectl already installed on your machine.

Install Kind and Create Your Cluster

To implement zero trust networking, you need a Kubernetes cluster to work with. You don't need a cloud cluster for this project.

Kind (Kubernetes in Docker) runs a full cluster locally inside Docker containers. In this step, you'll install Kind, create a single-node cluster, and confirm it supports network policies out of the box.

In this step, get ready to:
  • Install Kind on your machine.
  • Create a local Kubernetes cluster.
  • Confirm network policy support is active.
Install Kind

Kind is a tool that creates Kubernetes clusters using Docker containers as nodes. Since you already have Docker Desktop running, Kind can spin up a full cluster in seconds.

  • Install Kind using winget by running this command in your terminal:
winget install Kubernetes.kind

What does this command do?

winget is the Windows package manager. It downloads and installs the Kind binary, then adds it to your system PATH so you can run kind from any terminal window.

  • The installer may prompt you to accept license terms. Enter Y to accept.
  • Close and reopen your terminal after installation so the new PATH entry takes effect.
  • Verify Kind is installed by running:
kind version

✔️ I see a version number

Kind is installed and ready to go. You should see output like kind v0.31.0 or similar.

ⓧ Command not found

If winget didn't work or the command isn't recognized, you can install Kind manually by downloading the binary.

  • Download the Kind binary by running this command in PowerShell:
curl.exe -Lo kind-windows-amd64.exe https://kind.sigs.k8s.io/dl/v0.31.0/kind-windows-amd64
Move-Item .\kind-windows-amd64.exe c:\windows\kind.exe
  • Run kind version again to confirm it works.

Still not working?

Make sure you're running PowerShell as Administrator for the Move-Item command. The destination folder must be in your system PATH.

help me install Kind on Windows

Create a Kind cluster

With Kind installed, you can create a cluster with a single command. This cluster runs entirely inside Docker containers on your machine.

  • Make sure Docker Desktop is running. You should see the Docker icon in your system tray.
  • Create your cluster by running this command:
kind create cluster --name zero-trust

What does this command do?

kind create cluster pulls a pre-built Kubernetes node image and runs it as a Docker container. The --name zero-trust flag gives the cluster a meaningful name so you can identify it later.

Kind automatically sets your kubectl context to kind-zero-trust, so all kubectl commands will target this cluster.

  • Wait for the command to finish. You should see a message saying the cluster is ready.
  • Verify the cluster is healthy by running:
kubectl cluster-info
  • You should see output showing the Kubernetes control plane is running at a local address.
  • Check that your node is ready by running:
kubectl get nodes
  • You should see one node named zero-trust-control-plane with a Ready status.

Cluster creation failed?

Make sure Docker Desktop is running and has finished starting up. Kind needs Docker's daemon to create containers.

If you see a timeout error, try increasing Docker Desktop's memory allocation in Settings > Resources. Kind needs at least 2GB of RAM.

help me troubleshoot my Kind cluster creation failure

Confirm network policy support

For zero trust networking to work, your cluster needs a network plugin that enforces NetworkPolicies. Kind ships with kindnet, a CNI plugin that supports network policies out of the box.

  • Check that the kindnet pods are running by running this command:
kubectl get pods -n kube-system -l app=kindnet

What is kindnet?

kindnet is the default Container Network Interface (CNI) plugin for Kind clusters. It handles pod-to-pod networking and enforces NetworkPolicy rules.

Without a CNI that supports NetworkPolicies, your deny-all and allow-list rules would have no effect. kindnet provides this enforcement automatically.

  • You should see one or more kindnet pods with a Running status. This confirms your cluster can enforce network policies.

No kindnet pods found?

If you see no pods returned, make sure you typed the label selector correctly: -l app=kindnet. The label is case-sensitive.

If pods show a status other than Running, wait 30 seconds and run the command again. The pods may still be starting up.

help me check if my Kind cluster supports network policies

Your cluster is up and running with network policy support. Next up, you'll deploy a 3-tier application using Terraform to give yourself something to secure.

Deploy the 3-Tier Application with Terraform

Your Kind cluster is running and ready to host workloads. Now you need something to secure.

In this step, you'll use Terraform to deploy a 3-tier microservice application across three separate Kubernetes namespaces. Managing your infrastructure as code means you can tear down and rebuild the entire stack in seconds.

In this step, get ready to:
  • Configure the Terraform Kubernetes provider to connect to your Kind cluster.
  • Define three namespaces and deploy an nginx container in each with a ClusterIP service.
  • Verify all pods are running and confirm that pods can freely communicate across namespaces.
Create the Terraform provider configuration

Terraform needs to know which cloud or platform it's managing. The Kubernetes provider tells Terraform how to connect to your cluster using your local kubeconfig file.

  • Create a new file called providers.tf in your project folder. Paste the following code into it:
terraform {
  required_version = ">= 1.5.7"

  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 3.0"
    }
  }
}

provider "kubernetes" {
  config_path    = "~/.kube/config"
  config_context = "kind-zero-trust"
}

What does this code do?

The terraform block pins the minimum Terraform version and declares that you need the hashicorp/kubernetes provider at version ~> 3.0.

The provider block points Terraform at your local kubeconfig and specifically targets the kind-zero-trust context. This ensures Terraform only touches your Kind cluster.

  • Save providers.tf.

Why Infrastructure as Code?

Managing Kubernetes resources with Terraform instead of raw YAML gives you state management and drift detection. If someone manually deletes a resource, terraform apply restores it automatically.

This is especially valuable for security policies. Your entire security posture becomes code-reviewed, version-controlled, and reproducible.

✔️ Awesome, I've got everything!

Great. Make sure you've saved providers.tf before moving on.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.5.7"

  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 3.0"
    }
  }
}

provider "kubernetes" {
  config_path    = "~/.kube/config"
  config_context = "kind-zero-trust"
}
Define namespaces and deploy the application

Your 3-tier application needs three separate namespaces: frontend, api, and database. Each namespace gets its own deployment and service, simulating a production-like architecture.

  • Create a new file called namespaces.tf in your project folder. Paste the following code into it:
resource "kubernetes_namespace_v1" "frontend" {
  metadata {
    name = "frontend"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "frontend"
    }
  }
}

resource "kubernetes_namespace_v1" "api" {
  metadata {
    name = "api"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "api"
    }
  }
}

resource "kubernetes_namespace_v1" "database" {
  metadata {
    name = "database"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "database"
    }
  }
}

What does this code do?

Each kubernetes_namespace_v1 resource creates an isolated namespace. The tier label on each namespace is critical. Later, your network policies will use these labels to control which namespaces can talk to each other.

  • Save namespaces.tf.

✔️ Awesome, I've got everything!

Great. Make sure you've saved namespaces.tf before moving on.

ⓧ I'd like to double check the full code

resource "kubernetes_namespace_v1" "frontend" {
  metadata {
    name = "frontend"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "frontend"
    }
  }
}

resource "kubernetes_namespace_v1" "api" {
  metadata {
    name = "api"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "api"
    }
  }
}

resource "kubernetes_namespace_v1" "database" {
  metadata {
    name = "database"
    labels = {
      "app.kubernetes.io/part-of" = "zero-trust-demo"
      "tier"                      = "database"
    }
  }
}

Now you'll deploy the actual application. Each tier gets a deployment running an nginx:1.27-alpine container and a ClusterIP service that exposes it to other pods inside the cluster.

  • Create a new file called app.tf in your project folder. Paste the following code into it:
# Frontend Deployment and Service
resource "kubernetes_deployment_v1" "frontend" {
  metadata {
    name      = "frontend"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
    labels = {
      app  = "frontend"
      tier = "frontend"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "frontend"
      }
    }
    template {
      metadata {
        labels = {
          app  = "frontend"
          tier = "frontend"
        }
      }
      spec {
        container {
          name  = "frontend"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "frontend" {
  metadata {
    name      = "frontend-svc"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }
  spec {
    selector = {
      app = "frontend"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

# API Deployment and Service
resource "kubernetes_deployment_v1" "api" {
  metadata {
    name      = "api"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
    labels = {
      app  = "api"
      tier = "api"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "api"
      }
    }
    template {
      metadata {
        labels = {
          app  = "api"
          tier = "api"
        }
      }
      spec {
        container {
          name  = "api"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "api" {
  metadata {
    name      = "api-svc"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }
  spec {
    selector = {
      app = "api"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

# Database Deployment and Service
resource "kubernetes_deployment_v1" "database" {
  metadata {
    name      = "database"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
    labels = {
      app  = "database"
      tier = "database"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "database"
      }
    }
    template {
      metadata {
        labels = {
          app  = "database"
          tier = "database"
        }
      }
      spec {
        container {
          name  = "database"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "database" {
  metadata {
    name      = "database-svc"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }
  spec {
    selector = {
      app = "database"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

What does this code do?

The file defines three pairs of resources, one for each tier. Each pair contains a kubernetes_deployment_v1 (runs a pod with the nginx container) and a kubernetes_service_v1 (exposes that pod on port 80 within the cluster).

The namespace field references the namespace resources from namespaces.tf. Terraform handles the dependency ordering, so namespaces are created before the deployments that live inside them.

  • Save app.tf.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.tf before moving on.

ⓧ I'd like to double check the full code

# Frontend Deployment and Service
resource "kubernetes_deployment_v1" "frontend" {
  metadata {
    name      = "frontend"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
    labels = {
      app  = "frontend"
      tier = "frontend"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "frontend"
      }
    }
    template {
      metadata {
        labels = {
          app  = "frontend"
          tier = "frontend"
        }
      }
      spec {
        container {
          name  = "frontend"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "frontend" {
  metadata {
    name      = "frontend-svc"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }
  spec {
    selector = {
      app = "frontend"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

# API Deployment and Service
resource "kubernetes_deployment_v1" "api" {
  metadata {
    name      = "api"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
    labels = {
      app  = "api"
      tier = "api"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "api"
      }
    }
    template {
      metadata {
        labels = {
          app  = "api"
          tier = "api"
        }
      }
      spec {
        container {
          name  = "api"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "api" {
  metadata {
    name      = "api-svc"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }
  spec {
    selector = {
      app = "api"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

# Database Deployment and Service
resource "kubernetes_deployment_v1" "database" {
  metadata {
    name      = "database"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
    labels = {
      app  = "database"
      tier = "database"
    }
  }

  spec {
    replicas = 1
    selector {
      match_labels = {
        app = "database"
      }
    }
    template {
      metadata {
        labels = {
          app  = "database"
          tier = "database"
        }
      }
      spec {
        container {
          name  = "database"
          image = "nginx:1.27-alpine"
          port {
            container_port = 80
          }
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "database" {
  metadata {
    name      = "database-svc"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }
  spec {
    selector = {
      app = "database"
    }
    port {
      port        = 80
      target_port = 80
    }
  }
}

With all three files saved, you're ready to initialize Terraform and deploy your application.

  • Initialize Terraform by running this command:
terraform init
  • You should see a message saying Terraform has been successfully initialized!

Seeing a provider error?

Make sure your providers.tf file uses the exact source hashicorp/kubernetes with a lowercase k. Check that the file is saved in the same directory where you ran the command.

help me debug a terraform init error with the kubernetes provider

  • Deploy all resources by running this command:
terraform apply
  • Terraform will show you a plan of what it intends to create. Type yes when prompted to confirm.
  • You should see Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Apply failed with a connection error?

Make sure Docker Desktop is running. Your Kind cluster lives inside Docker, so if Docker is stopped, Terraform cannot reach the Kubernetes API.

Also verify that config_context in providers.tf is set to kind-zero-trust (not just zero-trust). Kind prefixes all context names with kind-.

help me fix a terraform apply connection error to my Kind cluster

Verify the deployment and test connectivity

Your 3-tier application should now be running across three namespaces. Before adding any network restrictions, you'll confirm that every pod can freely communicate with every other pod.

  • Confirm all pods are running by running this command:
kubectl get pods -A
  • You should see pods in the frontend, api, and database namespaces, all with status Running.

Pods stuck in ContainerCreating?

Kind needs to pull the nginx:1.27-alpine image the first time. Wait 30-60 seconds and run kubectl get pods -A again. If pods remain stuck, check your internet connection.

help me troubleshoot pods stuck in ContainerCreating in my Kind cluster

Now test that the frontend pod can reach both the API service and the database service. This proves there are no network restrictions in place yet.

  • Test that the frontend can reach the API service by running this command:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api.svc.cluster.local
  • You should see the default nginx HTML page returned. This confirms the frontend pod can reach the API service.
  • Test that the frontend can also reach the database service by running this command:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://database-svc.database.svc.cluster.local
  • This also succeeds. Right now, any pod can talk to any other pod with no restrictions.

Why is this a problem?

In a production cluster, the frontend should never talk directly to the database. If an attacker compromises the frontend pod, they could bypass the API entirely and access your data directly.

This is the "default allow" problem that Zero Trust architecture solves. In the next steps, you'll lock everything down and only permit the exact traffic flows your application needs.

Your 3-tier application is deployed and every pod can freely reach every other pod. Next up, you'll observe this security problem in more detail before locking everything down with default-deny policies.

Observe the Problem — Everything Can Talk to Everything

Your 3-tier application is deployed and running across three namespaces. But right now, there is a serious security gap hiding in plain sight.

By default, Kubernetes allows any pod to communicate with any other pod in the cluster. There are no network restrictions at all. In this step, you will prove this by testing connections that should never be allowed in a production system.

In this step, get ready to:
  • Prove the frontend pod can reach both the API and database services directly.
  • Prove the database pod can reach the frontend service (a connection that should never exist in production).
Test connectivity from the frontend pod

In a properly designed architecture, the frontend should only talk to the API layer. It should never reach the database directly. Let's see what actually happens when we try both connections.

  • Test whether the frontend pod can reach the API service by running this command:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api.svc.cluster.local

You should see a block of HTML output containing the default nginx welcome page. This proves the frontend pod successfully reached the API service.

  • Now test whether the frontend pod can reach the database service directly by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://database-svc.database.svc.cluster.local

This also succeeds and returns the same HTML output. The frontend pod can reach the database service directly, bypassing the API layer entirely.

Why is direct frontend-to-database access dangerous?

If an attacker compromises the frontend pod, they have direct access to the database. The API layer exists to validate and sanitize requests before they reach your data.

Bypassing the API means the database is exposed to unfiltered input from any pod in the cluster. In a real system, this could lead to data exfiltration or corruption.

Seeing a connection timeout or pod not found?

Make sure your pods are all running by checking kubectl get pods -A. All three pods should show Running status with 1/1 ready.

If a pod is stuck in ContainerCreating, wait 30 seconds and try again. The nginx image may still be pulling.

Help me troubleshoot kubectl exec connectivity issues

Test connectivity from the database pod

In a properly secured system, the database should be the most protected tier. It should only receive connections from the API. It should never initiate outbound traffic to other services.

  • Test whether the database pod can reach the frontend service by running:
kubectl exec -n database deploy/database -- wget --timeout=3 -qO- http://frontend-svc.frontend.svc.cluster.local

This succeeds too. The database pod can talk to the frontend, which makes no sense architecturally. A compromised database pod could reach any service in the entire cluster.

What is the zero trust security model?

Zero trust means no pod gets network access just because it is inside the cluster. Every connection must be explicitly allowed by a policy.

The default Kubernetes behavior you just observed is the opposite of zero trust. Everything is allowed unless you add restrictions. This is what NetworkPolicies fix.

The problem is clear. Every pod can reach every other pod regardless of namespace boundaries. In the next step, you will implement the zero trust foundation by blocking all traffic by default with default-deny network policies.

Lock It Down — Apply Default-Deny Policies

In the previous step, you proved that your cluster's networking is wide open. Any pod can reach any other pod across any namespace. A single compromised container could reach your database directly.

Now you'll flip the switch. You'll add NetworkPolicy resources that block ALL traffic by default. This is the Zero Trust foundation: deny everything, then explicitly allow only what's needed.

In this step, get ready to:
  • Write default-deny NetworkPolicy resources in Terraform for all three namespaces.
  • Apply the policies and confirm all pod-to-pod traffic is blocked.
  • Observe that DNS resolution is also broken by the deny-all policies.
Add default-deny NetworkPolicy resources to your Terraform configuration

A Kubernetes NetworkPolicy with an empty pod_selector matches every pod in the namespace. When you set both Ingress and Egress as policy types but provide no rules, the policy blocks all inbound and outbound traffic.

  • Create a new file called network-policies.tf in your zero-trust-terraform folder (the same folder where providers.tf lives).
  • Add the following default-deny policies for all three namespaces:
# =============================================================================
# DEFAULT DENY POLICIES - Block all traffic by default in each namespace
# =============================================================================

resource "kubernetes_network_policy_v1" "deny_all_frontend" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_api" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_database" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

What does this code do?

Each resource creates a NetworkPolicy named deny-all in one of your three namespaces.

  • pod_selector {} with no match labels means the policy applies to every pod in that namespace.
  • policy_types = ["Ingress", "Egress"] means both incoming and outgoing traffic are governed.
  • Because there are no ingress or egress rule blocks, no traffic is permitted. Everything is denied.

Why manage network policies with Terraform?

You could apply these policies with raw YAML and kubectl. Terraform adds state management and drift detection on top. If someone manually deletes a policy, terraform apply restores it.

Your security posture becomes code that can be reviewed, version-controlled, and reproduced in seconds.

  • Save network-policies.tf.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

# =============================================================================
# DEFAULT DENY POLICIES - Block all traffic by default in each namespace
# =============================================================================

resource "kubernetes_network_policy_v1" "deny_all_frontend" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_api" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_database" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}
Apply the policies and verify traffic is blocked

With the deny-all policies written, you need to apply them to the cluster. Once active, every pod will be completely isolated.

  • Apply the new policies by running:
terraform apply -auto-approve

What does this command do?

terraform apply reads your configuration, compares it to the current cluster state, and creates the three new NetworkPolicy resources. The -auto-approve flag skips the confirmation prompt so it applies immediately.

  • You should see Terraform report that 3 resources were added (the three deny-all policies).
  • Now test that the policies are working. Try to reach the API service from the frontend pod by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api.svc.cluster.local

This command will hang for 3 seconds and then fail. The frontend pod can no longer reach the API. Your zero trust foundation is active.

Still seeing a successful connection?

NetworkPolicies can take a few seconds to propagate. Wait 5-10 seconds after terraform apply completes, then retry the wget command.

If traffic still flows, confirm the policies exist by running kubectl get networkpolicies -A. You should see a deny-all policy in each of the three namespaces.

help me debug why my default-deny network policies aren't blocking traffic in Kind

Observe that DNS resolution is also broken

The deny-all policies block ALL egress traffic. That includes DNS queries, which pods send to CoreDNS in the kube-system namespace.

  • Try resolving a service name from inside the frontend pod by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api

This also fails. The pod cannot even look up the hostname because DNS egress is blocked.

Why is DNS breaking important?

In a zero trust environment, you must explicitly allow even basic infrastructure services like DNS. Nothing is trusted by default.

CoreDNS runs in the kube-system namespace. Your pods need egress to port 53 (UDP and TCP) in that namespace to resolve any service name. In the next step, you'll add explicit DNS allow policies to fix this.

Your cluster is now locked down completely. Every pod is isolated. In the next step, you'll selectively open the exact traffic paths your application needs.

Build Explicit Trust — Allow Only What's Needed

In the previous step, you locked down every namespace with default-deny policies. Right now, nothing can talk to anything. Even DNS is broken.

This is the foundation of zero trust. Now you'll add the explicit trust rules that permit only the traffic flows your application actually needs. First DNS, then the precise tier-to-tier connections.

In this step, get ready to:
  • Restore DNS resolution by allowing egress to CoreDNS.
  • Add ingress and egress policies permitting only frontend-to-API and API-to-database traffic.
  • Verify that unauthorized paths (frontend-to-database, database-to-frontend) remain blocked.
Restore DNS resolution with egress policies

Your default-deny policies block all egress traffic, including DNS lookups. Pods resolve service names like api-svc.api.svc.cluster.local by contacting CoreDNS in the kube-system namespace on port 53. Without explicit permission, that query never leaves the pod.

  • Open network-policies.tf in your editor.
  • Below the default-deny policies section, add the following DNS egress policies for all three namespaces:
# =============================================================================
# DNS EGRESS POLICIES - Allow all pods to resolve DNS names
# =============================================================================

resource "kubernetes_network_policy_v1" "allow_dns_frontend" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy_v1" "allow_dns_api" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy_v1" "allow_dns_database" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

What does this code do?

Each policy allows all pods in its namespace (empty pod_selector) to send traffic to port 53 on the CoreDNS pods in the kube-system namespace.

The kubernetes.io/metadata.name label is automatically applied to every namespace by Kubernetes. The k8s-app = "kube-dns" label targets only the CoreDNS pods. Both UDP and TCP on port 53 are allowed because DNS uses both protocols.

  • Save network-policies.tf.
  • Apply the new policies by running:
terraform apply -auto-approve
  • Verify DNS resolution works again by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api.svc.cluster.local

This command will still time out because you haven't allowed the actual HTTP traffic yet. The important thing is the error message. If DNS is working, you'll see a connection timeout instead of a DNS resolution failure.

Still seeing a DNS resolution error?

Make sure you saved network-policies.tf before running terraform apply. Check that the namespace_selector targets kube-system and the pod_selector targets k8s-app = "kube-dns".

Help me debug why DNS resolution is still failing after applying my allow-dns network policies.

Allow ingress traffic between tiers

DNS works, but HTTP connections still time out. Each tier needs an ingress policy that explicitly accepts traffic from the tier above it. The API accepts traffic from the frontend, and the database accepts traffic from the API.

  • In network-policies.tf, add the following allow policies section below the DNS egress policies:
# =============================================================================
# ALLOW POLICIES - Explicit trust between tiers
# =============================================================================

# API accepts traffic from frontend namespace on port 80
resource "kubernetes_network_policy_v1" "api_ingress_from_frontend" {
  metadata {
    name      = "allow-ingress-from-frontend"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "api"
      }
    }
    policy_types = ["Ingress"]

    ingress {
      from {
        namespace_selector {
          match_labels = {
            tier = "frontend"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

# Database accepts traffic from API namespace on port 80
resource "kubernetes_network_policy_v1" "database_ingress_from_api" {
  metadata {
    name      = "allow-ingress-from-api"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "database"
      }
    }
    policy_types = ["Ingress"]

    ingress {
      from {
        namespace_selector {
          match_labels = {
            tier = "api"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

What does this code do?

Each ingress policy uses a namespace_selector to identify which namespace the traffic can come FROM. The tier label you added to your namespaces in namespaces.tf makes this selector possible.

The pod_selector in the spec targets only pods with a specific app label. This means only the api pods accept frontend traffic, and only the database pods accept API traffic.

  • Save network-policies.tf and apply the changes by running:
terraform apply -auto-approve

You should see Terraform adding 2 new resources. Ingress is now permitted, but the connection will still time out because the source pods don't have egress permission yet.

Why do I need both ingress AND egress?

Network policies work at both ends of a connection. The sending pod needs an egress rule allowing it to send traffic out, and the receiving pod needs an ingress rule allowing traffic in. If either side is missing, the connection fails.

Terraform showing errors on apply?

Check that your namespace_selector uses the correct label key. The ingress policies reference tier = "frontend" and tier = "api" which must match the labels in your namespaces.tf file.

Help me fix Terraform errors when applying my ingress network policies.

Add egress policies and verify the full chain

The receiving side is ready. Now you need to give the sending pods permission to initiate outbound connections. The frontend needs egress to the API namespace, and the API needs egress to the database namespace.

  • In network-policies.tf, add the following egress policies ABOVE the ingress policies you just added (below the DNS section comment and above the api_ingress_from_frontend resource):
# Frontend can send traffic to API namespace on port 80
resource "kubernetes_network_policy_v1" "frontend_egress_to_api" {
  metadata {
    name      = "allow-egress-to-api"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "frontend"
      }
    }
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            tier = "api"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

# API can send traffic to database namespace on port 80
resource "kubernetes_network_policy_v1" "api_egress_to_database" {
  metadata {
    name      = "allow-egress-to-database"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "api"
      }
    }
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            tier = "database"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

What does this code do?

These egress policies mirror the ingress policies. The frontend_egress_to_api policy allows frontend pods to send TCP traffic on port 80 to any pod in the api namespace.

Together with the ingress policies, this creates a complete bidirectional permission. The frontend is allowed to send, and the API is allowed to receive. The same pattern connects the API to the database.

  • Save network-policies.tf and apply the final policies by running:
terraform apply -auto-approve

Terraform should add 2 more resources. Now test the full chain.

  • Test that the frontend can reach the API by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://api-svc.api.svc.cluster.local

You should see the nginx welcome page HTML. This confirms frontend-to-API traffic flows.

  • Test that the API can reach the database by running:
kubectl exec -n api deploy/api -- wget --timeout=3 -qO- http://database-svc.database.svc.cluster.local

Again, you should see the nginx HTML output. The API-to-database path is open.

  • Now verify that unauthorized paths are blocked. Test frontend directly to database by running:
kubectl exec -n frontend deploy/frontend -- wget --timeout=3 -qO- http://database-svc.database.svc.cluster.local

This should time out. The frontend has no egress rule allowing it to reach the database namespace.

  • Test database to frontend (the reverse path) by running:
kubectl exec -n database deploy/database -- wget --timeout=3 -qO- http://frontend-svc.frontend.svc.cluster.local

This also times out. The database has no permission to initiate connections to anyone. Your zero trust posture is fully enforced.

Authorized paths also timing out?

Make sure your egress policies use the correct namespace label. Check that tier = "api" and tier = "database" match the labels in your namespaces.tf file exactly. Also confirm your egress policies are in the correct namespace (the sender's namespace, not the receiver's).

Help me debug why my allowed traffic paths are still timing out after applying egress and ingress policies.

✔️ Awesome, I've got everything!

Great. Double check you've saved network-policies.tf with all the policies in place.

ⓧ I'd like to double check the full code

Here is the complete network-policies.tf file with all policies:

# =============================================================================
# DEFAULT DENY POLICIES - Block all traffic by default in each namespace
# =============================================================================

resource "kubernetes_network_policy_v1" "deny_all_frontend" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_api" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

resource "kubernetes_network_policy_v1" "deny_all_database" {
  metadata {
    name      = "deny-all"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Ingress", "Egress"]
  }
}

# =============================================================================
# DNS EGRESS POLICIES - Allow all pods to resolve DNS names
# =============================================================================

resource "kubernetes_network_policy_v1" "allow_dns_frontend" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy_v1" "allow_dns_api" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

resource "kubernetes_network_policy_v1" "allow_dns_database" {
  metadata {
    name      = "allow-dns"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {}
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            "kubernetes.io/metadata.name" = "kube-system"
          }
        }
        pod_selector {
          match_labels = {
            "k8s-app" = "kube-dns"
          }
        }
      }
      ports {
        port     = "53"
        protocol = "UDP"
      }
      ports {
        port     = "53"
        protocol = "TCP"
      }
    }
  }
}

# =============================================================================
# ALLOW POLICIES - Explicit trust between tiers
# =============================================================================

# Frontend can send traffic to API namespace on port 80
resource "kubernetes_network_policy_v1" "frontend_egress_to_api" {
  metadata {
    name      = "allow-egress-to-api"
    namespace = kubernetes_namespace_v1.frontend.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "frontend"
      }
    }
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            tier = "api"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

# API accepts traffic from frontend namespace on port 80
resource "kubernetes_network_policy_v1" "api_ingress_from_frontend" {
  metadata {
    name      = "allow-ingress-from-frontend"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "api"
      }
    }
    policy_types = ["Ingress"]

    ingress {
      from {
        namespace_selector {
          match_labels = {
            tier = "frontend"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

# API can send traffic to database namespace on port 80
resource "kubernetes_network_policy_v1" "api_egress_to_database" {
  metadata {
    name      = "allow-egress-to-database"
    namespace = kubernetes_namespace_v1.api.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "api"
      }
    }
    policy_types = ["Egress"]

    egress {
      to {
        namespace_selector {
          match_labels = {
            tier = "database"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

# Database accepts traffic from API namespace on port 80
resource "kubernetes_network_policy_v1" "database_ingress_from_api" {
  metadata {
    name      = "allow-ingress-from-api"
    namespace = kubernetes_namespace_v1.database.metadata[0].name
  }

  spec {
    pod_selector {
      match_labels = {
        app = "database"
      }
    }
    policy_types = ["Ingress"]

    ingress {
      from {
        namespace_selector {
          match_labels = {
            tier = "api"
          }
        }
      }
      ports {
        port     = "80"
        protocol = "TCP"
      }
    }
  }
}

Your cluster now enforces true zero trust networking. Traffic flows only along explicitly permitted paths, and the entire security posture is version-controlled as Terraform code. If someone accidentally deletes a policy, a single terraform apply restores it.

Secret mission

Add RBAC Restrictions with Terraform

Your network policies control what can talk to what. But who can see what? Lock down namespace-level access so a developer ServiceAccount can only view resources in the frontend namespace, and is completely blocked from the api and database namespaces.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. However, the Kind cluster does consume Docker resources (CPU, memory) while running.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Kind cluster named zero-trust (running as a Docker container).
  • Terraform state and configuration files (providers.tf, namespaces.tf, app.tf, network-policies.tf, rbac.tf).
  • Kind CLI (installed via winget during this project).

Keep everything running

No action needed. Choose this if you are still actively building or plan to experiment with the zero trust policies further.

  • Your Kind cluster will continue running as a Docker container.
  • Kind remains installed on your system via winget.
  • Your Terraform files stay on your Desktop inside the project folder.

Pause - I'll come back to this later

Stop the Kind cluster to free up Docker resources. Your Terraform files stay on disk so you can recreate the cluster later.

  • Delete the Kind cluster (this stops the Docker container) by running:
  • Your .tf files remain on disk. To recreate everything later, run kind create cluster --name zero-trust followed by terraform apply.
kind delete cluster --name zero-trust

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the cluster, Terraform state, project files, and the Kind CLI.

  • First, use Terraform to destroy all Kubernetes resources it manages by running:
terraform destroy
  • When prompted, type yes and press Enter to confirm.
  • Delete the Kind cluster by running:
kind delete cluster --name zero-trust
  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rmdir /s /q zero-trust
  • Uninstall the Kind CLI by running:
winget uninstall Kubernetes.kind

That's a Wrap!

That's a Wrap!

Nice work! You've just built a fully zero trust Kubernetes cluster where no pod can communicate with any other pod unless you explicitly permit it. The entire security posture lives as Terraform code you can destroy and recreate in seconds.

You've learned how to:

  • Deploy a 3-tier microservice architecture across separate Kubernetes namespaces using Terraform as the single source of truth.
  • Implement a default-deny security posture with NetworkPolicies that block all traffic by default, then build explicit trust by allowing only the exact paths needed.
  • Manage your entire zero trust network configuration as Infrastructure as Code that can be version-controlled, audited, and reapplied at will.
  • Added RBAC restrictions limiting what a developer ServiceAccount can see and do per namespace.

Ready to quiz yourself?