Skip to content

Phase 3 · CLOUD

Project 2, Day 2: network & IAM groundwork

Day 63 of 90 ~60 min 0/20 in phase Builds on Day 62

By the end of today

  • Build a VPC with public and private subnets across two Availability Zones
  • Create the ECS task execution role and a separate task role
  • Scope ALB, app, and RDS security groups so traffic flows one way

The VPC, subnets, and the roles that let Fargate run

Section 1 of 5 · ~3 min

Day 1 you drew the target architecture on paper. Today you build its foundation in us-east-1: the network every other resource will land in, the IAM roles Fargate assumes, and the security groups that wall the pieces off from each other. Nothing here is billable — you deliberately defer the one part that costs money.

A VPC (Virtual Private Cloud) is your own private slice of the AWS network — a 10.0.0.0/16 address space nothing outside can route into unless you allow it. Inside it you carve subnets, each a smaller CIDR pinned to one Availability Zone (a physically separate datacentre). You build four, across two AZs, so a single zone failure never takes the whole app down:

  • Two public subnets — they have a route to an internet gateway (IGW), so resources in them can get a public IP and be reached from the internet. The load balancer lives here.
  • Two private subnets — no route to the IGW. The Fargate tasks and the RDS database live here, unreachable directly from the internet. That is the whole point: your database should never have a public address.

What makes a subnet “public” is not a setting — it is its route table, a list of “for this destination, send traffic here” rules. The public route table carries a 0.0.0.0/0 → igw route; the private one does not. A private subnet that needs outbound internet (to pull an image from ECR, say) reaches it through a NAT gateway in a public subnet — one-way egress, no inbound. The NAT gateway is the only piece here that bills, so you defer creating it to Day 3, when the Fargate service actually needs to pull.

Real world: think of the VPC as a gated office campus. Public subnets are ground-floor lobbies with a door onto the street (the IGW); private subnets are upper floors reachable only from inside. Staff on a private floor can still receive deliveries — but only through the loading dock (the NAT gateway), never a public door of their own.

Capital One, which runs its banking platform almost entirely on AWS, places workloads in private subnets behind load balancers exactly this way — app servers and databases with no public address, reachable only through a tightly controlled front door.

Roles and security groups

Fargate needs two IAM roles. The task execution role is used by the ECS agent to pull the image from ECR and ship logs to CloudWatch — it carries AmazonECSTaskExecutionRolePolicy. The task role is assumed by your application code for whatever AWS APIs the app calls (S3, Secrets Manager). Keeping them separate is least privilege: the platform’s plumbing and your app’s permissions never share a credential.

Finally, three security groups, each a stateful allow-list, chained so traffic flows one way only:

  • ALB SG — allow 80/443 from the internet.
  • App SG — allow the container port (8000) from the ALB SG only.
  • RDS SG — allow 5432 from the App SG only.

Referencing one group from another — not a CIDR — means each rule follows the resource, so it still holds as tasks scale and get new IPs.

A VPC spanning two Availability Zones. An internet gateway feeds two public subnets holding the ALB; two private subnets hold the Fargate tasks and RDS. A NAT gateway (deferred to Day 3) would give the private subnets outbound-only internet. VPC 10.0.0.0/16 IGW AZ us-east-1a AZ us-east-1b public 10.0.0.0/24 ALB · NAT (Day 3) public 10.0.1.0/24 ALB private 10.0.10.0/24 Fargate task · RDS private 10.0.11.0/24 Fargate task · RDS dashed = outbound via NAT, added Day 3
Two AZs, two public + two private subnets. The ALB sits public; tasks and RDS stay private. The NAT path (dashed) waits for Day 3.

Today you build all of that — free — and stop just short of the NAT gateway.

Hands-On Lab

Section 2 of 5 · ~5 min

Budget about 30 minutes. You’ll drive this from the AWS CLI v2 as your IAM user (not root), in us-east-1, building on the plan you drew on Day 1. Type every command and read the output before moving on. VPC, subnet, gateway, role and security-group IDs are unique to your account — yours will differ from the samples.

What this costs: $0 today — as long as you don’t create the NAT gateway. A VPC, its subnets, the internet gateway, route tables, security groups, and IAM roles are all free; they cost nothing to sit there. The one billable piece in this design is the NAT gateway a private subnet needs for outbound internet — it runs about $32–45/month (roughly $0.045/hr in us-east-1) plus ~$0.045 per GB processed, and it bills the moment you create it, whether traffic flows or not. So you build everything free today and defer the NAT to Day 3, when the Fargate service actually needs to pull its image. Cheaper still: VPC endpoints for ECR, S3 and CloudWatch skip the NAT entirely (Go Deeper). Nothing here needs tearing down — you keep it all for tomorrow; the full teardown is Day 4.

