🟢 Welcome to my World!
WORKSPACE
TIMELINE
root/welcome.hi

Building a Production-Ready VPC for Amazon EKS with Terraform — No Modules Required

Building a Production-Ready VPC for Amazon EKS with Terraform — No Modules Required


If you’ve ever tried to set up an Amazon Elastic Kubernetes Service (EKS) cluster from scratch, you might have felt like you were staring at a 10,000-piece jigsaw puzzle without the picture on the box. The networking layer alone—VPCs, subnets, NAT gateways, route tables, and obscure tags—can be overwhelming.

Many tutorials tell you to just use the official AWS Terraform modules and call it a day. While modules are fantastic for saving time, they hide the underlying architecture. If you don’t know why your EKS cluster needs a specific network topology, you’re going to have a rough time debugging when things go wrong (and they will).

In this guide, we are rolling up our sleeves and building a production-ready VPC for EKS from the ground up using raw Terraform resources. By the end of this post, you’ll understand exactly what makes an EKS network tick, the reasoning behind public and private subnets, and the “magic” tags that make Kubernetes routing work on AWS.

Prerequisites: What You’ll Need

Before we dive into the code, ensure you have the following ready:

  • Terraform Installed: Version 1.0 or higher.
  • AWS CLI Configured: You should have AWS credentials configured locally with permissions to create VPC resources.
  • Basic Terraform Knowledge: You should know how to run terraform init, plan, and apply, and understand basic syntax (resources, variables, locals).
  • A text editor: VS Code, Vim, or whatever you prefer.

The “Why”: EKS VPC Topology Explained

Think of an EKS cluster’s network like a medieval castle.

The VPC (Virtual Private Cloud) is the outer boundary of your kingdom. Inside, you have the Public Subnets. This is the castle courtyard. Anyone from the outside world (the internet) can walk into the courtyard, provided they use the main gate (the Internet Gateway). We put things like Load Balancers here—they act as receptionists, taking requests from the public and directing them where they need to go.

Then, you have the Private Subnets. This is the inner keep where the royal family lives. In our case, the royal family is your Kubernetes worker nodes and your applications. You do not want the public to have direct access to them. However, the royal family still needs to send letters out to the outside world (for example, downloading a container image from the internet). To do this, they hand their outgoing letters to a trusted messenger (the NAT Gateway) who walks through the courtyard and out the front gate.

If you put your worker nodes in a public subnet, you are giving every node a public IP address. From a security standpoint, that is equivalent to leaving the door to the inner keep wide open. Therefore, a standard production EKS topology requires:

  1. Public subnets for Load Balancers (Ingress).
  2. Private subnets for Worker Nodes and Pods.
  3. NAT Gateways to allow private subnets to reach the internet.

Let’s translate this castle into Terraform code.

Step 1: Laying the Foundation - Locals and the VPC

First, let’s define some local variables to keep our configuration DRY (Don’t Repeat Yourself). We’ll define our environment, region, availability zones, and cluster details.

Create a file named 0-locals.tf:

locals {
    env = "staging"
    region = "us-east-2"
    zone1 = "us-east-2a"
    zone2 = "us-east-2b"
    eks_name = "demo"
    eks_version = "1.36"
}

Next, we establish the kingdom’s boundary: the VPC.

Create a file named 2-vpc.tf:

resource "aws_vpc" "main" {
    cidr_block = "10.0.0.0/16"

    enable_dns_support   = true
    enable_dns_hostnames = true

    tags = {
        Name = "${local.env}-main"
    }
}

Practical Tip: Always set enable_dns_support and enable_dns_hostnames to true for an EKS VPC. EKS heavily relies on DNS resolution for node-to-control-plane communication. If these are false, your worker nodes might silently fail to join the cluster.

Step 2: The Main Gate - Internet Gateway

For anything in our VPC to talk to the internet directly (or for the internet to talk to it), we need an Internet Gateway.

Create 3-igw.tf:

resource "aws_internet_gateway" "igw" {
    vpc_id = aws_vpc.main.id

    tags = {
        Name = "${local.env}-igw"
    }
}

This is simply attaching the main gate to the castle walls. It doesn’t do much until we configure the routes, which we’ll do later.

Step 3: Subnets - The Inner Keep and the Courtyard

This is where the magic happens, and where most EKS beginners stumble. We need to create our private and public subnets across at least two Availability Zones (AZs) for high availability.

Let’s look at 4-subnets.tf:

resource "aws_subnet" "private_zone1" {
    vpc_id = aws_vpc.main.id
    cidr_block = "10.0.0.0/19"
    availability_zone = local.zone1

    tags = {
        Name = "${local.env}-private-${local.zone1}"
        "kubernetes.io/role/internal-elb" = "1"
        "kubernetes.io/cluster/${local.env}-${local.eks_name}" = "owned"
    }
}

