No service catalog to memorize. We take MiniChat, an AI chat app, and put it live on AWS one need at a time. Each service earns its place by solving a real problem, and every idea starts with a real-world picture.
A Region (like us-east-1, N. Virginia) is a geographic area. Inside it are 3+ Availability Zones, each an isolated datacenter miles apart but linked by fast fiber. You pick a Region close to your users and spread across its AZs for resilience.
Shared Responsibility: Amazon secures the buildings, hardware, and hypervisor (security of the cloud). You secure your data, access, and config (security in the cloud). Most breaches are the second half, a misconfigured bucket, not a hacked datacenter.
Now the words: a Region is a named geographic area; an Availability Zone (AZ) is one or more discrete datacenters within it. Edge locations (used by CloudFront) are a separate, much larger set of points-of-presence for caching. Multi-AZ = highly available; multi-Region = disaster recovery.
A policy is the list of what's allowed (JSON). A user is a human with long-lived credentials. A role is a set of permissions that a service or person temporarily assumes, getting short-lived keys that auto-expire. Give MiniChat's Lambda a role, never a user password.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/Messages"
},{
"Effect": "Allow",
"Action": "bedrock:InvokeModel",
"Resource": "arn:aws:bedrock:*::foundation-model/anthropic.claude-*"
}]
}
"Action": "*" on "Resource": "*", that is the master key you swore not to hand out.Now the words: the JSON is an IAM policy. When Lambda "borrows the keycard" it is assuming a role via STS, which returns temporary credentials. Least privilege means grant only the exact actions and resources needed. Policies can be identity-based (attached to who) or resource-based (attached to what, like a bucket policy).
For MiniChat's bursty, event-driven API we'll pick Lambda: no servers to manage, pay per request, scales to zero. If we later needed a long-running websocket server or a big custom runtime, we'd reach for Fargate (containers, no server management) or EC2 (full VM control).
| Option | You manage | Billing | Best when |
|---|---|---|---|
| EC2 (virtual machine) | OS, patching, scaling, capacity | Per second the instance runs (idle still costs) | Full control, legacy apps, GPUs, steady load |
| Lambda (functions) | Just your code | Per request + ms of compute, scales to zero | Bursty, event-driven, short jobs (≤15 min) |
| ECS/Fargate (containers) | The container image only | Per vCPU/GB while tasks run | Containers, long-running services, no server ops |
Now the words: EC2 = Elastic Compute Cloud (rented VMs called instances). Lambda = Function as a Service (FaaS), serverless. ECS = Elastic Container Service (orchestrator); Fargate = the serverless engine that runs ECS/EKS tasks without you managing EC2. EKS = managed Kubernetes if you specifically want k8s.
You write a handler function. AWS runs it in a fresh micro-container triggered by an event (an HTTP call, a queue message, a file upload). You set its memory (which also scales CPU) and a timeout. Each concurrent request gets its own copy, that's concurrency.
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime"; const bedrock = new BedrockRuntimeClient({}); // reused across warm invokes export const handler = async (event) => { // the entry point const { message } = JSON.parse(event.body); const out = await bedrock.send(new ConverseCommand({ modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0", messages: [{ role: "user", content: [{ text: message }] }] })); return { statusCode: 200, body: JSON.stringify({ reply: out.output.message.content[0].text }) }; };
Now the words: the invoked function is a Lambda; its trigger is an event source; the number of simultaneous copies is concurrency (default account cap ~1000, adjustable). Reusing globals between invokes is execution context reuse.
Put API Gateway in front of Lambda. It gives you a public HTTPS endpoint, maps POST /chat to your function, and handles auth, rate limiting, and CORS so the function only ever sees clean, allowed requests.
Two flavors: HTTP API (cheaper, faster, fewer features, great default for Lambda) and REST API (older, more knobs like request validation and API keys). For MiniChat, HTTP API is the right call.
Now the words: the mapping from a route to Lambda is an integration. Auth can be a Lambda authorizer or a Cognito / JWT authorizer. Throttling caps requests per second; usage plans + API keys meter per client. CORS config lets your S3-hosted frontend call the API.
S3 stores objects (files) in buckets, addressed by a key. It's not a disk you mount; it's an HTTP key/value store for blobs. Point S3 static hosting (fronted by CloudFront) at MiniChat's build output and the frontend is served, no server required.
Presigned URLs solve uploads securely: your backend signs a short-lived URL that lets the browser PUT one file straight to S3, without your credentials ever touching the client, and without proxying big files through Lambda.
// backend hands the browser a 5-min upload link const url = await getSignedUrl(s3, new PutObjectCommand({ Bucket: "minichat-uploads", Key: `user/${id}/pic.png` }), { expiresIn: 300 });
| Storage class | Use for |
|---|---|
| S3 Standard | Hot files served often (the web app, recent uploads) |
| S3 Intelligent-Tiering | Unknown/variable access, auto-moves to save cost |
| Standard-IA / One Zone-IA | Infrequent access, cheaper storage, retrieval fee |
| Glacier / Deep Archive | Backups, compliance, minutes-to-hours to restore |
Now the words: S3 = Simple Storage Service. Buckets are globally unique names; objects have keys. Modern S3 gives strong read-after-write consistency automatically. Block Public Access is on by default, serve the site via CloudFront + OAC, not a public bucket.
DynamoDB is a managed NoSQL key/value + document store. You design around your access pattern: pick a partition key that spreads data evenly and a sort key that gives you free ordering and range queries. For MiniChat: PK = CONV#42, SK = TS#2026-07-02T10:00.
Query jumps straight to one partition (fast, cheap). Scan reads the entire table (slow, expensive), avoid it in hot paths. A bad partition key (e.g. everyone in one drawer) creates a hot partition.Now the words: partition key + sort key = the primary key. Want another access pattern? Add a Global Secondary Index (GSI). Cramming many entity types (users, messages, convos) into one table with clever key prefixes is single-table design. Billing is on-demand or provisioned capacity.
RDS is managed relational databases (PostgreSQL, MySQL, etc.), AWS handles backups, patching, failover. Aurora is AWS's cloud-native, faster, auto-scaling flavor. Add the pgvector extension to Postgres and it can store embedding vectors and run similarity search with plain SQL.
-- pgvector: find the 3 most similar chunks SELECT chunk, 1 - (embedding <=> :q_vec) AS similarity FROM handbook ORDER BY embedding <=> :q_vec -- <=> = cosine distance LIMIT 3;
Now the words: an embedding is a list of numbers representing meaning. RAG = Retrieval-Augmented Generation: fetch relevant chunks, stuff them into the prompt, then let the model answer grounded in them. Aurora Serverless v2 scales capacity automatically. Alternatives: OpenSearch or Bedrock Knowledge Bases (next section) for a fully managed vector store.
Amazon Bedrock is one API to reach many foundation models (including Anthropic's Claude). No servers, your prompts aren't used to train the base models, and you pick a model by id. Three ways to call it, from oldest to what you should use:
| API | What it does | Use when |
|---|---|---|
| InvokeModel | Raw request/response in the model's own JSON shape | Low-level or non-chat models (e.g. embeddings) |
| Converse | One unified chat schema across all models, full reply at once | Standard chat, easy model swapping |
| ConverseStream | Same, but streams tokens as they generate | MiniChat, so words appear live |
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime"; const client = new BedrockRuntimeClient({ region: "us-east-1" }); const res = await client.send(new ConverseCommand({ modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0", system: [{ text: "You are MiniChat, concise and friendly." }], messages: [{ role: "user", content: [{ text: "What are embeddings?" }] }], inferenceConfig: { maxTokens: 512, temperature: 0.7 } })); console.log(res.output.message.content[0].text);
using Amazon.BedrockRuntime; using Amazon.BedrockRuntime.Model; var client = new AmazonBedrockRuntimeClient(); var res = await client.ConverseAsync(new ConverseRequest { ModelId = "anthropic.claude-sonnet-4-5-20250929-v1:0", Messages = { new Message { Role = ConversationRole.User, Content = { new ContentBlock { Text = "What are embeddings?" } } }}, InferenceConfig = new InferenceConfiguration { MaxTokens = 512, Temperature = 0.7f } }); Console.WriteLine(res.Output.Message.Content[0].Text);
The RAG payoff: Bedrock Knowledge Bases is managed RAG. Point it at an S3 folder of docs; it chunks, embeds (with Titan embeddings), stores vectors, and on a query retrieves the relevant chunks and feeds them to the model. You skip building the pipeline in section 8 yourself.
Now the words: a foundation model is a large pretrained model you call, not train. InvokeModel = per-model shape; Converse/ConverseStream = unified shape (prefer these). Titan = Amazon's own model family (text + embeddings). Knowledge Bases = managed RAG; Guardrails = policy layer. Enable model access per-region in the console first.
SQS is a queue: one message, pulled by one worker, retried if it fails. SNS is a broadcast: one message, fanned out to many subscribers. For MiniChat, the API drops a "summarize" job on SQS and returns instantly; a worker Lambda processes it and notifies the user when done.
Now the words: SQS = Simple Queue Service (point-to-point, pull, at-least-once; FIFO variant for strict order). SNS = Simple Notification Service (pub/sub, push). EventBridge is the richer event bus for routing by content. Visibility timeout hides a message while one worker processes it.
A VPC is your own isolated network. You carve it into subnets: public ones (route to an internet gateway) for anything internet-facing, private ones (no direct internet route) for databases. Put Aurora in a private subnet; nothing outside can even reach it.
| Security Group | NACL | |
|---|---|---|
| Attached to | Individual resource (per office door) | Whole subnet (whole floor) |
| State | Stateful (reply traffic auto-allowed) | Stateless (must allow both directions) |
| Rules | Allow only | Allow and deny, evaluated in order |
Now the words: VPC = Virtual Private Cloud. Security group = stateful, instance-level firewall (allow rules). NACL = network ACL, stateless, subnet-level (allow + deny). Internet Gateway for public egress; NAT Gateway for private outbound; VPC endpoints for private access to AWS services.
CloudWatch is AWS's built-in observability. Lambda auto-ships logs and metrics (invocations, errors, duration). You set alarms on a metric (e.g. error rate > 1%) that page you or trigger auto-scaling. X-Ray traces a single request across API Gateway → Lambda → Bedrock so you see exactly where the time went.
Errors and on Throttles catches most incidents before customers do. Structured JSON logs make searching in Logs Insights trivial.Now the words: a log group holds a service's log streams; metrics are time-series numbers; an alarm fires an action (SNS, auto-scaling) when a metric crosses a threshold. X-Ray = distributed tracing. CloudTrail is separate: it logs who did what in your account (audit), not app behavior.
Describe your infra in files, commit them to git, and let a tool create/update it. Change the file, apply, and AWS reconciles reality to match. No more "works in prod because someone clicked something once."
| Tool | What it is | Pick when |
|---|---|---|
| CloudFormation | AWS-native YAML/JSON templates | Pure AWS, no extra tooling, declarative |
| AWS CDK | Write infra in TypeScript/Python; compiles to CloudFormation | You want loops, types, and real code for infra |
| Terraform | Third-party, multi-cloud (HCL) | Multi-cloud, or a team standard on Terraform |
# CloudFormation: MiniChat's message table, declared Resources: MessagesTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: pk, AttributeType: S } - { AttributeName: sk, AttributeType: S } KeySchema: - { AttributeName: pk, KeyType: HASH } - { AttributeName: sk, KeyType: RANGE }
Now the words: a stack is one deployed CloudFormation unit; drift is when someone clicks a change outside the template. SAM is a CloudFormation flavor tuned for serverless. CDK gives you loops and abstraction but you still deploy CloudFormation underneath.
KMS manages encryption keys; S3, DynamoDB, and Aurora use it to encrypt data at rest with one checkbox. Secrets Manager stores credentials, rotates them, and hands them to your code at runtime via an IAM-controlled call, never checked into code or pasted into plaintext env vars.
// fetch the DB password at runtime, never hard-coded const sm = new SecretsManagerClient({}); const { SecretString } = await sm.send( new GetSecretValueCommand({ SecretId: "minichat/db" }));
Now the words: KMS = Key Management Service (envelope encryption, keys never leave). Secrets Manager = stored + rotated secrets (vs SSM Parameter Store, cheaper, no auto-rotation). Encryption at rest (stored data) vs in transit (TLS). AWS Budgets + Cost Explorer for spend visibility.
Follow the arrows: the browser loads the app from S3 via CloudFront, calls API Gateway, which invokes a Lambda. The Lambda writes to DynamoDB, calls Bedrock for the reply, and for slow jobs drops a message on SQS for a worker. Everything sits inside a VPC, uses IAM roles, pulls secrets from Secrets Manager, and reports to CloudWatch.