# 1. Confirm you're your IAM user (NOT root) and set a default region.
aws sts get-caller-identity
export AWS_DEFAULT_REGION=us-east-1
# Output (your Account and Arn will differ):
# {
#     "UserId": "AIDA...EXAMPLE",
#     "Account": "123456789012",
#     "Arn": "arn:aws:iam::123456789012:user/devops-you"
# }
# 2. Create the VPC (10.0.0.0/16) and turn on DNS so ECS and RDS get resolvable names.
VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=linkstash-vpc}]' \
  --query 'Vpc.VpcId' --output text)
aws ec2 modify-vpc-attribute --vpc-id "$VPC" --enable-dns-hostnames
echo "$VPC"
# Output (your VPC ID will differ):
# vpc-0abc123def4567890
# 3. Create an internet gateway and attach it to the VPC.
IGW=$(aws ec2 create-internet-gateway \
  --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW" --vpc-id "$VPC"
echo "$IGW"
# Output (yours will differ):
# igw-0aa11bb22cc33dd44
# 4. Carve four subnets: two public, two private, spread across two AZs.
PUB_A=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.0.0/24 \
  --availability-zone us-east-1a --query 'Subnet.SubnetId' --output text)
PUB_B=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.1.0/24 \
  --availability-zone us-east-1b --query 'Subnet.SubnetId' --output text)
PRIV_A=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.10.0/24 \
  --availability-zone us-east-1a --query 'Subnet.SubnetId' --output text)
PRIV_B=$(aws ec2 create-subnet --vpc-id "$VPC" --cidr-block 10.0.11.0/24 \
  --availability-zone us-east-1b --query 'Subnet.SubnetId' --output text)
