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
- What a VPC is and why the default VPC will not do for production
- Addressing: CIDR, masks and how to choose the range
- The five IPs AWS reserves in every subnet
- Why not to overlap with the office network
- MercadoFresco's network design: six subnets across two AZs
- The internet gateway and what really makes a subnet "public"
- Route tables: main, custom, associations and the local route
- NAT gateway and NAT instance: what they are for and what they really cost
- Private, public and elastic IP addresses, and network interfaces
- VPC endpoints: gateway and interface
- DNS inside the VPC
- Connecting to the office: peering, Transit Gateway, VPN and Direct Connect
- Building
vpc-mercadofrescoend to end from the CLI - Moving the
mercadofresco-pedidosdatabase to the private data subnets - VPC Flow Logs: the record of what goes across the network
- 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— from10.0.0.0to10.255.255.255. Almost 17 million addresses.172.16.0.0/12— from172.16.0.0to172.31.255.255. This is where the default VPC lives.192.168.0.0/16— from192.168.0.0to192.168.255.255. The classic of home routers.
Marta chooses 10.0.0.0/16 for three concrete reasons:
- 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. - 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. - 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-1agoes down,eu-west-1bhas 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:
- It provides the destination for traffic bound for the internet.
- 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/0entry 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 → local0.0.0.0/0 → igw-mercadofresco |
rt-mercadofresco-app-a |
app-a |
10.0.0.0/16 → local0.0.0.0/0 → nat-mercadofresco-a |
rt-mercadofresco-app-b |
app-b |
10.0.0.0/16 → local0.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-1a 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.examplewill fire in less than a week. Your options:
- A single NAT gateway for testing (≈ 35 USD/month): create only
nat-mercadofresco-aand 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 tableThe 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=operacionesOperational 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-accessibleThe
--no-publicly-accessiblematters just as much as the subnet group: even though the subnet has no route to the internet, thePubliclyAccessibleattribute set totruewould 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 tableCommon 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.0 – 10.5.3.255 |
1,019 |
| public-b | eu-west-1b |
10.5.4.0/22 |
10.5.4.0 – 10.5.7.255 |
1,019 |
| public-c | eu-west-1c |
10.5.8.0/22 |
10.5.8.0 – 10.5.11.255 |
1,019 |
| private-a | eu-west-1a |
10.5.16.0/20 |
10.5.16.0 – 10.5.31.255 |
4,091 |
| private-b | eu-west-1b |
10.5.32.0/20 |
10.5.32.0 – 10.5.47.255 |
4,091 |
| private-c | eu-west-1c |
10.5.48.0/20 |
10.5.48.0 – 10.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 tableWhat 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 textWhat 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.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
