We closed module 2 with an uncomfortable observation: MercadoFresco already has an Auto Scaling group in eu-west-1, disks with automated snapshots, the catalogue in S3, the database in RDS and two Lambda functions… and all of it lives in the default VPC. That is, in a network AWS created on its own the day the account was opened, with every subnet public, with the database sharing network space with the shop and with the mercadofresco-tienda-01 instance reachable from the internet without anyone having consciously decided so.

A VPC (Virtual Private Cloud) is your own virtual data centre inside AWS: an isolated network space, with the range of IP addresses you choose, divided into subnets that you place in whichever Availability Zones you want, with the routes you define. It is the layer absolutely everything else rests on. A load balancer sits in subnets. A database lives in a subnet group. A security group belongs to a VPC. If the network is badly designed, everything built on top of it drags the problem along.

In this lesson Marta designs and builds MercadoFresco's definitive network from scratch: vpc-mercadofresco, with six subnets spread across two Availability Zones, three levels of isolation, controlled outbound internet access and a database that stops being reachable from outside.

Contents

  1. What a VPC is and why the default VPC will not do for production
  2. Addressing: CIDR, masks and how to choose the range
  3. The five IPs AWS reserves in every subnet
  4. Why not to overlap with the office network
  5. MercadoFresco's network design: six subnets across two AZs
  6. The internet gateway and what really makes a subnet "public"
  7. Route tables: main, custom, associations and the local route
  8. NAT gateway and NAT instance: what they are for and what they really cost
  9. Private, public and elastic IP addresses, and network interfaces
  10. VPC endpoints: gateway and interface
  11. DNS inside the VPC
  12. Connecting to the office: peering, Transit Gateway, VPN and Direct Connect
  13. Building vpc-mercadofresco end to end from the CLI
  14. Moving the mercadofresco-pedidos database to the private data subnets
  15. VPC Flow Logs: the record of what goes across the network
  16. Clean-up and cost control

What a VPC is and why the default VPC will not do for production

A VPC is a virtual network that is logically isolated from the rest of AWS's customers. It lives in a region (ours is eu-west-1) and automatically spans all of its Availability Zones. Inside it you create subnets, and each subnet lives in one single Availability Zone. That is the rule governing the whole design: the VPC is regional, the subnet is zonal.

When AWS creates a new account, it generates a default VPC in every region with a design meant to make anything work on the first attempt. That is exactly its problem.

Aspect Default VPC Designed VPC (vpc-mercadofresco)
Range 172.31.0.0/16, identical in every account 10.0.0.0/16, chosen by us
Subnets One per AZ, all public Six: public, private application and private data, across two AZs
Route to the internet 0.0.0.0/0 → IGW in every subnet Only in the public ones
Automatic public IP Yes, enabled by default No, disabled except in the public ones
Isolation by layers None Three levels with different route tables
Database Reachable from the internet if the SG fails No route to the internet at all
Overlap with the office Unpredictable Controlled and documented
Reproducible in another account No Yes, it is declared infrastructure

The critical point is the second to last one. In the default VPC, the only thing stopping anyone on the internet from opening a connection to the orders database is the security group. A security group is a rule, and rules get edited by mistake on a Friday at seven in the evening. In a private subnet there is no route to the internet at all: even if somebody opened port 5432 to 0.0.0.0/0, there would be no way back. That is not a rule, it is network physics. It is called defence in depth, and it is why this design is worth the work.

Addressing: CIDR, masks and how to choose the range

CIDR (Classless Inter-Domain Routing) is the address/mask notation used to describe a range of IP addresses. In 10.0.0.0/16, the /16 means that the first 16 bits are fixed (the 10.0.) and the remaining 16 are variable: that gives 2^16 = 65,536 addresses, from 10.0.0.0 up to 10.0.255.255.

The mental rule: the larger the number after the slash, the smaller the network. Every bit you add halves the range.

Mask Total addresses Usable IPs in AWS Typical use
/16 65,536 65,531 The maximum allowed for a VPC
/18 16,384 16,379 An enormous subnet, rarely needed
/20 4,096 4,091 A comfortable application subnet (our choice)
/22 1,024 1,019 A medium subnet
/24 256 251 A small subnet, the most common one in examples
/26 64 59 A database subnet
/28 16 11 The minimum allowed for a subnet in AWS

The private ranges you may use (defined in RFC 1918) are:

  • 10.0.0.0/8 — from 10.0.0.0 to 10.255.255.255. Almost 17 million addresses.
  • 172.16.0.0/12 — from 172.16.0.0 to 172.31.255.255. This is where the default VPC lives.
  • 192.168.0.0/16 — from 192.168.0.0 to 192.168.255.255. The classic of home routers.

Marta chooses 10.0.0.0/16 for three concrete reasons:

  1. It is the maximum AWS allows for a VPC (/16). A VPC's primary CIDR cannot be shrunk or changed once it is created; you can only add secondary blocks. Starting small "because we do not need that many IPs" is the mistake that has cost the most migrations.
  2. It is not 172.31.x.x, so it will never clash with the default VPC if one day the two have to be peered.
  3. It is not 192.168.x.x, which is exactly what the router in MercadoFresco's office uses.

On point 1, a figure to calibrate with: MercadoFresco today has 2 shop instances and reaches 4 at the Friday peak. With 4,091 addresses per application subnet, the headroom is three orders of magnitude. Private IP addresses cost nothing. Being generous here is free; being stingy is paid for with a network migration two years from now.

The five IPs AWS reserves in every subnet

In any subnet, AWS keeps five addresses you cannot assign to anything. Taking 10.0.0.0/20 (from 10.0.0.0 to 10.0.15.255) as the example:

