AWS Explained: Core Services, Real-World Architecture & When to Use Each
Every app you use daily — Netflix streaming your show, Airbnb finding you a room, even NASA processing telescope images — runs on someone else's hardware. That hardware is overwhelmingly likely to be Amazon Web Services. AWS controls roughly 31% of the global cloud market, and understanding it isn't optional for a modern developer. Whether you're deploying your first side project or designing a system that serves millions, AWS is the environment you'll be working in.
Before cloud computing existed, launching a product meant buying physical servers, installing them in a data centre, estimating your peak traffic years in advance, and paying for that capacity whether you used it or not. A startup that went viral overnight would crash under load with no way to recover quickly. AWS solved this by turning infrastructure into software — things you provision with an API call in seconds, pay for by the minute, and throw away when you're done.
By the end of this article you'll understand the five services every AWS project touches — EC2, S3, RDS, Lambda, and IAM — why each one exists, when to reach for it over the alternatives, and how they wire together into a real production architecture. You'll also walk away with the vocabulary and mental models that make AWS job interviews approachable.
The Five Core Services Every AWS Project Uses — and Why They Were Built
AWS has over 200 services, which is overwhelming until you realise that almost every architecture starts with the same five building blocks. Think of them as the five trades in construction: electricity, plumbing, walls, a roof, and a lock on the door. Everything else is finishing work.
EC2 (Elastic Compute Cloud) is your rented computer. It runs your application code exactly as a physical server would, but you can resize it, clone it, or delete it in minutes.
S3 (Simple Storage Service) is unlimited file storage. Not a database — a place to put files. Images, videos, backups, static websites, data exports. It's so reliable (eleven 9s of durability) that AWS themselves use it internally.
RDS (Relational Database Service) runs a managed PostgreSQL, MySQL, or other SQL engine. You don't patch it, back it up, or handle failover — AWS does. You just query it.
Lambda runs a function without a server. Upload code, define a trigger, done. No EC2 instance sitting idle waiting for work.
IAM (Identity and Access Management) is the lock on the door. Every call to every AWS service checks IAM first. Get this wrong and either nothing works or everything is exposed.
#!/bin/bash # Prerequisites: AWS CLI installed and configured with `aws configure` # This script creates the skeleton of a real web app infrastructure: # an S3 bucket for static assets, checks your IAM identity, and # lists available EC2 instance types so you can make an informed choice. # ── Step 1: Confirm who you are (IAM) ──────────────────────────────── # Always run this first. If the wrong profile is active you'll create # resources in the wrong account — a very expensive mistake. echo "Current IAM identity:" aws sts get-caller-identity # Output shows Account ID, IAM User ARN, and User ID. # If you see 'Unable to locate credentials', run: aws configure # ── Step 2: Create an S3 bucket for your app's static assets ───────── # Bucket names must be globally unique across ALL AWS accounts. # Using a domain-style name is a common convention to avoid conflicts. BUCKET_NAME="myapp-static-assets-$(date +%s)" # timestamp ensures uniqueness AWS_REGION="us-east-1" echo "Creating S3 bucket: $BUCKET_NAME" aws s3api create-bucket \ --bucket "$BUCKET_NAME" \ --region "$AWS_REGION" # Note: us-east-1 does NOT take a LocationConstraint. Other regions do: # --create-bucket-configuration LocationConstraint=eu-west-1 # ── Step 3: Block all public access (secure by default) ─────────────── # New buckets are private, but explicitly blocking public access # prevents any future policy change from accidentally exposing data. aws s3api put-public-access-block \ --bucket "$BUCKET_NAME" \ --public-access-block-configuration \ "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true" echo "Bucket $BUCKET_NAME created and locked down." # ── Step 4: Upload a sample asset ───────────────────────────────────── echo "<h1>Hello from S3</h1>" > /tmp/index.html aws s3 cp /tmp/index.html s3://"$BUCKET_NAME"/index.html echo "File uploaded successfully." # ── Step 5: Verify the upload ───────────────────────────────────────── echo "Bucket contents:" aws s3 ls s3://"$BUCKET_NAME"/
{
"UserId": "AIDA4EXAMPLE7USERID",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/sarah-dev"
}
Creating S3 bucket: myapp-static-assets-1718123456
{
"Location": "/myapp-static-assets-1718123456"
}
Bucket myapp-static-assets-1718123456 created and locked down.
File uploaded successfully.
Bucket contents:
2024-06-11 14:22:01 22 index.html
EC2 vs Lambda: Choosing the Right Compute Model Before You Write a Line of Code
The most consequential architectural decision in AWS isn't which database to use or how to structure your VPC. It's whether your code runs on EC2 or Lambda. Getting this wrong means either paying for idle servers 24/7 or hitting cold-start timeouts on user-facing requests.
Use EC2 when: your workload is continuous and predictable, you need full OS control, you're running long-running processes (video encoding, ML training), or you're lifting-and-shifting an existing app. An EC2 instance is just a VM — it starts up and stays up until you stop it.
Use Lambda when: your workload is event-driven and intermittent. An API endpoint that gets 50 requests per minute, a function that fires when a file lands in S3, a nightly data transform. Lambda charges per 1ms of execution. If the function doesn't run, you pay nothing.
The trap beginners fall into is using Lambda for everything because it sounds cheaper and more modern. Lambda has a hard 15-minute execution timeout. Put a 20-minute database migration in a Lambda and it will die mid-run, leaving your schema in a broken state. Put a CPU-intensive image processor in Lambda and the cold start latency will frustrate your users.
The sweet spot is using Lambda for glue — the code that reacts to events and orchestrates other services — while EC2 or containers handle the persistent, long-running workloads.
# This is a complete AWS Lambda function that triggers whenever a new image # is uploaded to an S3 bucket, generates a thumbnail, and saves it to a # second 'thumbnails' bucket. This is one of the most common Lambda patterns. import boto3 # AWS SDK for Python — installed in Lambda runtime by default import json from PIL import Image # Requires a Lambda Layer or packaging Pillow with your deploy import io import os # boto3 clients are created outside the handler so they are reused across # warm invocations — this is a real performance optimisation, not just style. s3_client = boto3.client('s3') # The THUMBNAILS_BUCKET env var is set in the Lambda config, not hardcoded. # Hardcoding bucket names is a common mistake that breaks staging/prod parity. THUMBNAILS_BUCKET = os.environ['THUMBNAILS_BUCKET'] THUMBNAIL_SIZE = (128, 128) # width x height in pixels def lambda_handler(event, context): """ AWS calls this function automatically when a new object is created in the source S3 bucket. 'event' contains the bucket name and object key. """ # Extract the source bucket and file key from the S3 event payload source_bucket = event['Records'][0]['s3']['bucket']['name'] source_key = event['Records'][0]['s3']['object']['key'] # Only process image files — avoid infinite loops if thumbnails land # in the same bucket as originals (a classic footgun). if source_key.startswith('thumbnails/'): print(f"Skipping thumbnail file to prevent recursion: {source_key}") return {'statusCode': 200, 'body': 'Skipped thumbnail.'} print(f"Processing image: s3://{source_bucket}/{source_key}") # Download the original image into memory (no disk — Lambda is stateless) response = s3_client.get_object(Bucket=source_bucket, Key=source_key) image_data = response['Body'].read() # Open, resize, and convert using Pillow original_image = Image.open(io.BytesIO(image_data)) original_image.thumbnail(THUMBNAILS_SIZE) # modifies in place, preserves ratio # Write the thumbnail to an in-memory buffer (no temp files needed) thumbnail_buffer = io.BytesIO() image_format = original_image.format or 'JPEG' original_image.save(thumbnail_buffer, format=image_format) thumbnail_buffer.seek(0) # rewind the buffer to the start before upload # Build the destination key — mirrors the original path under 'thumbnails/' thumbnail_key = f"thumbnails/{source_key}" s3_client.put_object( Bucket=THUMBNAILS_BUCKET, Key=thumbnail_key, Body=thumbnail_buffer, ContentType=f"image/{image_format.lower()}" ) print(f"Thumbnail saved to: s3://{THUMBNAILS_BUCKET}/{thumbnail_key}") return { 'statusCode': 200, 'body': json.dumps({'thumbnail': thumbnail_key}) }
Processing image: s3://myapp-uploads/profile-photos/user_42.jpg
Thumbnail saved to: s3://myapp-thumbnails/thumbnails/profile-photos/user_42.jpg
END RequestId: abc-123
REPORT RequestId: abc-123 Duration: 312.45 ms Billed Duration: 313 ms Memory Size: 256 MB Max Memory Used: 89 MB
IAM Done Right: Why Least-Privilege Access Is Not Optional
IAM is the part of AWS that most tutorials rush through to get to the 'interesting' stuff, and it's the part that causes the most expensive real-world incidents. The 2019 Capital One breach that exposed 100 million customer records was an IAM misconfiguration. Understanding IAM isn't bureaucracy — it's engineering.
Every entity in AWS (a user, an EC2 instance, a Lambda function) has an identity. Every action on every resource is authorised by checking IAM policies attached to that identity. By default, everything is denied. You grant access explicitly.
The three concepts you must internalise are: Users (humans), Roles (services and applications — an EC2 instance assumes a role, not a user), and Policies (JSON documents that say what is allowed or denied on which resources).
The golden rule is least privilege: grant only the exact permissions needed for a specific task, scoped to the specific resource. Not s3: on — that's every S3 action on every bucket in your account. Instead: s3:GetObject on arn:aws:s3:::myapp-assets/*.
If a Lambda function only needs to read from one S3 bucket, its execution role should be able to do exactly that — nothing else. If that Lambda is compromised, the blast radius is one bucket in read-only mode, not your entire AWS account.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadingFromSpecificBucketOnly",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::myapp-static-assets-1718123456",
"arn:aws:s3:::myapp-static-assets-1718123456/*"
]
},
{
"Sid": "AllowCloudWatchLoggingForDebugging",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/thumbnail-processor:*"
}
]
}
# To attach this policy to a Lambda execution role via CLI:
#
# 1. Create the policy in IAM:
# aws iam create-policy \
# --policy-name LambdaThumbnailS3ReadPolicy \
# --policy-document file://lambda_s3_readonly_policy.json
#
# 2. Create the role (Lambda needs permission to assume it):
# aws iam create-role \
# --role-name LambdaThumbnailRole \
# --assume-role-policy-document '{
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Principal": {"Service": "lambda.amazonaws.com"},
# "Action": "sts:AssumeRole"
# }]
# }'
#
# 3. Attach the policy to the role:
# aws iam attach-role-policy \
# --role-name LambdaThumbnailRole \
# --policy-arn arn:aws:iam::123456789012:policy/LambdaThumbnailS3ReadPolicy
{
"Policy": {
"PolicyName": "LambdaThumbnailS3ReadPolicy",
"PolicyId": "ANPA4EXAMPLEPOLICYID",
"Arn": "arn:aws:iam::123456789012:policy/LambdaThumbnailS3ReadPolicy",
"CreateDate": "2024-06-11T14:30:00+00:00",
"AttachmentCount": 0,
"IsAttachable": true
}
}
# After attach-role-policy: (no output means success — this is intentional CLI behaviour)
How a Real Production Architecture Wires These Services Together
Seeing services in isolation is useful for learning. But AWS's real power emerges when services compose. Here's how a production web application typically connects the pieces we've covered.
A user hits your domain. Route 53 (AWS DNS) resolves it to a CloudFront distribution (CDN). CloudFront serves static assets (HTML, CSS, JS) directly from S3 — zero server involved, globally cached, essentially free at scale. For dynamic API requests, CloudFront forwards to an Application Load Balancer, which distributes traffic across EC2 instances (or ECS containers) running your application.
The application reads and writes to RDS for structured data, and stores uploaded files directly to S3 using pre-signed URLs (so files go direct from the browser to S3 — never through your server). When a file lands in S3, an event triggers a Lambda function for async processing: thumbnail generation, virus scanning, metadata extraction.
Everything runs inside a VPC (Virtual Private Cloud) — a private network. The RDS instance has no public IP. The EC2 instances live in private subnets. Only the Load Balancer is internet-facing. IAM roles control exactly which service can talk to which resource.
This pattern — static assets on S3/CloudFront, compute on EC2/Lambda, data on RDS, files on S3, security via IAM and VPC — handles everything from a startup's MVP to a Fortune 500 platform without fundamentally changing shape.
#!/bin/bash # This script creates a minimal RDS PostgreSQL instance for a web app # and demonstrates connecting to it securely from an EC2 instance. # Cost note: even the smallest RDS instance (~$15/month) is NOT free tier # eligible after the first 12 months. Use RDS Proxy in production for # connection pooling — Lambda functions can exhaust DB connections instantly. DB_IDENTIFIER="myapp-postgres-prod" DB_NAME="myapp" DB_USER="myapp_admin" # In real usage, pull this from AWS Secrets Manager — never hardcode passwords DB_PASSWORD="$(aws secretsmanager get-secret-value \ --secret-id myapp/db/password \ --query SecretString \ --output text)" # ── Create the RDS instance ─────────────────────────────────────────── echo "Creating RDS PostgreSQL instance..." aws rds create-db-instance \ --db-instance-identifier "$DB_IDENTIFIER" \ --db-instance-class db.t3.micro \ --engine postgres \ --engine-version "15.4" \ --master-username "$DB_USER" \ --master-user-password "$DB_PASSWORD" \ --db-name "$DB_NAME" \ --allocated-storage 20 \ --storage-type gp3 \ --no-publicly-accessible \ --backup-retention-period 7 \ --deletion-protection \ --region us-east-1 # --no-publicly-accessible: the DB only accepts connections from within the VPC # --deletion-protection: prevents accidental deletion with a single CLI call # --backup-retention-period 7: keeps 7 days of automated backups echo "Waiting for instance to become available (this takes ~5 minutes)..." aws rds wait db-instance-available \ --db-instance-identifier "$DB_IDENTIFIER" # ── Retrieve the endpoint once the instance is ready ────────────────── DB_ENDPOINT=$(aws rds describe-db-instances \ --db-instance-identifier "$DB_IDENTIFIER" \ --query 'DBInstances[0].Endpoint.Address' \ --output text) echo "RDS instance ready at: $DB_ENDPOINT" # ── Connect (run this from inside your EC2 instance, not your laptop) ─ # psql is available on Amazon Linux 2: sudo yum install -y postgresql15 echo "Connecting to database..." PGPASSWORD="$DB_PASSWORD" psql \ --host="$DB_ENDPOINT" \ --port=5432 \ --username="$DB_USER" \ --dbname="$DB_NAME" \ --command="SELECT version();"
{
"DBInstance": {
"DBInstanceIdentifier": "myapp-postgres-prod",
"DBInstanceClass": "db.t3.micro",
"Engine": "postgres",
"DBInstanceStatus": "creating",
"Endpoint": null
}
}
Waiting for instance to become available (this takes ~5 minutes)...
RDS instance ready at: myapp-postgres-prod.cxyz1234abcd.us-east-1.rds.amazonaws.com
Connecting to database...
version
------------------------------------------------------------------------
PostgreSQL 15.4 on x86_64-pc-linux-gnu, compiled by gcc 7.3.1, 64-bit
(1 row)
| Aspect | EC2 (Virtual Machine) | Lambda (Serverless Function) |
|---|---|---|
| Billing unit | Per hour/second the instance runs | Per 1ms of execution + per request |
| Max runtime | Unlimited (runs continuously) | 15 minutes hard limit |
| Idle cost | Full price even with 0% CPU | Zero — you pay nothing at rest |
| Cold start latency | None (already running) | 100ms–1s for infrequent functions |
| OS/runtime control | Full control — any OS, any runtime | Runtime limited to AWS-supported list |
| Best for | Long-running apps, full servers, ML | Event handlers, APIs, async jobs |
| Scaling speed | Minutes (AMI boot time) | Milliseconds (concurrent by default) |
| Database connections | Stable, long-lived connections | Can exhaust DB pools — needs RDS Proxy |
🎯 Key Takeaways
- EC2 = rented VM that runs continuously. Lambda = function that runs on-demand and costs nothing at rest. The choice between them is the first architecture decision in any AWS project.
- IAM controls every single API call in AWS. The correct mental model is 'everything is denied by default — I must explicitly grant what's needed.' Never use wildcard actions () on wildcard resources ().
- S3 is not a database and not a file system — it's object storage. 11 nines of durability means you're more likely to win the lottery three times than lose a file. Use it aggressively for anything file-shaped.
- Lambda functions can exhaust relational database connection limits at scale. If you use Lambda with RDS, RDS Proxy is not optional in production — it's the glue that makes the architecture actually work.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using the AWS root account for daily work — Symptom: you log in as root and create resources directly. Fix: immediately create an IAM user with AdministratorAccess, enable MFA on the root account, lock it in a drawer, and never use it again. The root account can't be restricted by IAM policies — a compromised root credential is total account loss.
- ✕Mistake 2: Leaving S3 buckets publicly accessible 'temporarily' — Symptom: you enable public access to test something and forget to reverse it. Fix: enable S3 Block Public Access at the account level (not just bucket level) as a blanket safety net. Use CloudFront with an Origin Access Control instead of making buckets public — it's more secure and faster for users.
- ✕Mistake 3: Hardcoding AWS credentials in application code or committing them to Git — Symptom: your Access Key ID and Secret appear in a GitHub repo, automated bots find it within minutes, and you wake up to a $50,000 bill from someone mining cryptocurrency. Fix: never use access keys in application code. EC2 instances should use IAM Roles (attached at launch), Lambdas use execution roles, and local dev should use named profiles via
aws configure. Use AWS Secrets Manager for any secret that isn't an AWS credential.
Interview Questions on This Topic
- QWhat's the difference between an IAM Role and an IAM User, and when would you assign a role to an EC2 instance instead of embedding access keys in the application?
- QA Lambda function that worked fine in testing is throwing AccessDenied errors in production when it tries to write to an S3 bucket. Walk me through how you'd diagnose and fix this.
- QYour team's web application is getting intermittent database connection errors under load. The EC2 instances and RDS instance both appear healthy. What's your first hypothesis and how would you test it?
Frequently Asked Questions
What is AWS and why do so many companies use it instead of their own servers?
AWS is Amazon's cloud platform — a collection of on-demand computing services you pay for by the minute or second. Companies use it because buying and maintaining physical servers requires predicting capacity years in advance, paying for hardware whether it's used or not, and hiring staff to run the data centre. AWS turns all of that into a monthly bill that scales exactly with actual usage, with no upfront hardware investment.
Do I need to learn all 200+ AWS services to use AWS professionally?
No. The majority of real-world applications use under 15 services regularly. Master EC2, S3, RDS, Lambda, IAM, VPC, CloudFront, and Route 53 and you can build and operate almost anything. Specialised services (SageMaker for ML, Kinesis for streaming, etc.) are worth learning when a specific problem demands them — not before.
What's the difference between a Security Group and an IAM Policy in AWS?
They solve different problems. A Security Group is a firewall — it controls network traffic (which IP addresses and ports can reach a resource). An IAM Policy controls API permissions — it determines which AWS actions (like s3:GetObject or ec2:StartInstances) an identity is allowed to perform. You need both: Security Groups protect your network layer, IAM protects your AWS API layer. A misconfigured Security Group lets bad network traffic in. A misconfigured IAM policy lets authenticated identities do things they shouldn't.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.