You add a todo · EC2 + DynamoDB
12 steps from your browser to the database and back. Then a background job finishes the work.
Text version of this diagram.
What sits where:
- AWS Region · us-east-1: A Region is a part of the world where AWS has data centers. Most things you build live in one Region. us-east-1 is in northern Virginia. contains:
- VPC: Virtual private cloud: your own private network inside AWS. Nothing gets in or out except the ways you allow. contains:
- Public subnet · to IGW: A slice of the VPC that can reach the internet directly, through the internet gateway (IGW). contains:
- EC2 instance (runs the web app): A virtual server you rent from AWS. You pick its size and operating system, and you look after it.
- Internet gateway (the VPC's door): The door between a VPC and the internet. Without one, nothing in the VPC can talk to the internet directly.
- Public subnet · to IGW: A slice of the VPC that can reach the internet directly, through the internet gateway (IGW). contains:
- API Gateway (WebSocket API · live line): The front door for the app's web requests. A WebSocket API keeps a connection open, so the server can push updates to your browser instantly.
- CloudWatch Logs (app log lines): Collects the lines your app writes about what it's doing (its logs), so you can search them later.
- DynamoDB (Todo.io's tables): AWS's serverless NoSQL database: you store and fetch items by key, and it scales by itself with no server to manage.
- Worker Lambda (updates monthly stats): A second Lambda function that does slow work in the background, taking jobs off the queue.
- SQS queue (todo-events): Simple Queue Service: a waiting line for messages. One part of the app drops a job in; another picks it up later.
- Dead-letter queue (todo-events-dlq): A dead-letter queue: where a message goes after failing several times, so someone can look at it instead of it being retried forever.
- VPC: Virtual private cloud: your own private network inside AWS. Nothing gets in or out except the ways you allow. contains:
- Your browser (on the internet)
- IAM (global · who can do what): Identity and Access Management: decides who (and which app) may do what in AWS. It's global, the same in every Region.
Steps, in order:
- The live line is already open. Path: Your browser → API Gateway. When you logged in, your browser opened a WebSocket to API Gateway. It stays open, so AWS can send you messages any time.
- You add a todo. Path: Your browser. Your browser sends POST /todos over HTTPS.
- Into the VPC. Path: Your browser → Internet gateway. todo.io points at the instance's public IP, so the request comes in through the VPC's internet gateway. The public IP changes if the instance is stopped and started (an Elastic IP stays put), so the DNS record has to follow it. In Level 2 todo.io points at a load balancer's DNS name instead, which never changes.
- Through the firewall. Path: Internet gateway → EC2 instance. The security group around the instance allows HTTPS on port 443, so the request reaches the web server. Checks: security-group: todo-app-sg: TCP 443 from 0.0.0.0/0 → allow.
- Who are you?. Path: EC2 instance. The app checks your signed session cookie and finds your user id, u-123. This is the app's own login, not AWS.
- Get AWS credentials. Path: EC2 instance. The app gets temporary credentials for its IAM role (the instance profile) from the instance metadata service, which lives on the host itself. No keys are stored in the code.
- Call DynamoDB. Path: EC2 instance → Internet gateway → DynamoDB. The app calls PutItem on the DynamoDB API over HTTPS, signed with those credentials. It leaves through the internet gateway because DynamoDB is a public AWS service, not something inside your VPC.
- Is this allowed?. Path: DynamoDB → IAM. DynamoDB checks with IAM: may todo-app-ec2-role do dynamodb:PutItem here? The policy on the role allows it on the table's ARN. Checks: iam: todo-app-ec2-role: dynamodb:PutItem on table/Todos → allow.
- DynamoDB answers. Path: DynamoDB → Internet gateway → EC2 instance. DynamoDB confirms the item is saved.
- Drop a job in the queue. Path: EC2 instance → Internet gateway → SQS queue. The app sends a message to an SQS queue saying a todo was created, instead of doing the slow work now. Checks: iam: todo-app-ec2-role: sqs:SendMessage on todo-events → allow.
- Back to you. Path: EC2 instance → Internet gateway → Your browser. You get 201 Created, and "Buy milk" appears in your list.
- Leave a note. Path: EC2 instance → Internet gateway → CloudWatch Logs. The app's log line goes to CloudWatch Logs so Todo.io's engineers can see what happened. Like SQS, it's a public AWS service, so it leaves through the internet gateway.
- The worker picks it up. Path: SQS queue → Worker Lambda. Lambda keeps polling the queue for you and runs the worker function with the new message. Checks: iam: todo-worker-role: sqs:ReceiveMessage, sqs:DeleteMessage → allow.
- Save the result. Path: Worker Lambda → DynamoDB. The worker adds 1 to your monthly stats in DynamoDB, using its own role. Checks: iam: todo-worker-role: dynamodb:UpdateItem on table/Stats → allow.
- Tell your browser. Path: Worker Lambda → API Gateway. The worker looks up your connection id in the database and posts a message to it through the WebSocket API. Checks: iam: todo-worker-role: execute-api:ManageConnections → allow.
- It shows up instantly. Path: API Gateway → Your browser. Your "12 done this month" badge updates on screen, with no refresh.
- Job done. Path: Worker Lambda → SQS queue. The worker returns without an error, so the Lambda service deletes the message from the queue. Your code never deletes it.
- If the worker keeps failing. Path: SQS queue → Dead-letter queue. If the worker fails on the same message 3 times, SQS moves it to a dead-letter queue. It isn't lost, and it stops being retried forever.
When you logged in, your browser opened a WebSocket to API Gateway. It stays open, so AWS can send you messages any time.
Show me the real thing
The WebSocket handshake
GET /prod HTTP/1.1 Host: ws123.execute-api.us-east-1.amazonaws.com Upgrade: websocket Connection: Upgrade → API Gateway assigns connection id Ab3xYzEXAMPLE=Exam tipAn API Gateway WebSocket API has $connect, $disconnect and $default routes. Each one can call Lambda or an HTTP backend.
Your browser sends
POST /todosover HTTPS.Show me the real thing
The HTTP request
POST /todos HTTP/1.1 Host: todo.io Cookie: session=eyJ1c2VySWQiOiJ1LTEyMyJ9.Zm9v Content-Type: application/json {"title":"Buy milk"}todo.io points at the instance's public IP, so the request comes in through the VPC's internet gateway. The public IP changes if the instance is stopped and started (an Elastic IP stays put), so the DNS record has to follow it. In Level 2 todo.io points at a load balancer's DNS name instead, which never changes.
Exam tipA subnet is "public" only because its route table sends 0.0.0.0/0 to an internet gateway. Nothing else makes it public.
The security group around the instance allows HTTPS on port 443, so the request reaches the web server.
- 🛡Security group todo-app-sg: TCP 443 from 0.0.0.0/0Allowed
Show me the real thing
Inbound rules on todo-app-sg
Type Protocol Port Source HTTPS TCP 443 0.0.0.0/0Exam tipThe classic way to do HTTPS is a free AWS Certificate Manager (ACM) certificate on a load balancer or CloudFront (Level 2). Since 2025 ACM can also sell you an exportable certificate to install on the instance or container itself.
The app checks your signed session cookie and finds your user id,
u-123. This is the app's own login, not AWS.The app gets temporary credentials for its IAM role (the instance profile) from the instance metadata service, which lives on the host itself. No keys are stored in the code.
Show me the real thing
Trust policy on todo-app-ec2-role: who may use this role
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] }Exam tipNever store access keys on an instance. Attach a role through an instance profile; the SDK fetches its credentials from 169.254.169.254.
The app calls
PutItemon the DynamoDB API over HTTPS, signed with those credentials. It leaves through the internet gateway because DynamoDB is a public AWS service, not something inside your VPC.Show me the real thing
The PutItem request body
{ "TableName": "Todos", "Item": { "userId": { "S": "u-123" }, "todoId": { "S": "42" }, "title": { "S": "Buy milk" }, "done": { "BOOL": false } } }Exam tipA VPC gateway endpoint (Level 1) keeps this traffic off the internet, and it costs nothing.
DynamoDB checks with IAM: may
todo-app-ec2-roledodynamodb:PutItemhere? The policy on the role allows it on the table's ARN.- 🔑IAM todo-app-ec2-role: dynamodb:PutItem on table/TodosAllowed
Show me the real thing
Permissions policy on todo-app-ec2-role
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem" ], "Resource": [ "arn:aws:dynamodb:us-east-1:111122223333:table/Users", "arn:aws:dynamodb:us-east-1:111122223333:table/Todos" ] } ] }Exam tipIAM starts from deny. A request is only allowed if some policy explicitly allows it and nothing explicitly denies it.
DynamoDB confirms the item is saved.
The app sends a message to an SQS queue saying a todo was created, instead of doing the slow work now.
- 🔑IAM todo-app-ec2-role: sqs:SendMessage on todo-eventsAllowed
Show me the real thing
SendMessage request
{ "QueueUrl": "https://sqs.us-east-1.amazonaws.com/111122223333/todo-events", "MessageBody": "{\"type\":\"todo.created\",\"userId\":\"u-123\",\"todoId\":\"42\"}" }Exam tipA queue decouples the app from the worker. If the worker is slow or broken, messages simply wait (up to 14 days).
You get
201 Created, and "Buy milk" appears in your list.Show me the real thing
The HTTP response
HTTP/1.1 201 CreatedExam tipSecurity groups are stateful: a reply to an allowed request goes back out automatically. No outbound rule needed for it.
The app's log line goes to CloudWatch Logs so Todo.io's engineers can see what happened. Like SQS, it's a public AWS service, so it leaves through the internet gateway.
Show me the real thing
The log line
2026-10-11T18:42:07Z INFO POST /todos user=u-123 status=201 14msExam tipEC2 doesn't send logs by itself. You install the CloudWatch agent and give the instance role permission to write logs.
Lambda keeps polling the queue for you and runs the worker function with the new message.
- 🔑IAM todo-worker-role: sqs:ReceiveMessage, sqs:DeleteMessageAllowed
Show me the real thing
The event the worker receives (trimmed)
{ "Records": [ { "messageId": "19dd0b57-b21e-4ac1", "eventSource": "aws:sqs", "eventSourceARN": "arn:aws:sqs:us-east-1:111122223333:todo-events", "body": "{\"type\":\"todo.created\",\"userId\":\"u-123\",\"todoId\":\"42\"}" } ] }Exam tipSQS never pushes. Lambda polls it for you (an "event source mapping"). Set the queue's visibility timeout to at least 6× the function's timeout.
The worker adds 1 to your monthly stats in DynamoDB, using its own role.
- 🔑IAM todo-worker-role: dynamodb:UpdateItem on table/StatsAllowed
Show me the real thing
The UpdateItem request body
{ "TableName": "Stats", "Key": { "userId": { "S": "u-123" }, "month": { "S": "2026-10" } }, "UpdateExpression": "ADD created :one", "ExpressionAttributeValues": { ":one": { "N": "1" } } }Exam tipStandard queues deliver at least once, so the same message can arrive twice and count your todo twice. Real workers are idempotent: they record each message id and skip repeats.
The worker looks up your connection id in the database and posts a message to it through the WebSocket API.
- 🔑IAM todo-worker-role: execute-api:ManageConnectionsAllowed
Show me the real thing
Posting to a connection
POST /prod/@connections/Ab3xYzEXAMPLE= HTTP/1.1 Host: ws123.execute-api.us-east-1.amazonaws.com {"type":"stats.updated","month":"2026-10","created":5,"completed":12}Exam tipYour browser never asked for this. Anything with permission can push to a connection id through the @connections API.
Your "12 done this month" badge updates on screen, with no refresh.
The worker returns without an error, so the Lambda service deletes the message from the queue. Your code never deletes it.
Exam tipIf the worker throws, the message isn't deleted. It becomes visible again after the visibility timeout and is retried.
If the worker fails on the same message 3 times, SQS moves it to a dead-letter queue. It isn't lost, and it stops being retried forever.
Show me the real thing
Redrive policy on todo-events
{ "deadLetterTargetArn": "arn:aws:sqs:us-east-1:111122223333:todo-events-dlq", "maxReceiveCount": 3 }Exam tipA dead-letter queue must be the same type as its source queue: a FIFO queue needs a FIFO DLQ.
What to notice
- You run the server. Patching, scaling and the HTTPS certificate are your job. One instance in one Availability Zone is a single point of failure (Level 2 adds a load balancer and a second AZ).
- IAM approved every database call, using a role. There's no database password anywhere.
- DynamoDB isn't in your VPC. It's a public AWS service called over HTTPS.
- You got your answer before the job ran. The queue lets slow work happen later without making you wait.
- If the worker breaks, messages wait in the queue, and after 3 failures they land in the DLQ. Nothing is lost.
- A WebSocket stays open, so the server side can send you updates without you asking.
- You, the customer, were never an IAM user. IAM controls the app, not its customers.