Address Reserved for
10.0.0.0 The network identifier (a networking standard, not an AWS thing)
10.0.0.1 The VPC router: the subnet's default gateway
10.0.0.2 The VPC DNS server (Route 53 Resolver, "the +2 IP")
10.0.0.3 Reserved by AWS for future use
10.0.15.255 The broadcast address, even though AWS does not support broadcast

That is why a /28 with 16 addresses gives only 11 usable ones, and why /28 is the minimum: below that there would be practically nothing left. The .2 address is worth remembering: when we configure DNS forwarding rules in lesson 03-05, that is the address every instance will be talking to.

Why not to overlap with the office network

MercadoFresco's office uses 192.168.10.0/24 for its computers and its NAS. Marta documents that before creating anything, because the moment somebody wants to connect the office to AWS —over a VPN, as we will see at the end of the lesson— overlapping ranges make the connection impossible.

The reason is pure routing. If Luis's laptop is 192.168.10.25 and there were also an instance 192.168.10.25 in AWS, when the laptop tried to talk to the instance it would look at its own route table, see that 192.168.10.0/24 is "my local network" and send the packet to the switch next to it. It would never cross the tunnel. And there is no setting that fixes it: one of the two networks would have to be renumbered from top to bottom.

The rule Marta follows and that is worth adopting: a single document with the map of every range in the company, including the ones that do not exist yet.

Network Range Status
Barcelona office 192.168.10.0/24 In use
AWS default VPC 172.31.0.0/16 Exists, to be removed later
vpc-mercadofresco (production, eu-west-1) 10.0.0.0/16 Created in this lesson
Development VPC (future) 10.1.0.0/16 Reserved
VPC in a second region (future) 10.2.0.0/16 Reserved
Remote-working VPN 10.200.0.0/22 Reserved

MercadoFresco's network design: six subnets across two AZs

The pattern we are going to build is the industry standard: three layers × two Availability Zones. Each layer has a different level of exposure and a different route table.

Subnet AZ CIDR Usable IPs Layer What lives here
snet-mercadofresco-publica-a eu-west-1a 10.0.0.0/20 4,091 Public ALB, NAT gateway
snet-mercadofresco-publica-b eu-west-1b 10.0.16.0/20 4,091 Public ALB, NAT gateway
snet-mercadofresco-app-a eu-west-1a 10.0.32.0/20 4,091 Private application ASG instances, Lambda in VPC
snet-mercadofresco-app-b eu-west-1b 10.0.48.0/20 4,091 Private application ASG instances, Lambda in VPC
snet-mercadofresco-datos-a eu-west-1a 10.0.64.0/20 4,091 Private data Primary RDS, EFS
snet-mercadofresco-datos-b eu-west-1b 10.0.80.0/20 4,091 Private data Standby RDS, EFS

Everything from 10.0.96.0 onwards is left free: more than half the VPC unused, available for future layers (containers in module 10, caching in 06-05) without touching anything that exists.

Three design decisions worth understanding:

  • The application layer is private. The shop instances will have no public IP. They will receive traffic through the load balancer (lesson 03-03) and will reach the internet, when they need to, through the NAT gateway.
  • The data layer has no route to the internet at all, not even through NAT. RDS does not need to download anything. The fewer paths there are, the smaller the attack surface.
  • Everything is duplicated across two AZs. If the whole of eu-west-1a goes down, eu-west-1b has a public subnet (for the load balancer), an application subnet (for the instances) and a data subnet (for the RDS standby). This is the practical reason for the multi-AZ design we saw in 01-03.

The internet gateway and what really makes a subnet "public"

An internet gateway (IGW) is a managed, redundant, horizontally scaled component that AWS attaches to a VPC (one per VPC) and that does two things:

  1. It provides the destination for traffic bound for the internet.
  2. It translates (1:1 NAT) an instance's private IP to its public IP, in both directions.

And here is the concept that causes the most confusion in the whole lesson:

There is no checkbox called "public subnet". A subnet is public if —and only if— the route table associated with it contains a 0.0.0.0/0 entry pointing at an internet gateway. That is all. The name you give it is decorative.

Two very practical consequences follow from that:

  • An instance with a public IP in a subnet without a route to the IGW has no internet. It has an IP that is good for nothing.
  • An instance without a public IP in a subnet with a route to the IGW has no internet either: the IGW needs a public IP to translate.

In other words, for an instance to talk to the internet directly you need both things at once: a route to the IGW and a public IP (or an elastic one).

Route tables: main, custom, associations and the local route

A route table is a list of "to get to this destination, send it this way" rules. Each subnet is associated with exactly one route table; one table can serve many subnets.

Every VPC is born with a main route table, which applies to any subnet you have not explicitly associated with another one. And every table contains a local route that cannot be deleted or modified:

Destination Target Meaning
10.0.0.0/16 local All traffic inside the VPC is routed internally

That local route is what lets the shop instance talk to RDS with no extra configuration whatsoever: inside a VPC, every subnet can reach every other one by default, whichever AZ they are in. What decides whether that conversation is allowed is not the route, it is the security groups, which is exactly the subject of lesson 03-02.

MercadoFresco's three tables end up like this:

Table Associated subnets Routes
rt-mercadofresco-publica publica-a, publica-b 10.0.0.0/16 → local
0.0.0.0/0 → igw-mercadofresco
rt-mercadofresco-app-a app-a 10.0.0.0/16 → local
0.0.0.0/0 → nat-mercadofresco-a
rt-mercadofresco-app-b app-b 10.0.0.0/16 → local
0.0.0.0/0 → nat-mercadofresco-b
rt-mercadofresco-datos datos-a, datos-b 10.0.0.0/16 → local
(nothing else)

