Terraform with AWS – Building the Network and Database

August 12, 20267 min readUpdated 8/24/2026

Everything up to here has been the language. This lesson and the next deploy a real application: the pizza API, a Spring Boot 4 service with MySQL, Liquibase migrations and JWT auth, onto ECS Fargate behind a load balancer.

This half builds what it runs on — a VPC, subnets, security groups and an RDS instance. The next half runs the container.

⚠️ Everything here was applied to a real AWS account, checked through the load balancer, and destroyed. The timings and error messages are what actually happened.

What it costs

Say this first, because a lot of ECS tutorials do not:

NAT gateway                       $0.045/hr    ~$32/month
Application load balancer         $0.023/hr    ~$17/month
Fargate 0.5 vCPU / 1 GB (ARM64)   $0.020/hr
RDS db.t4g.micro                  $0.016/hr
                                  ─────────
                                  ~$0.13/hr    ~$92/month if you leave it up

None of it is free tier except the RDS instance in a new account's first year. The full stack for this article cost about $0.11, because it was destroyed after fifty minutes. End every session with terraform destroy.

The shape

                   internet
                      │
               ┌──────▼──────┐
               │     ALB     │   public subnets, 2 AZs, :80
               └──────┬──────┘
                      │  :8085
            ┌─────────▼─────────┐
            │  ECS Fargate task │   private subnets
            └─────────┬─────────┘
                      │  :3306
               ┌──────▼──────┐
               │  RDS MySQL  │   private subnets
               └─────────────┘

  NAT gateway (public subnet) ── outbound only: ECR, Secrets Manager, logs

Nothing in a private subnet has a route from the internet, so nothing on the internet can open a connection to it. The tasks still need outbound access to pull their image and write logs, which is what the NAT gateway is for.

The VPC

resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr

  # Both are required for RDS to be reachable by hostname, and both default to
  # FALSE on a VPC you create yourself — unlike the default VPC, where they are
  # already on. RDS hands out a DNS name; without enable_dns_hostnames there is
  # no name to resolve, and the app fails with an unknown-host error that reads
  # like a typo in the connection string.
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(var.tags, { Name = var.name_prefix })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = merge(var.tags, { Name = "${var.name_prefix}-igw" })
}

The CIDR is 10.20.0.0/16 — deliberately not the 172.31.0.0/16 that AWS default VPCs use. Overlapping ranges cannot be peered later, and picking an unused range costs nothing today.

Subnets

data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  # Exactly two. An ALB REQUIRES subnets in at least two AZs and refuses to
  # create with one — and the error says "at least two subnets" without
  # mentioning availability zones, which is a confusing way to learn this.
  azs = slice(data.aws_availability_zones.available.names, 0, 2)

  public_cidrs  = ["10.20.0.0/24", "10.20.1.0/24"]
  private_cidrs = ["10.20.10.0/24", "10.20.11.0/24"]
}

resource "aws_subnet" "public" {
  count = length(local.azs)

  vpc_id            = aws_vpc.this.id
  cidr_block        = local.public_cidrs[count.index]
  availability_zone = local.azs[count.index]

  # This is what makes the subnet "public" in the sense the ALB cares about.
  # It is NOT what makes it routable — the route table below does that.
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-public-${local.azs[count.index]}"
    Tier = "public"
  })
}

count, not for_each — and this is the case where lesson 9's warning does not apply. The subnets are an ordered pair with no identity of their own, indexed by AZ, from a list that is derived rather than hand-maintained. Nobody is going to remove the middle element of a two-element list.

The NAT gateway, and what it costs

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

  # The EIP is allocated from a pool the internet gateway must already exist
  # to draw from. Terraform cannot see that dependency in the arguments —
  # neither resource references the other — so it has to be stated.
  depends_on = [aws_internet_gateway.this]
}

resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat.id

  # ⚠️ In a PUBLIC subnet. A NAT gateway's whole job is to have a route to the
  # internet gateway; putting it in the private subnet it serves creates a
  # routing loop that Terraform will happily build for you.
  subnet_id = aws_subnet.public[0].id

  depends_on = [aws_internet_gateway.this]
}

One gateway, not one per AZ. A NAT gateway lives in a single AZ and takes that AZ's outbound traffic with it when it fails. Two would be the highly-available answer and would double the cost — a deliberate trade for a demo, and one production in two AZs should make differently.

The alternatives, honestly:

public subnets + assign_public_ip   free, but tasks get inbound-capable addresses
VPC interface endpoints             better at scale — but Fargate needs FIVE of
                                    them (ECR api, ECR dkr, S3, Logs, Secrets)
                                    at ~$0.01/hr each. That is MORE than one NAT
                                    gateway until data volume is high.

Route tables

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

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

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

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

