Updated Nov 5, 20266 min read

AWS VPC with Terraform: Subnets, NAT Gateways and Security Groups

How to build a production AWS VPC in Terraform, first with the community VPC module and then with plain resources, what each piece is for, and how to get the same three-tier network for every environment without maintaining it.

A VPC is usually the first thing a team writes in Terraform on AWS, and the one that stays longest. Every Fargate service, RDS instance and ElastiCache cluster lands in its subnets, so getting the layout right early saves a painful migration later.

This guide builds a production layout with public, private and database subnets across two Availability Zones, first with the community VPC module and then with plain resources so each piece is visible. It closes with the trade-offs of maintaining that code and how Encore (infrastructure from code) provisions the same network per environment.

The target layout

TierSubnetsRoute for 0.0.0.0/0Holds
Public10.0.0.0/24, 10.0.1.0/24Internet gatewayLoad balancer, NAT gateways
Private10.0.10.0/24, 10.0.11.0/24NAT gateway in the same AZECS tasks, EKS nodes
Database10.0.20.0/24, 10.0.21.0/24NoneRDS, ElastiCache

The reasons behind each tier are covered in the Amazon VPC user guide: only the load balancer is reachable from the internet, application containers reach out through NAT, and the data tier has no internet route at all.

Option 1: the terraform-aws-modules VPC module

The terraform-aws-modules VPC module is the most common starting point. Pin the major version, since major releases change inputs:

module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 6.0" name = "app-production" cidr = "10.0.0.0/16" azs = ["eu-central-1a", "eu-central-1b"] public_subnets = ["10.0.0.0/24", "10.0.1.0/24"] private_subnets = ["10.0.10.0/24", "10.0.11.0/24"] database_subnets = ["10.0.20.0/24", "10.0.21.0/24"] enable_nat_gateway = true one_nat_gateway_per_az = true enable_dns_hostnames = true create_database_subnet_group = true }

The module outputs vpc_id, public_subnets, private_subnets and database_subnet_group_name, which the load balancer, ECS services and RDS instance then reference. For development environments, single_nat_gateway = true replaces the per-AZ gateways with one.

Option 2: plain resources

Writing the resources directly shows what the module creates. The core of it, using for_each over a map of Availability Zones:

locals { azs = { a = { az = "eu-central-1a", public = "10.0.0.0/24", private = "10.0.10.0/24", db = "10.0.20.0/24" } b = { az = "eu-central-1b", public = "10.0.1.0/24", private = "10.0.11.0/24", db = "10.0.21.0/24" } } } resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" enable_dns_hostnames = true tags = { Name = "app-production" } } resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id } resource "aws_subnet" "public" { for_each = local.azs vpc_id = aws_vpc.main.id availability_zone = each.value.az cidr_block = each.value.public map_public_ip_on_launch = true } resource "aws_subnet" "private" { for_each = local.azs vpc_id = aws_vpc.main.id availability_zone = each.value.az cidr_block = each.value.private } resource "aws_subnet" "db" { for_each = local.azs vpc_id = aws_vpc.main.id availability_zone = each.value.az cidr_block = each.value.db } resource "aws_eip" "nat" { for_each = local.azs domain = "vpc" } resource "aws_nat_gateway" "main" { for_each = local.azs allocation_id = aws_eip.nat[each.key].id subnet_id = aws_subnet.public[each.key].id depends_on = [aws_internet_gateway.main] }

Routing is what makes a subnet public or private. The public route table points at the internet gateway, each private table points at the NAT gateway in its own zone, and the database subnets get a table with no default route:

resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.main.id } } resource "aws_route_table_association" "public" { for_each = local.azs subnet_id = aws_subnet.public[each.key].id route_table_id = aws_route_table.public.id } resource "aws_route_table" "private" { for_each = local.azs vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.main[each.key].id } } resource "aws_route_table_association" "private" { for_each = local.azs subnet_id = aws_subnet.private[each.key].id route_table_id = aws_route_table.private[each.key].id } resource "aws_route_table" "db" { vpc_id = aws_vpc.main.id } resource "aws_route_table_association" "db" { for_each = local.azs subnet_id = aws_subnet.db[each.key].id route_table_id = aws_route_table.db.id }

Security groups then restrict traffic between tiers. Referencing the application's security group instead of CIDR ranges keeps the rule valid as tasks come and go, and the separate ingress rule resources avoid the conflicts of inline rules:

resource "aws_security_group" "app" { name = "app" vpc_id = aws_vpc.main.id } resource "aws_security_group" "db" { name = "db" vpc_id = aws_vpc.main.id } resource "aws_vpc_security_group_ingress_rule" "db_from_app" { security_group_id = aws_security_group.db.id referenced_security_group_id = aws_security_group.app.id ip_protocol = "tcp" from_port = 5432 to_port = 5432 }

Security groups are stateful, so return traffic needs no rule, and the NAT gateway docs explain why each private subnet should route to the gateway in its own zone. An S3 gateway endpoint on the private route tables keeps bucket traffic off the NAT gateway.

What maintaining it involves

The listing above is around 150 lines before the load balancer, ECS cluster, RDS subnet group, ElastiCache subnet group and per-service security groups are added. Each environment needs its own non-overlapping CIDR range and state file, each new database or cache needs another ingress rule, and provider and module upgrades need a reviewed plan for every environment. Each of these drifts from the application as services are added.

The same VPC, provisioned per environment with Encore

Encore

Encore is infrastructure from code for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. For each environment, Encore creates an isolated VPC across two Availability Zones with a public subnet for the Application Load Balancer, internet gateway and NAT gateway, a compute subnet for Fargate or EKS that only accepts traffic from the load balancer, and a storage subnet with no internet access for databases and caches, as described in the AWS infrastructure docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create catalog --example=go/hello-world$ cd catalog && mkdir -p product/migrations

The application declares what it uses, and the network placement follows. This Go service with a database gets its containers in the compute subnet and its RDS instance in the storage subnet, and the storage subnet only accepts connections from the compute subnet:

// product/product.go package product import ( "context" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("products", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type Product struct { SKU string `json:"sku"` Name string `json:"name"` } //encore:api public method=GET path=/products/:sku func Get(ctx context.Context, sku string) (*Product, error) { p := &Product{SKU: sku} err := db.QueryRow(ctx, `SELECT name FROM products WHERE sku = $1`, sku).Scan(&p.Name) return p, err }
-- product/migrations/1_create_products.up.sql CREATE TABLE products (sku TEXT PRIMARY KEY, name TEXT NOT NULL);

encore run starts Postgres locally. Each environment gets a random /16 private range by default, or a range you choose when creating it if you plan to peer with existing networks, per configure network settings, and resources are adjusted in the infrastructure configuration dashboard. If you already run Terraform for other parts of your AWS account, Encore deploys alongside it and is built to avoid overwriting changes made directly in the AWS console.

An AI coding agent adding a cache or a new service writes the declaration in Go or TypeScript, and the subnets and security group rules come with the deploy.

Connecting the AWS account is a one-time step covered in the own-cloud deployment guide.

Best fit: Teams who want the standard three-tier VPC layout in every environment, created from their application.

Good to know

  • Reserve a /16 per environment and keep a record of ranges before peering or Transit Gateway.
  • aws_eip uses domain = "vpc" in current AWS provider versions, replacing the deprecated vpc = true argument.
  • Use separate state per environment so a mistake in staging cannot touch production networking.

Frequently asked questions

How do I create a VPC in Terraform?

Use the aws_vpc resource with a CIDR block, then add aws_subnet resources per Availability Zone, an aws_internet_gateway, NAT gateways with Elastic IPs, route tables and associations. The terraform-aws-modules/vpc/aws module wraps all of these behind a few inputs such as cidr, azs, public_subnets and private_subnets.

Should I use the terraform-aws-modules VPC module or write resources myself?

The module is widely used and covers NAT, database subnet groups, flow logs and endpoints, which saves a few hundred lines. Plain resources give full control and easier reviews of exactly what exists. Many teams start with the module and pin its major version.

How many NAT gateways does a VPC need?

One per Availability Zone is the resilient setup, since a single NAT gateway makes every private subnet depend on one zone and adds cross-zone data charges. A single NAT gateway is a common cost saving for development environments.

Can I get a production VPC on AWS without writing Terraform?

Yes. With Encore, you write your backend in TypeScript or Go and declare resources such as new SQLDatabase() in code. Deploying to your AWS account creates a VPC per environment across two Availability Zones with public, compute and storage subnets, the load balancer, NAT gateway and security groups.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders