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 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.
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.
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.
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.
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.

Run in your terminal to get started locally.
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.
/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 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.
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.
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.
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.