Notice that there are two separate tables for the application layer, one per AZ. It is deliberate: if app-a and app-b shared a table, both would go out through the same NAT gateway, and if that NAT went down with its AZ, the instances in the other zone would be left with no way out. With one table per AZ, each zone is self-sufficient.

AWS always applies the most specific route that matches (longest prefix match). If a table has 0.0.0.0/0 → NAT and 52.94.0.0/16 → IGW, a packet to 52.94.10.1 goes via the IGW because /16 is more specific than /0.

NAT gateway and NAT instance: what they are for and what they really cost

The application layer's instances have no public IP, but they need to reach the internet: to download security patches, install packages with dnf, call the payment gateway. They need outbound connections without accepting inbound ones. That is what the NAT gateway is for.

The NAT gateway sits in a public subnet, has an elastic IP, and acts as an intermediary: it receives the packet from the private instance, forwards it to the internet with its own public IP and returns the response. Since the conversation is always started by the instance, nobody can open a connection from the internet inwards.

flowchart LR
    EC2["Private instance<br/>10.0.32.15<br/>(no public IP)"]
    RT["Table rt-mercadofresco-app-a<br/>0.0.0.0/0 → nat-mercadofresco-a"]
    NAT["NAT gateway<br/>in snet-publica-a<br/>Elastic IP 52.x.x.x"]
    IGW["Internet gateway"]
    NET["Internet<br/>(package repository)"]

    EC2 --> RT --> NAT --> IGW --> NET
    NET -. "response" .-> IGW -.-> NAT -.-> EC2

⚠️ Cost warning: the NAT gateway generates more surprise bills than any other resource

In eu-west-1 a NAT gateway costs roughly 0.048 USD per hour plus 0.048 USD per GB processed. With two NAT gateways (one per AZ) running all month:

  • 2 × 0.048 × 730 h = ≈ 70 USD/month just for existing, without transferring a single byte.
  • Plus 0.048 USD for every GB that goes through them, in both directions.

It is not in the free tier. If you are following the course in a test account with the 10 USD budget we set up in 01-02, the alert to aws-alertas@mercadofresco.example will fire in less than a week. Your options:

  • A single NAT gateway for testing (≈ 35 USD/month): create only nat-mercadofresco-a and point both application tables at it. You lose zone fault tolerance, which in a test environment does not matter.
  • No NAT at all: if you only want to practise addressing, skip the NAT and use Session Manager (seen in 02-01) to reach the instances. Without NAT they will not update packages, but the network works just the same for learning purposes.
  • Delete it at the end of every practice session. There are clean-up commands at the end of the lesson.

The historical alternative is the NAT instance: an ordinary EC2 with IP forwarding enabled and the source/destination check disabled. An honest comparison:

NAT gateway NAT instance
Management AWS, zero maintenance Yours: patches, monitoring, restarts
Availability Redundant within its AZ A single point of failure
Bandwidth Up to 100 Gbps, scales on its own Whatever the instance gives (0.5-25 Gbps)
Base cost ≈ 35 USD/month per gateway A t4g.nano ≈ 3 USD/month
Cost per GB 0.048 USD/GB Only the standard transfer
Security groups Not applicable Yes, you can attach them
Can be used as a bastion No Yes
Recommendation Production Labs and learning accounts

For MercadoFresco in production: NAT gateways, two of them, one per AZ. For anyone following the course in a personal account: a t4g.nano NAT instance, or none at all.

And there is a third way, the most elegant one, which we will see in the endpoints section: taking off the NAT all the traffic that is actually going to AWS services.

Private, public and elastic IP addresses, and network interfaces

Four concepts that are constantly confused with one another:

Concept What it is Survives stopping the instance Cost
Private IP An address within the subnet's CIDR (10.0.32.15) Yes, it is fixed as long as the ENI exists Free
Public IP A routable IP assigned automatically at start-up No, it changes on every start Since 2024, ≈ 0.005 USD/h
Elastic IP (EIP) A public IP that is yours, to assign and reassign at will Yes Free while in use; charged when unassigned
ENI A virtual network interface: the instance's network card Yes, it can be detached and moved Free

The ENI (Elastic Network Interface) is the piece that ties it all together. An ENI belongs to a subnet, has one or more private IPs, optionally a public or elastic IP, a MAC address and the security groups. When you say "this instance is in subnet X with security group Y", what you are really describing is its ENI.

That explains things that otherwise look like magic:

  • RDS, an ALB or a Lambda in a VPC are not machines you can see, but they do consume IPs from your subnets: each of them creates its own ENIs. An ALB can create eight or more ENIs as it scales. Another reason not to make subnets small.
  • An EIP attached to an ENI can be moved to another instance in seconds, which lets you replace a machine without changing DNS.

The classic billing trap: an elastic IP that is allocated and in use is free; an elastic IP that is reserved and attached to nothing costs ≈ 0.005 USD/hour (≈ 3.6 USD/month). Orphaned EIPs left behind after deleting an instance are the commonest phantom cost in learning accounts.

VPC endpoints: gateway and interface

When the mercadofresco-tienda-01 instance, now in a private subnet, asks for a photo from mercadofresco-catalogo-fotos, which way does that traffic go? By default: instance → NAT gateway → internet gateway → AWS's public network → S3. It leaves for the internet only to come back into AWS. And the NAT is charged for every GB.

VPC endpoints remove that detour: they take the traffic to AWS services over AWS's internal network, without ever going through the internet. There are two kinds, and they work very differently.