echo "public: $PUB_A $PUB_B  private: $PRIV_A $PRIV_B"
# Output (four subnet IDs — yours will differ):
# public: subnet-0aaa111 subnet-0bbb222  private: subnet-0ccc333 subnet-0ddd444
# 5. A public route table with a default route to the IGW; associate both public subnets.
RT_PUB=$(aws ec2 create-route-table --vpc-id "$VPC" \
  --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id "$RT_PUB" \
  --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW" >/dev/null
aws ec2 associate-route-table --route-table-id "$RT_PUB" --subnet-id "$PUB_A" >/dev/null
aws ec2 associate-route-table --route-table-id "$RT_PUB" --subnet-id "$PUB_B" >/dev/null
echo "$RT_PUB routes 0.0.0.0/0 -> $IGW"
# Output (yours will differ):
# rtb-0pub111222333 routes 0.0.0.0/0 -> igw-0aa11bb22cc33dd44
# 6. A private route table — associate both private subnets, but add NO internet route yet.
#    The 0.0.0.0/0 -> NAT route waits for Day 3, when the Fargate service needs to pull.
RT_PRIV=$(aws ec2 create-route-table --vpc-id "$VPC" \
  --query 'RouteTable.RouteTableId' --output text)
aws ec2 associate-route-table --route-table-id "$RT_PRIV" --subnet-id "$PRIV_A" >/dev/null
aws ec2 associate-route-table --route-table-id "$RT_PRIV" --subnet-id "$PRIV_B" >/dev/null
echo "$RT_PRIV (local-only for now — no NAT, no bill)"
# Output (yours will differ):
# rtb-0priv444555666 (local-only for now — no NAT, no bill)
# 7. The ECS task EXECUTION role (used by the platform to pull the image and write logs).
#    If Day 59 already made it, the create errors harmlessly — you reuse the existing one.
cat > ecs-trust.json <<'EOF'
{ "Version": "2012-10-17",
  "Statement": [ { "Effect": "Allow",
    "Principal": { "Service": "ecs-tasks.amazonaws.com" },
    "Action": "sts:AssumeRole" } ] }
EOF
aws iam create-role --role-name ecsTaskExecutionRole \
  --assume-role-policy-document file://ecs-trust.json >/dev/null 2>&1 || true
aws iam attach-role-policy --role-name ecsTaskExecutionRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
aws iam get-role --role-name ecsTaskExecutionRole --query 'Role.Arn' --output text
# Output (your account ID will differ):
# arn:aws:iam::123456789012:role/ecsTaskExecutionRole
# 8. A separate TASK role, assumed by linkstash's own code. No policy yet — least privilege.
aws iam create-role --role-name linkstash-task-role \
  --assume-role-policy-document file://ecs-trust.json \
  --query 'Role.Arn' --output text
# Output (your account ID will differ):
# arn:aws:iam::123456789012:role/linkstash-task-role
# 9. Three security groups in the VPC — one each for the ALB, the app, and the database.
ALB_SG=$(aws ec2 create-security-group --group-name linkstash-alb-sg \
  --description "public ALB" --vpc-id "$VPC" --query 'GroupId' --output text)
APP_SG=$(aws ec2 create-security-group --group-name linkstash-app-sg \
  --description "Fargate tasks" --vpc-id "$VPC" --query 'GroupId' --output text)
RDS_SG=$(aws ec2 create-security-group --group-name linkstash-rds-sg \
  --description "Postgres" --vpc-id "$VPC" --query 'GroupId' --output text)
echo "alb=$ALB_SG app=$APP_SG rds=$RDS_SG"
# Output (three group IDs — yours will differ):
# alb=sg-0alb111 app=sg-0app222 rds=sg-0rds333
# 10. Chain the rules one way: internet -> ALB, ALB -> app :8000, app -> RDS :5432.
aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" \
  --protocol tcp --port 80  --cidr 0.0.0.0/0 >/dev/null
aws ec2 authorize-security-group-ingress --group-id "$ALB_SG" \
  --protocol tcp --port 443 --cidr 0.0.0.0/0 >/dev/null
aws ec2 authorize-security-group-ingress --group-id "$APP_SG" \
  --ip-permissions "IpProtocol=tcp,FromPort=8000,ToPort=8000,UserIdGroupPairs=[{GroupId=$ALB_SG}]" >/dev/null
aws ec2 authorize-security-group-ingress --group-id "$RDS_SG" \
  --ip-permissions "IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=$APP_SG}]" >/dev/null
echo "chained: ALB<-internet(80,443), app<-ALB(8000), rds<-app(5432)"
# Output:
# chained: ALB<-internet(80,443), app<-ALB(8000), rds<-app(5432)
# 11. Verify the foundation: four subnets in two AZs, and confirm no NAT is billing.
aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC" \
  --query 'Subnets[].{az:AvailabilityZone,cidr:CidrBlock,id:SubnetId}' --output table
echo "no NAT gateway created -> today's spend is \$0"
# Output (four rows; IDs yours):
# --------------------------------------------------------
# |                    DescribeSubnets                    |
# +--------------+----------------+-----------------------+
# |     az       |     cidr       |          id           |
# +--------------+----------------+-----------------------+
# |  us-east-1a  |  10.0.0.0/24   |  subnet-0aaa111       |
# |  us-east-1b  |  10.0.1.0/24   |  subnet-0bbb222       |
# |  us-east-1a  |  10.0.10.0/24  |  subnet-0ccc333       |
# |  us-east-1b  |  10.0.11.0/24  |  subnet-0ddd444       |
# +--------------+----------------+-----------------------+
# no NAT gateway created -> today's spend is $0

Read the last output back: four subnets across two AZs, and a security-group chain where the only public door is the ALB’s. There is nothing to tear down today — every resource you made is free, and Day 3 builds directly on it. The one thing you did not create, on purpose, is the NAT gateway: it is the only meter running in this design, so it waits until the Fargate service actually needs to reach ECR.

Common Errors & Fixes

Section 3 of 5 · ~2 min

These three catch almost everyone laying down a VPC and its roles for the first time. Read the error text slowly — parsing it is the actual skill.

Common error: create-role for the execution role fails because Day 59 already created it:

An error occurred (EntityAlreadyExists) when calling the CreateRole operation: Role with name ecsTaskExecutionRole already exists.

Why: IAM roles are account-global — not per-VPC, not per-region. If an earlier AWS lab (Day 59’s Fargate task) already created ecsTaskExecutionRole, creating it again collides with the existing one. This is expected here, which is why lab step 7 swallows the create with || true and then reads the ARN back.

Fix: Reuse it — don’t recreate. aws iam get-role --role-name ecsTaskExecutionRole --query 'Role.Arn' --output text returns the ARN of the existing role, and attach-role-policy is idempotent, so re-attaching the managed policy is harmless.

How you’d spot it in prod: EntityAlreadyExists on any IAM create means the entity is account-wide and already there — usually made by another stack or teammate. Import or reference it; don’t fork a second copy with a slightly different name.

Common error: The second or third create-subnet fails because two subnets try to claim overlapping address space:

An error occurred (InvalidSubnet.Conflict) when calling the CreateSubnet operation: The CIDR '10.0.0.0/24' conflicts with another subnet

Why: Every subnet in a VPC must own a distinct, non-overlapping CIDR inside the VPC’s range. Re-running lab step 4, or copy-pasting the same --cidr-block twice, tries to hand the same /24 to two subnets — and AWS refuses.

Fix: Give each subnet its own block within 10.0.0.0/16 (the lab uses .0, .1, .10, .11). Before recreating, list what already exists: aws ec2 describe-subnets --filters "Name=vpc-id,Values=$VPC" --query 'Subnets[].CidrBlock', and delete any half-made subnet first.

How you’d spot it in prod: InvalidSubnet.Conflict is always CIDR math, never permissions. Sketch the address plan before running anything — overlapping ranges are the classic result of two people editing the same VPC without agreeing on the layout.

Common error: Adding the app or RDS ingress rule fails because it references a security group that isn’t there:

An error occurred (InvalidGroup.NotFound) when calling the AuthorizeSecurityGroupIngress operation: The security group 'sg-0app222' does not exist

Why: The rules reference other groups by ID (UserIdGroupPairs), not by CIDR — that’s what makes the chain tight. If a create-security-group step was skipped, its output wasn’t captured into the variable, or the groups are created in the wrong order, the referenced group ID is empty or wrong when the rule runs.

Fix: Create all three groups first and capture each GroupId into its variable (lab step 9), then add the referencing rules (step 10). Confirm the IDs are set with echo "$ALB_SG $APP_SG $RDS_SG" before authorizing.

How you’d spot it in prod: InvalidGroup.NotFound on an ingress rule usually means a referenced group lives in a different VPC or region, or wasn’t created yet — security-group references can’t cross VPCs. Check the referenced group’s VPC before suspecting a typo.

VPC and IAM Interview Questions

Section 4 of 5 · ~1 min

Cover the answers below and say your own version out loud first — name what makes a subnet public, and what the execution role does that the task role doesn’t, before you reveal each answer. Recalling before revealing is what makes these stick when an interviewer asks them cold. The four questions and answers render right after this note.

Go Deeper

Section 5 of 5 · ~1 min

Optional extras if you have ~30 more minutes today:

  • 10 min — Read the VPC endpoints doc: a gateway endpoint for S3 plus interface endpoints for ECR and CloudWatch Logs let private tasks reach those services with no NAT gateway at all — the cheapest way to keep the NAT bill off this project.
  • 10 min — Read the VPC route tables guide, then run aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC" and trace which subnet each table serves — the public one has the IGW route, the private one doesn’t (yet).
  • 5 min — Skim IAM roles for tasks to see exactly which actions the execution role needs versus the task role — the clearest statement of why they’re two roles, not one.
  • 5 min — Open the VPC Reachability Analyzer (or just re-read your step-10 rules) and confirm the internet → ALB → app → RDS chain holds, and that nothing lets the internet reach RDS directly.
What is the difference between a public and a private subnet in a VPC? Both

The difference is one route. A public subnet's route table has a 0.0.0.0/0 route to an internet gateway, so resources in it can hold a public IP and be reached from the internet — that's where a load balancer goes. A private subnet has no such route, so nothing in it is directly reachable from outside; that's where I put app servers and the database. A private subnet can still reach out — to pull an image or hit an API — through a NAT gateway sitting in a public subnet, which allows outbound only. So 'public' and 'private' aren't a checkbox on the subnet; they're a property of the route table attached to it.

What is the difference between the ECS task execution role and the task role? Product

Both are IAM roles a Fargate task uses, but for different actors. The task execution role is assumed by the ECS agent — the platform — to pull the container image from ECR, fetch secrets, and write logs to CloudWatch; it carries AmazonECSTaskExecutionRolePolicy. The task role is assumed by my application code inside the container, for the AWS APIs the app itself calls — reading an S3 bucket, say, or Secrets Manager. Keeping them separate is least privilege: the plumbing that starts the container and my app's own permissions never share a credential. A common mistake is putting an app's S3 permission on the execution role — it works, but now the platform role is over-privileged.

Why does a private subnet need a NAT gateway, and what are the cheaper alternatives? Both

A private subnet has no route to the internet gateway, so a task in it can't pull an image from ECR or reach a public API on its own. A NAT gateway, placed in a public subnet, gives it outbound-only internet: the task starts connections out, replies come back, but nothing outside can start a connection in. The catch is cost — a NAT gateway bills around $32–45/month plus per-GB data, running whether or not traffic flows. Cheaper alternatives: VPC endpoints (a gateway endpoint for S3, interface endpoints for ECR and CloudWatch) let tasks reach those AWS services privately with no NAT at all; or share a single NAT across AZs, trading some resilience for cost.

How do you scope security groups for a load-balanced app talking to a database? Both

I chain them by referencing security groups, not IP ranges. The ALB's security group allows 80 and 443 from 0.0.0.0/0 — it's meant to be public. The app's security group allows the container port only from the ALB's security group, so nothing but the load balancer can reach the tasks. The database's security group allows 5432 only from the app's security group. Referencing a group instead of a CIDR means the rule follows the resource: as tasks scale and get new IPs, the rule still holds, because it names the group they belong to. The result is a one-way chain — internet → ALB → app → database — with no step opened wider than it needs.

Mark Day 63 complete

Tomorrow you deploy linkstash itself — push its image to ECR, stand up RDS Postgres in the private subnets, and run the container on Fargate behind an Application Load Balancer.

Stuck on today’s lab? Ask in Mission 90 Q&A