# ⚠️ A route table does nothing until a subnet is associated with it.
resource "aws_route_table_association" "private" {
  count          = length(aws_subnet.private)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

The association is the step people skip. The symptom is a task that starts, cannot pull its image, and times out several minutes later with an error that never mentions routing.

Security groups that reference security groups

The single most useful habit in AWS networking:

resource "aws_security_group" "db" {
  name        = "${var.name_prefix}-db"
  description = "MySQL access for the pizza API tasks"
  vpc_id      = var.vpc_id
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id = aws_security_group.db.id
  description       = "MySQL from the ECS tasks"

  from_port   = 3306
  to_port     = 3306
  ip_protocol = "tcp"

  # ⚠️ NO CIDR BLOCK. This allows traffic from anything WEARING that security
  # group, wherever it is and whatever address it has. Fargate tasks get a new
  # private IP every time they start, so a CIDR rule here would either be wrong
  # or so wide it allowed the whole VPC.
  referenced_security_group_id = var.app_security_group_id
}

Note there is deliberately no egress rule on the database. A security group with no egress rules allows nothing out, and a database has no business opening outbound connections.

These are the separate rule resources, not the older inline ingress/egress blocks on aws_security_group. Two reasons: each rule is its own addressable resource, so a plan shows exactly which rule changed; and the inline form silently retains AWS's default allow-all egress, so the absence above would not have meant anything.

RDS, and keeping the password out of state

This is the most useful thing in the lesson.

resource "aws_db_subnet_group" "this" {
  name = "${var.name_prefix}-db"

  # At least two subnets in different AZs, EVEN FOR A SINGLE-AZ INSTANCE. AWS
  # wants somewhere to fail over to before it will let you opt out of failing over.
  subnet_ids = var.private_subnet_ids
}

resource "aws_db_instance" "this" {
  identifier = "${var.name_prefix}-mysql"

  engine         = "mysql"
  engine_version = "8.4"
  instance_class = "db.t4g.micro"

  allocated_storage = 20
  storage_type      = "gp3"
  storage_encrypted = true

  db_name  = "pizza"
  username = "pizza"    # NOT "admin" or "root" — both are reserved by RDS

  # ⚠️ THE POINT OF THIS RESOURCE.
  #
  # With `password = var.db_password`, the password is in the state file in
  # PLAINTEXT — forever, in every historical version, readable by anyone who can
  # read state. Marking the variable `sensitive` only hides it from console
  # output; it changes nothing about what is written to disk.
  #
  # This instead has RDS generate the password, store it in Secrets Manager, and
  # own its rotation. Terraform never learns it.
  manage_master_user_password = true

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.db.id]

  publicly_accessible = false
  multi_az            = false   # production says true

  # The three arguments that make `terraform destroy` actually work. Every
  # default here is tuned for a real database and fights a teardown.
  skip_final_snapshot     = true  # default false: destroy fails asking for a snapshot name
  deletion_protection     = false # production says true, and destroy then refuses
  backup_retention_period = 0     # default 1 day: backups bill after the instance is gone
}

The password reaches the application as an ARN, never as a value:

output "master_user_secret_arn" {
  description = "ARN of the RDS-managed master password secret."
  value       = aws_db_instance.this.master_user_secret[0].secret_arn
}

Proving the password is not in state

Do not take that on trust — it is checkable. Pull the live password out of Secrets Manager and grep the state file for it:

SECRET=$(terraform output -raw db_secret_arn)
PW=$(aws secretsmanager get-secret-value --secret-id "$SECRET" \
       --query SecretString --output text | jq -r .password)

grep -c "$PW" terraform.tfstate
# 0

And what the state does hold instead:

{
  "password": null,
  "manage_master_user_password": true,
  "master_user_secret": [
    {
      "secret_arn": "arn:aws:secretsmanager:us-west-2:329580012644:secret:rds!db-a7221b70-...",
      "kms_key_id": "arn:aws:kms:us-west-2:329580012644:key/b12f0d01-..."
    }
  ]
}

An ARN and a KMS key id. That is the pattern worth copying wherever a platform will generate and hold a secret for you: let it, and reference the ARN.

⚠️ The honest counterpart, from the next lesson: random_password does not have this property. It stores its result in state by definition, because that is how the value stays stable across applies.

How long it takes

module.database.aws_db_instance.this: Still creating... [05m10s elapsed]
module.database.aws_db_instance.this: Creation complete after 5m17s

RDS is the long pole, at 5m17s. Everything else in the network — VPC, four subnets, gateways, route tables, security groups — completes in under a minute, and the NAT gateway takes about ninety seconds. Plan your feedback loop around that: developing the ECS half against an already-created database is much faster than tearing the whole stack down each time.

Next

The network and the database are up. Lesson 13 puts the application on it — ECR, a task definition, a Fargate service and a load balancer — including the health check that has to be exactly right, and the first apply that cycled tasks forever.