Gateway endpoint Interface endpoint (PrivateLink)
Supported services Only S3 and DynamoDB Almost all of them: SSM, Secrets Manager, KMS, ECR, CloudWatch, SQS, SNS…
How it works A route in the route table An ENI with a private IP in your subnet
What DNS resolves S3's public name, redirected by the route A private name (or the public one, with private DNS enabled)
Security groups Not applicable Yes, you attach one to it
Cost Free ≈ 0.011 USD/h per AZ + 0.01 USD/GB
Reach Only inside the VPC Reachable from other VPCs and from the office over VPN

For MercadoFresco, the gateway endpoint to S3 is a decision with nothing to discuss: it is free and it saves money from the very first byte.

flowchart TB
    subgraph VPC["vpc-mercadofresco"]
        EC2["Shop instance<br/>snet-mercadofresco-app-a"]
        VPCE(["Gateway endpoint<br/>vpce-mercadofresco-s3<br/>(an entry in the route table)"])
        NAT["NAT gateway<br/>0.048 USD/GB"]
    end
    S3[("mercadofresco-catalogo-fotos")]
    INET["Internet"]

    EC2 -- "pl-6da54004 → vpce (free)" --> VPCE --> S3
    EC2 -. "0.0.0.0/0 → NAT (paid)" .-> NAT --> INET

The interesting part of the mechanism: when you create the endpoint, AWS adds an entry to your route tables whose destination is a managed prefix list (pl-6da54004 for S3 in eu-west-1), which contains every public IP range of S3 in the region. Because that route is more specific than 0.0.0.0/0, the traffic to S3 is diverted to the endpoint and the rest keeps going through the NAT. Not a single line of the application's code has to change.

On top of that, a gateway endpoint accepts an endpoint policy that restricts what can be done through it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SoloBucketsDeMercadoFresco",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::mercadofresco-catalogo-fotos",
        "arn:aws:s3:::mercadofresco-catalogo-fotos/*",
        "arn:aws:s3:::mercadofresco-copias-basedatos",
        "arn:aws:s3:::mercadofresco-copias-basedatos/*"
      ]
    }
  ]
}

This policy says: through this endpoint you can only read from and write to MercadoFresco's two buckets. If a compromised instance tried to upload data to an attacker's bucket, the endpoint would reject it. Principal: "*" here does not mean "anyone on the internet": the only way in through this endpoint is from inside the VPC.

The interface endpoints MercadoFresco will eventually need (ssm, ssmmessages, ec2messages for Session Manager, and secretsmanager for what we will see in 04-03) do cost money, but they enable something valuable: using Session Manager on instances with no NAT and no internet whatsoever.

DNS inside the VPC

Every VPC comes with a built-in DNS resolver, the Route 53 Resolver, reachable at the VPC's .2 address (10.0.0.2 in our case) and also at the link-local address 169.254.169.253. It is what translates mercadofresco-pedidos.abc123.eu-west-1.rds.amazonaws.com into the right private IP.

Two VPC attributes govern its behaviour and confuse absolutely everybody:

Attribute What it does Recommended value
enableDnsSupport Enables the VPC resolver on the .2 IP. If it is off, nothing resolves names, not even those of RDS or S3 true, always
enableDnsHostnames Makes instances with a public IP also receive a public DNS name. It is essential for the private DNS of interface endpoints to work, and for RDS true

A VPC created with create-vpc has enableDnsSupport=true but enableDnsHostnames=false. It is the first setting to fix after creating it, and the number one cause of an interface endpoint "not working".

Connecting to the office: peering, Transit Gateway, VPN and Direct Connect

MercadoFresco still has its ERP on a server in the office. Sooner or later the two networks will have to be connected. The four options, which we do not develop here because they go beyond the scope of the course, but which it is worth being able to name:

Option Connects Key idea When
VPC peering Two VPCs A direct 1-to-1 link, not transitive Few VPCs, simple topology
Transit Gateway Many VPCs + VPN + Direct Connect A central hub-and-spoke router From 3-4 VPCs or several accounts on
Site-to-Site VPN Office ↔ AWS An encrypted IPsec tunnel over the internet Quick, cheap, variable bandwidth
Direct Connect Office ↔ AWS Dedicated fibre, never touching the internet Stable latency and high volume; expensive and months of lead time

For MercadoFresco, the natural choice when the time comes is a Site-to-Site VPN into vpc-mercadofresco: it is set up in an afternoon and it works precisely because 10.0.0.0/16 and 192.168.10.0/24 do not overlap, exactly as we decided at the start of the lesson.

Building vpc-mercadofresco end to end from the CLI

We are going to build all of it with AWS CLI v2 and the mercadofresco-dev profile we configured in 01-05. Each block saves the identifier it returns so the next one can use it.

Step 1: the VPC and its DNS attributes

# Create the VPC with the chosen CIDR and the project's mandatory tags
VPC_ID=$(aws ec2 create-vpc \
  --profile mercadofresco-dev --region eu-west-1 \
  --cidr-block 10.0.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[
      {Key=Name,Value=vpc-mercadofresco},
      {Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},
      {Key=Componente,Value=tienda},
      {Key=Propietario,Value=marta},
      {Key=CentroCoste,Value=operaciones}]' \
  --query 'Vpc.VpcId' --output text)

echo "VPC created: $VPC_ID"

# Turn on DNS hostnames (off by default: this is THE setting everyone forgets)
aws ec2 modify-vpc-attribute --profile mercadofresco-dev --region eu-west-1 \
  --vpc-id "$VPC_ID" --enable-dns-hostnames

# Check both attributes
aws ec2 describe-vpc-attribute --profile mercadofresco-dev --region eu-west-1 \
  --vpc-id "$VPC_ID" --attribute enableDnsHostnames \
  --query 'EnableDnsHostnames.Value'

--query 'Vpc.VpcId' --output text extracts just the identifier, with no quotes and no JSON, so it can be stored in a shell variable. It is the JMESPath pattern we saw in 01-05 and that we will use throughout the module.

Step 2: the six subnets

# Helper function: creates a subnet and returns its ID
create_subnet() {
  local name=$1 cidr=$2 az=$3 component=$4
  aws ec2 create-subnet \
    --profile mercadofresco-dev --region eu-west-1 \
    --vpc-id "$VPC_ID" --cidr-block "$cidr" --availability-zone "$az" \
    --tag-specifications "ResourceType=subnet,Tags=[
        {Key=Name,Value=$name},
        {Key=Proyecto,Value=mercadofresco},
        {Key=Entorno,Value=produccion},
        {Key=Componente,Value=$component},
        {Key=Propietario,Value=marta},
        {Key=CentroCoste,Value=operaciones}]" \
    --query 'Subnet.SubnetId' --output text
}

PUB_A=$(create_subnet  snet-mercadofresco-publica-a 10.0.0.0/20  eu-west-1a tienda)
PUB_B=$(create_subnet  snet-mercadofresco-publica-b 10.0.16.0/20 eu-west-1b tienda)
APP_A=$(create_subnet  snet-mercadofresco-app-a     10.0.32.0/20 eu-west-1a tienda)
APP_B=$(create_subnet  snet-mercadofresco-app-b     10.0.48.0/20 eu-west-1b tienda)
DAT_A=$(create_subnet  snet-mercadofresco-datos-a   10.0.64.0/20 eu-west-1a pedidos)
DAT_B=$(create_subnet  snet-mercadofresco-datos-b   10.0.80.0/20 eu-west-1b pedidos)

# Only the public ones assign a public IP automatically to whatever is launched in them
for S in "$PUB_A" "$PUB_B"; do
  aws ec2 modify-subnet-attribute --profile mercadofresco-dev --region eu-west-1 \
    --subnet-id "$S" --map-public-ip-on-launch
done

--map-public-ip-on-launch is a convenience, not a security decision: remember that without the route to the IGW that public IP would be good for nothing.

Step 3: internet gateway and public route table

IGW_ID=$(aws ec2 create-internet-gateway \
  --profile mercadofresco-dev --region eu-west-1 \
  --tag-specifications 'ResourceType=internet-gateway,Tags=[
      {Key=Name,Value=igw-mercadofresco},{Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},{Key=Componente,Value=tienda},
      {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]' \
  --query 'InternetGateway.InternetGatewayId' --output text)

aws ec2 attach-internet-gateway --profile mercadofresco-dev --region eu-west-1 \
  --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID"

RT_PUB=$(aws ec2 create-route-table --profile mercadofresco-dev --region eu-west-1 \
  --vpc-id "$VPC_ID" \
  --tag-specifications 'ResourceType=route-table,Tags=[
      {Key=Name,Value=rt-mercadofresco-publica},{Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},{Key=Componente,Value=tienda},
      {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]' \
  --query 'RouteTable.RouteTableId' --output text)

# THIS is the line that turns the subnets into "public" ones
aws ec2 create-route --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_PUB" --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW_ID"

aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_PUB" --subnet-id "$PUB_A"
aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_PUB" --subnet-id "$PUB_B"

Step 4: NAT gateways (⚠️ the spending starts here)

# One elastic IP per NAT gateway
EIP_A=$(aws ec2 allocate-address --profile mercadofresco-dev --region eu-west-1 \
  --domain vpc --query 'AllocationId' --output text)

NAT_A=$(aws ec2 create-nat-gateway --profile mercadofresco-dev --region eu-west-1 \
  --subnet-id "$PUB_A" --allocation-id "$EIP_A" \
  --tag-specifications 'ResourceType=natgateway,Tags=[
      {Key=Name,Value=nat-mercadofresco-a},{Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},{Key=Componente,Value=tienda},
      {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]' \
  --query 'NatGateway.NatGatewayId' --output text)

# A NAT gateway takes 1-2 minutes to become available; we wait actively
aws ec2 wait nat-gateway-available --profile mercadofresco-dev --region eu-west-1 \
  --nat-gateway-ids "$NAT_A"
echo "NAT $NAT_A available"

An important note: the NAT gateway is created in the public subnet ($PUB_A), not in the private one. It is the commonest placement mistake: if you put it in the private subnet, the NAT has no way out and nothing works, with no message anywhere explaining it.

Repeat the block with $PUB_B for nat-mercadofresco-b, or skip it if you are on a budget and point both application tables at $NAT_A.

Step 5: private route tables

create_private_rt() {
  local name=$1
  aws ec2 create-route-table --profile mercadofresco-dev --region eu-west-1 \
    --vpc-id "$VPC_ID" \
    --tag-specifications "ResourceType=route-table,Tags=[
        {Key=Name,Value=$name},{Key=Proyecto,Value=mercadofresco},
        {Key=Entorno,Value=produccion},{Key=Componente,Value=tienda},
        {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]" \
    --query 'RouteTable.RouteTableId' --output text
}

RT_APP_A=$(create_private_rt rt-mercadofresco-app-a)
RT_APP_B=$(create_private_rt rt-mercadofresco-app-b)
RT_DATOS=$(create_private_rt rt-mercadofresco-datos)

# Outbound internet through the NAT in their own AZ
aws ec2 create-route --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_APP_A" --destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_A"
aws ec2 create-route --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_APP_B" --destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_B"

aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_APP_A" --subnet-id "$APP_A"
aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_APP_B" --subnet-id "$APP_B"

# The data layer does NOT get any 0.0.0.0/0 route: that is intentional
aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_DATOS" --subnet-id "$DAT_A"
aws ec2 associate-route-table --profile mercadofresco-dev --region eu-west-1 \
  --route-table-id "$RT_DATOS" --subnet-id "$DAT_B"

Step 6: gateway endpoint to S3

VPCE_S3=$(aws ec2 create-vpc-endpoint --profile mercadofresco-dev --region eu-west-1 \
  --query 'VpcEndpoint.VpcEndpointId' --output text \
  --vpc-id "$VPC_ID" \
  --service-name com.amazonaws.eu-west-1.s3 \
  --vpc-endpoint-type Gateway \
  --route-table-ids "$RT_APP_A" "$RT_APP_B" "$RT_DATOS" \
  --tag-specifications 'ResourceType=vpc-endpoint,Tags=[
      {Key=Name,Value=vpce-mercadofresco-s3},{Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},{Key=Componente,Value=catalogo},
      {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]')

By passing --route-table-ids, AWS automatically inserts the route to S3's prefix list into those three tables. Check it:

aws ec2 describe-route-tables --profile mercadofresco-dev --region eu-west-1 \
  --route-table-ids "$RT_APP_A" \
  --query 'RouteTables[0].Routes[].{Destination:DestinationCidrBlock,PrefixList:DestinationPrefixListId,Gateway:GatewayId,NAT:NatGatewayId}' \
  --output table

The final network

flowchart TB
    USER["Users of<br/>mercadofresco.example"]
    IGW(["igw-mercadofresco"])

    subgraph VPC["vpc-mercadofresco — 10.0.0.0/16 — eu-west-1"]
        direction TB
        subgraph AZA["Zone eu-west-1a"]
            PA["snet-publica-a<br/>10.0.0.0/20<br/>NAT nat-mercadofresco-a"]
            AA["snet-app-a<br/>10.0.32.0/20<br/>ASG EC2 instances"]
            DA["snet-datos-a<br/>10.0.64.0/20<br/>RDS mercadofresco-pedidos"]
        end
        subgraph AZB["Zone eu-west-1b"]
            PB["snet-publica-b<br/>10.0.16.0/20<br/>NAT nat-mercadofresco-b"]
            AB["snet-app-b<br/>10.0.48.0/20<br/>ASG EC2 instances"]
            DB["snet-datos-b<br/>10.0.80.0/20<br/>Multi-AZ standby RDS"]
        end
        VPCE(["vpce-mercadofresco-s3"])
    end

    S3[("mercadofresco-catalogo-fotos")]

    USER --> IGW
    IGW --> PA
    IGW --> PB
    AA -->|egress| PA
    AB -->|egress| PB
    AA --> DA
    AB --> DB
    DA -. "Multi-AZ" .- DB
    AA --> VPCE --> S3

Moving the mercadofresco-pedidos database to the private data subnets

The RDS instance created in 02-04 still lives in the sng-mercadofresco subnet group, which pointed at the default VPC. We have to create a new group inside vpc-mercadofresco:

aws rds create-db-subnet-group --profile mercadofresco-dev --region eu-west-1 \
  --db-subnet-group-name sng-mercadofresco-datos \
  --db-subnet-group-description "MercadoFresco private data subnets" \
  --subnet-ids "$DAT_A" "$DAT_B" \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=pedidos Key=Propietario,Value=marta \
         Key=CentroCoste,Value=operaciones

Operational warning: an RDS instance cannot be moved between VPCs by changing its subnet group. The real path is: take a snapshot, restore it specifying the new subnet group, and repoint the application. At MercadoFresco, Marta does it in the small hours of a Tuesday:

aws rds create-db-snapshot --profile mercadofresco-dev --region eu-west-1 \\
  --db-instance-identifier mercadofresco-pedidos \\
  --db-snapshot-identifier mercadofresco-pedidos-premigracion

aws rds wait db-snapshot-completed --profile mercadofresco-dev --region eu-west-1 \\
  --db-snapshot-identifier mercadofresco-pedidos-premigracion

aws rds restore-db-instance-from-db-snapshot --profile mercadofresco-dev --region eu-west-1 \\
  --db-instance-identifier mercadofresco-pedidos-v2 \\
  --db-snapshot-identifier mercadofresco-pedidos-premigracion \\
  --db-subnet-group-name sng-mercadofresco-datos \\
  --multi-az --no-publicly-accessible

The --no-publicly-accessible matters just as much as the subnet group: even though the subnet has no route to the internet, the PubliclyAccessible attribute set to true would make RDS resolve to a public IP and break the connection from inside.

VPC Flow Logs: the record of what goes across the network

VPC Flow Logs capture metadata about every IP traffic flow: source, destination, ports, protocol, bytes, packets and —most valuable of all— whether the flow was ACCEPT or REJECT. They do not capture the content, only the headers.

They can be enabled at three levels: the whole VPC, a subnet or a network interface. And they can be sent to CloudWatch Logs (interactive querying, more expensive) or to S3 (cheap, for bulk analysis).

aws ec2 create-flow-logs --profile mercadofresco-dev --region eu-west-1 \
  --resource-type VPC --resource-ids "$VPC_ID" \
  --traffic-type ALL \
  --log-destination-type s3 \
  --log-destination arn:aws:s3:::mercadofresco-registros-web/flow-logs/ \
  --max-aggregation-interval 60 \
  --tag-specifications 'ResourceType=vpc-flow-log,Tags=[
      {Key=Name,Value=flowlogs-mercadofresco},{Key=Proyecto,Value=mercadofresco},
      {Key=Entorno,Value=produccion},{Key=Componente,Value=tienda},
      {Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]'

A record looks like this:

2 111122223333 eni-0a1b2c3d 10.0.32.15 52.94.5.20 44321 443 6 12 3800 1738500000 1738500060 ACCEPT OK

Read piece by piece: version, account, ENI, source IP, destination IP, source port, destination port, protocol (6 = TCP), packets, bytes, start, end, action and status. That final ACCEPT/REJECT is the diagnostic tool we will use in lesson 03-02 to tell whether a connectivity problem is caused by a security group or by a NACL. Analysis with CloudWatch Logs Insights and the associated alarms are covered in 05-01; here it is enough to have them switched on.

A cost to keep in mind: flow logs have no service charge of their own, but you do pay for the storage and the ingestion in S3 or in CloudWatch Logs. In a VPC with heavy traffic, ALL can generate several GB a day; starting with --traffic-type REJECT is a cheap way of keeping what matters.

Clean-up and cost control

If you are practising in an account of your own, this is what has to be deleted and in this order, because AWS blocks the deletion of a resource that others depend on:

# 1. The most expensive thing first
aws ec2 delete-nat-gateway --profile mercadofresco-dev --region eu-west-1 --nat-gateway-id "$NAT_A"
aws ec2 wait nat-gateway-deleted --profile mercadofresco-dev --region eu-west-1 --nat-gateway-ids "$NAT_A"

# 2. Release the elastic IP (otherwise it keeps costing ~3.6 USD/month)
aws ec2 release-address --profile mercadofresco-dev --region eu-west-1 --allocation-id "$EIP_A"

# 3. Endpoints, subnets, route tables, IGW and VPC
aws ec2 delete-vpc-endpoints --profile mercadofresco-dev --region eu-west-1 --vpc-endpoint-ids "$VPCE_S3"
for S in "$PUB_A" "$PUB_B" "$APP_A" "$APP_B" "$DAT_A" "$DAT_B"; do
  aws ec2 delete-subnet --profile mercadofresco-dev --region eu-west-1 --subnet-id "$S"
done
aws ec2 detach-internet-gateway --profile mercadofresco-dev --region eu-west-1 \
  --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID"
aws ec2 delete-internet-gateway --profile mercadofresco-dev --region eu-west-1 --internet-gateway-id "$IGW_ID"
aws ec2 delete-vpc --profile mercadofresco-dev --region eu-west-1 --vpc-id "$VPC_ID"

A final check that nothing is still being charged for:

# Orphaned elastic IPs across the whole region
aws ec2 describe-addresses --profile mercadofresco-dev --region eu-west-1 \
  --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output table

# NAT gateways still alive
aws ec2 describe-nat-gateways --profile mercadofresco-dev --region eu-west-1 \
  --filter Name=state,Values=available \
  --query 'NatGateways[].[NatGatewayId,SubnetId]' --output table

Common Mistakes and Tips

Choosing a CIDR that is too small. A /24 for the whole VPC looks like plenty until the third service arrives. The primary CIDR cannot be changed. Always use /16: private IP addresses are free.

Overlapping ranges between environments. Production on 10.0.0.0/16 and development also on 10.0.0.0/16 is convenient until the day of the peering. Reserve the range map before the first VPC.

Putting the NAT gateway in a private subnet. It will give no error when you create it, and nothing will work. The NAT always goes in a public subnet; the private one is what uses it.

A single NAT gateway for two AZs in production. You save 35 USD a month and create a dependency between zones: if the NAT's AZ goes down, the instances in the other zone lose their way out. In testing it is reasonable; in production it is not.

Forgetting enableDnsHostnames. Interface endpoints and some RDS integrations fail in incomprehensible ways. It is the first thing to enable after creating the VPC.

Leaving PubliclyAccessible=true on RDS. Moving it to a private subnet is not enough: if the attribute is still on, RDS returns a public IP and the application stops connecting.

Not tagging the network. A VPC with no Name and no Proyecto is a vpc-0a1b2c3d that in six months' time nobody dares to delete. Every command in this lesson carries the five mandatory tags for that reason; in 11-02 we will see the financial value they have.

Leaving elastic IPs unassociated. They cost money even when they do nothing. Review them with the command from the clean-up section every time you delete an instance.

The golden tip: before creating a single subnet, draw the table of ranges in a document. Ten minutes on paper save a network migration.

Exercises

Exercise 1: planning the addressing for a second region

MercadoFresco wants to open a development VPC in eu-west-1 and another one in the future in eu-central-1, and also connect the office over VPN. Design the complete addressing plan: the ranges you assign to each network, why, and what would happen if you gave all three VPCs 10.0.0.0/16.

Exercise 2: calculating subnets and usable IPs

For a 10.5.0.0/16 VPC designed with three AZs and two layers (public and private), using a /22 mask on the public ones and /20 on the private ones: write the complete table of the six subnets with their non-overlapping CIDRs, say how many usable IPs each one has and how much space is left.

Exercise 3: diagnosing an instance with no internet

Luis launches an instance in snet-mercadofresco-app-a and cannot run dnf update. Write the sequence of CLI checks, in order, and what to look for in each one. Consider at least four different possible causes.

Solutions

Solution 1

The addressing plan, respecting the map we already defined:

Network Range Rationale
Barcelona office 192.168.10.0/24 Already exists, simply documented
vpc-mercadofresco (prod, eu-west-1) 10.0.0.0/16 The main network, already created
Development VPC (eu-west-1) 10.1.0.0/16 The second contiguous block, easy to remember
Production VPC (eu-central-1) 10.2.0.0/16 The third block, reserved from now on
Remote-working VPN 10.200.0.0/22 A high, separate range, 1,024 addresses

With 10.0.0.0/16 on all three VPCs: each one would work perfectly on its own, and that is the danger, because the problem does not show up until months later. On the first attempt at peering, AWS rejects the request outright: VPCs with overlapping CIDRs cannot be peered. And on setting up the VPN with the office, the office could not tell which VPC to send a packet destined for 10.0.32.15 to. The only solution would be to recreate a whole VPC and migrate all its resources, because the primary CIDR is immutable.

Solution 2

VPC 10.5.0.0/16 (from 10.5.0.0 to 10.5.255.255). A /22 is 1,024 addresses (blocks of 4 in the third octet); a /20 is 4,096 (blocks of 16).

Subnet AZ CIDR Range Usable IPs
public-a eu-west-1a 10.5.0.0/22 10.5.0.010.5.3.255 1,019
public-b eu-west-1b 10.5.4.0/22 10.5.4.010.5.7.255 1,019
public-c eu-west-1c 10.5.8.0/22 10.5.8.010.5.11.255 1,019
private-a eu-west-1a 10.5.16.0/20 10.5.16.010.5.31.255 4,091
private-b eu-west-1b 10.5.32.0/20 10.5.32.010.5.47.255 4,091
private-c eu-west-1c 10.5.48.0/20 10.5.48.010.5.63.255 4,091

Total allocated: 3,072 + 12,288 = 15,360 addresses. The VPC has 65,536, so 50,176 are left free (from 10.5.64.0 onwards), that is, 76 % of the space. A design detail: the public ones occupy 10.5.0.0 to 10.5.11.255 and the private ones start at 10.5.16.0, leaving a deliberate gap at 10.5.12.0/22 so the public layer can be extended without fragmenting the plan.

Solution 3

# 1. Is the subnet associated with the right route table?
aws ec2 describe-route-tables --profile mercadofresco-dev --region eu-west-1 \
  --filters Name=association.subnet-id,Values=$APP_A \
  --query 'RouteTables[].{Table:RouteTableId,Routes:Routes[].{D:DestinationCidrBlock,NAT:NatGatewayId,IGW:GatewayId}}'

What to look for: a 0.0.0.0/0 entry with a NatGatewayId. If no table comes back at all, the subnet is using the main table, which has no outbound route: it needs associating.

# 2. Does the NAT gateway exist and is it available?
aws ec2 describe-nat-gateways --profile mercadofresco-dev --region eu-west-1 \
  --nat-gateway-ids "$NAT_A" --query 'NatGateways[].[State,SubnetId]' --output table

What to look for: state available. And check that its SubnetId is the public subnet, not the private one. A NAT in a private subnet is the classic cause.

# 3. Does the NAT's subnet really have a route to the IGW?
aws ec2 describe-route-tables --profile mercadofresco-dev --region eu-west-1 \
  --filters Name=association.subnet-id,Values=$PUB_A \
  --query 'RouteTables[].Routes[?DestinationCidrBlock==`0.0.0.0/0`]'

What to look for: a GatewayId starting with igw-. Without it, the NAT has no way out either.

# 4. Does the instance's security group allow OUTBOUND traffic?
aws ec2 describe-instances --profile mercadofresco-dev --region eu-west-1 \
  --instance-ids "$INSTANCE_ID" \
  --query 'Reservations[].Instances[].SecurityGroups[].GroupId' --output text

What to look for: by default every security group allows all outbound traffic, but if somebody has restricted it, dnf will not reach the repository. This already takes us into lesson 03-02.

A fifth cause to rule out: the instance started before the NAT route existed and is holding an old network cache; restarting it is enough. And a sixth: the flow logs show REJECT on the return traffic, which would point to a NACL, also material for 03-02.

Conclusion

MercadoFresco no longer lives in the network it happened to be given: it lives in vpc-mercadofresco, a designed network. You know that a VPC is regional and a subnet is zonal, that the primary CIDR 10.0.0.0/16 is immutable and that this is why it is chosen generously, and that private addresses are free. You can read a mask and work out its usable IPs, and you know that AWS reserves five addresses in every subnet, among them the .1 of the router and the .2 of the DNS resolver. You have documented the company's range map so that 10.0.0.0/16 will never clash with the office's 192.168.10.0/24 when the VPN arrives.

You have built the six subnets across three layers and two Availability Zones, and you understand the concept that holds up the whole module: a subnet is public purely because its route table has a 0.0.0.0/0 pointing at the internet gateway, not because of a setting or its name. You know that the local route is what lets the shop talk to the database with nothing configured, and that the data layer has no way out to the internet by design: that is defence in depth, not a rule somebody can delete by mistake.

You know the NAT gateway —where it goes, why one per AZ, and that it costs around 35 USD a month per unit plus 0.048 USD per GB, which makes it the most expensive resource you have created so far— and its alternatives for a learning account. You can tell private, public and elastic IPs and ENIs apart, and you know that an unassociated EIP keeps charging. You have set up the gateway endpoint to S3, which is free and takes all the photo catalogue's traffic off the NAT, and you know when an interface endpoint with PrivateLink is needed. You have enabled enableDnsHostnames, moved the mercadofresco-pedidos database to sng-mercadofresco-datos via snapshot and restore —with --no-publicly-accessible— and switched on VPC Flow Logs to mercadofresco-registros-web.

But the network you have built, as it stands, filters nothing. The local route lets any instance in the VPC open a connection to port 5432 of the database, and the public subnet accepts whatever arrives through the internet gateway. The firewall is missing. In lesson 03-02, "Security groups and network access control lists", we will build AWS's two filtering layers: security groups —stateful and, above all, with the technique of referencing one group from another instead of writing IP ranges— and NACLs, stateless, with their numbered rules and their ephemeral-port trap. By the end, port 5432 of mercadofresco-pedidos will only accept connections from the shop's security group, and you will be able to read a REJECT in the flow logs you have just switched on to work out exactly which of the two layers blocked a packet.

© Copyright 2026. All rights reserved