resource "aws_subnet" "private_zone2" {
    vpc_id = aws_vpc.main.id
    cidr_block = "10.0.32.0/19"
    availability_zone = local.zone2

    tags = {
        Name = "${local.env}-private-${local.zone2}"
        "kubernetes.io/role/internal-elb" = "1"
        "kubernetes.io/cluster/${local.env}-${local.eks_name}" = "owned"
    }
}

resource "aws_subnet" "public_zone1" {
    vpc_id = aws_vpc.main.id
    cidr_block = "10.0.64.0/19"
    availability_zone = local.zone1
    map_public_ip_on_launch = true

    tags = {
        Name = "${local.env}-public-${local.zone1}"
        "kubernetes.io/role/elb" = "1"
        "kubernetes.io/cluster/${local.env}-${local.eks_name}" = "owned"
    }
}

resource "aws_subnet" "public_zone2" {
    vpc_id = aws_vpc.main.id
    cidr_block = "10.0.96.0/19"
    availability_zone = local.zone2
    map_public_ip_on_launch = true

    tags = {
        Name = "${local.env}-public-${local.zone2}"
        "kubernetes.io/role/elb" = "1"
        "kubernetes.io/cluster/${local.env}-${local.eks_name}" = "owned"
    }
}

The Crucial EKS Tags Gotcha

Notice those specific kubernetes.io/... tags? If you miss these, your EKS cluster will deploy successfully, but when you try to create a Kubernetes Service of type LoadBalancer or an Ingress, it will fail silently or stay in a “Pending” state forever.

Here is the breakdown of why these tags are non-negotiable:

  1. "kubernetes.io/role/elb" = "1" (Public Subnets): This tag tells the AWS Load Balancer Controller, “Hey! I am a public subnet. If you need to create an internet-facing Application Load Balancer (ALB) or Network Load Balancer (NLB), put it here.”
  2. "kubernetes.io/role/internal-elb" = "1" (Private Subnets): This tag tells the controller, “I am a private subnet. If you need to create an internal load balancer (one that is only accessible within the VPC), put it here.”
  3. "kubernetes.io/cluster/<cluster-name>" = "owned" (or "shared"): This tag ties the subnets to your specific EKS cluster. The AWS cloud provider looks for this tag to discover which subnets it is allowed to use.

Without these tags, the Kubernetes control plane is flying blind and won’t know where to provision AWS resources.

Step 4: The Trusted Messenger - NAT Gateway

Our worker nodes will live in the private subnets. This means they do not have public IP addresses. But wait—how will they pull container images from Docker Hub or Amazon ECR? How will they download OS updates?

They need outbound internet access. Enter the NAT (Network Address Translation) Gateway.

Create 5-nat.tf:

resource "aws_eip" "nat" {
    domain = "vpc"

    tags = {
        Name = "${local.env}-nat"
    }
}

resource "aws_nat_gateway" "nat" {
    allocation_id = aws_eip.nat.id
    subnet_id = aws_subnet.public_zone1.id

    tags = {
        Name = "${local.env}-nat"
    }

    depends_on = [aws_internet_gateway.igw]
}

The Cost vs. High Availability Trade-off

You might notice something here: we only created one NAT Gateway, and we placed it in public_zone1.

In a strictly highly-available (HA) production environment, AWS best practices dictate that you should have one NAT Gateway in every availability zone where you have private subnets. If us-east-2a goes down, your nodes in us-east-2b would lose internet access because the single NAT Gateway is dead.

So why did we only deploy one? Cost.

NAT Gateways are notoriously expensive. You pay an hourly rate just for them to exist, plus a per-gigabyte data processing charge. For staging environments, side projects, or startups trying to extend their runway, a single NAT Gateway is a very common and acceptable trade-off.

If this were a mission-critical, tier-1 production cluster, you would absolutely want a for_each loop here to deploy a NAT Gateway per public subnet.

Step 5: Directing Traffic - Route Tables

We have the subnets, the Internet Gateway, and the NAT Gateway. But right now, they don’t know how to talk to each other. We need Route Tables to act as the traffic cops.

Create 6-routes.tf:

resource "aws_route_table" "private" {
    vpc_id = aws_vpc.main.id

    route {
        cidr_block = "0.0.0.0/0"
        nat_gateway_id = aws_nat_gateway.nat.id
    }

    tags = {
        Name = "${local.env}-private"
    }
}

resource "aws_route_table" "public" {
    vpc_id = aws_vpc.main.id

    route {
        cidr_block = "0.0.0.0/0"
        gateway_id = aws_internet_gateway.igw.id
    }

    tags = {
        Name = "${local.env}-public"
    }
}

resource "aws_route_table_association" "private_zone1" {
    subnet_id = aws_subnet.private_zone1.id
    route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_zone2" {
    subnet_id = aws_subnet.private_zone2.id
    route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "public_zone1" {
    subnet_id = aws_subnet.public_zone1.id
    route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_zone2" {
    subnet_id = aws_subnet.public_zone2.id
    route_table_id = aws_route_table.public.id
}

Here is how the traffic flows:

  • Public Route Table: We set the default route (0.0.0.0/0) to point to the aws_internet_gateway. Then we associate both of our public subnets with this route table. This is what makes a public subnet “public”.
  • Private Route Table: We set the default route to point to the aws_nat_gateway. We associate our private subnets here. Now, when a node in a private subnet tries to reach the internet, the traffic is routed to the NAT Gateway in the public subnet, which then forwards it to the Internet Gateway.

Gotchas and Practical Tips

Before we wrap up, here are the pitfalls that trip up most people when setting up VPC networking for EKS:

  1. Missing EKS subnet tags will cause silent failures. If you forget the kubernetes.io/role/elb or kubernetes.io/role/internal-elb tags on your subnets, your EKS cluster will deploy just fine—but any LoadBalancer Service or Ingress resource will stay stuck in a Pending state forever. There are no helpful error messages; the AWS Load Balancer Controller simply cannot discover the subnets. Always double-check your tags.

  2. Always enable DNS on your VPC. Setting enable_dns_support and enable_dns_hostnames to true is not optional for EKS. Without them, worker nodes may fail to register with the control plane, CoreDNS won’t resolve internal service names, and the VPC CNI plugin may struggle with IP management. These settings default to true and false respectively, so you must explicitly set enable_dns_hostnames = true.

  3. A single NAT Gateway is a single point of failure. We used one NAT Gateway in this guide to save costs, and that’s perfectly fine for staging or development. But in production, if the AZ hosting your NAT Gateway goes down, all private subnets lose outbound internet access—meaning no image pulls, no API calls, no updates. For production, deploy one NAT Gateway per AZ.

Conclusion

Congratulations! You’ve just built a robust, secure, and EKS-ready VPC architecture entirely from scratch using Terraform.

By peeling back the layers of abstraction that modules provide, you now understand the mechanics of EKS networking. You know that security means keeping worker nodes in private subnets, that NAT gateways are necessary for outbound traffic, and most importantly, that without the right tags, the AWS Load Balancer Controller will leave you tearing your hair out.

Key Takeaways

  • Public vs. Private: Worker nodes should live in private subnets for security. Public subnets are primarily for load balancers and NAT Gateways.
  • Tagging is Mandatory: You must tag your subnets with kubernetes.io/role/elb (public) or kubernetes.io/role/internal-elb (private) so Kubernetes can dynamically provision load balancers.
  • DNS Resolution: Ensure enable_dns_support and enable_dns_hostnames are true on your VPC.
  • NAT Gateway Costs: A single NAT Gateway is a cost-effective choice for non-critical workloads, but true HA requires one per Availability Zone.

In the next post of this series, we’ll take this networking foundation and actually deploy the EKS cluster and node groups on top of it. Happy Terraforming!

TERMINAL
1: zsh
sirishgurung@portfolio:-$cat ./contact.txt
▄▄   ▄▄▄▄ ▄▄▄▄ ▄▄  ▄▄▄      ▄▄ ▄ ▄ ▄▄▄▄ ▄▄▄▄ ▄▄ ▄      ▄▄▄▄ ▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄ ▄ ▄▄▄▄ ▄▄▄▄ ▄▄
██   ██ ▀  ██  ▀  ██▀       ██ █ █ ██ █ ██ █ ██ █       ██  ██ █ ██    ██ ▀  ██  ██ █ ██ ▀ ██ █ ██
██   ██▀   ██     ▀██▄      ██ █ █ ██ █ ██▄▀ ██▄▀       ██  ██ █ ██ ▄▄ ██▀   ██  ██▄█ ██▀  ██▄▀ ██
 █▄▄  █ █  ▐█      ▄▀▀       █ █ ▀  █ █ ▀█ █  █ █       ▐█   █ █ ▐▀ ▀▌  █ █  ▐█   █ █  █ █ ▀█ █ ▀▀
▀▀▀▀ ▀▀▀▀  ▀▀     ▀▀▀       ▀▀▀▀▀▀ ▀▀▀▀ ▀▀ ▀ ▀▀ ▀       ▀▀  ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀  ▀▀  ▀▀ ▀ ▀▀▀▀ ▀▀ ▀ ▀▀
      
I'd love to hear from you!>Get in touch