Introduction
AWS is the default cloud substrate for most production workloads, but the surface area is enormous — over 200 services, most of which a DevOps engineer will never touch. In daily practice, the job narrows down to a small, well-defined core: IAM for identity and access, EC2 for compute, VPC for networking, S3 for object storage, RDS for managed databases, CloudWatch for observability, and an Application Load Balancer tying it all together in front of an Auto Scaling group.
This guide covers that core stack in the depth a working engineer actually needs — real IAM policy JSON, real VPC wiring, real CLI commands for every service, and the production patterns that connect them into a deployable architecture. It complements (rather than repeats) a static-hosting-specific S3 + CloudFront + ACM walkthrough elsewhere in this site — here the focus is the broader compute/networking/database stack behind an actual application.
Who this is for
- DevOps/SRE engineers who need the AWS services touched daily, not the entire catalog.
- Linux administrators moving workloads from on-prem or VPS hosting into AWS.
- Anyone designing or reviewing a production 3-tier architecture on AWS for the first time.
Prerequisites
You should have the following in place before working through this guide:
- An AWS account with billing enabled and root-account MFA turned on.
- An IAM user or role with administrative access for initial setup (never the root account day-to-day).
- AWS CLI v2 installed locally, or access to AWS CloudShell in the console.
- Basic comfort with JSON, since IAM policies and CLI --cli-input-json payloads are both JSON.
- A registered domain if you intend to follow the Route 53 sections end to end.
Root account vs IAM
The root account should be used only to create your first IAM administrator and to set up billing alerts and MFA — then locked away. Everything in this guide is performed as an IAM identity, exactly as it would be in production.Theory
The Shared Responsibility Model
AWS secures the cloud — physical data centers, host hardware, hypervisor, and managed-service internals. You secure what you put in the cloud— IAM policies, security group rules, OS patching on EC2, data encryption, and application-level access control. Moving to a managed service (RDS vs. self-hosted MySQL on EC2) shifts more of that line to AWS, but it never disappears entirely — misconfigured IAM or an open security group is always the customer's responsibility.
Regions, Availability Zones, and edge locations
| Concept | What it is |
|---|---|
| Region | An independent geographic area (e.g. ap-south-1) containing multiple AZs; most resources are region-scoped. |
| Availability Zone (AZ) | One or more discrete data centers within a region, with independent power/networking; the unit of fault-tolerance for Multi-AZ RDS and multi-AZ EC2/ALB deployments. |
| Edge location | A CloudFront/Route 53 point of presence, far more numerous than regions, used for caching and DNS resolution close to end users. |
A production-grade design spreads compute and databases across at least two AZs in one region, so the loss of a single data center does not take the application down.
IAM fundamentals
- Users— a persistent identity (human or service) with long-lived credentials; use sparingly and prefer roles for anything that isn't a human logging in.
- Groups— a collection of users that share attached policies; there is no "group of groups" nesting.
- Roles — an identity assumed temporarily (by an EC2 instance, a Lambda function, or another AWS account) via STS, producing short-lived credentials instead of a permanent access key.
- Policies — JSON documents that grant or deny actions on resources; evaluated with an explicit-deny-wins, default-deny model — nothing is allowed unless a policy statement says so.
Prefer roles over access keys
An EC2 instance, ECS task, or Lambda function should assume an IAM role via an instance profile rather than have an IAM user's access keys baked into an environment variable or AMI. Roles issue temporary credentials that rotate automatically.Architecture
A standard production 3-tier layout: a public-facing Application Load Balancer in public subnets, application servers in private subnets across two Availability Zones, and a Multi-AZ RDS database in dedicated private database subnets. Outbound internet access for the private tier (OS updates, package installs) flows through a NAT Gateway in the public subnet; inbound internet access only ever reaches the ALB.
Client → Internet Gateway → ALB (public subnets) → EC2 (private subnets) → RDS Multi-AZ (private DB subnets), with a NAT Gateway for outbound-only traffic
Never put EC2 or RDS in a public subnet
Application and database instances should have no route to an Internet Gateway at all. The ALB is the only component with a public IP; everything behind it reaches the internet outbound-only via NAT, and receives inbound traffic only from the ALB's security group.Installation
Almost everything in this guide can be done from the AWS Console, but the CLI is what makes AWS work reproducible and scriptable — the same commands work in CI/CD pipelines.
# Download and install AWS CLI v2
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install
# Verify
aws --version
# Configure a named profile with an IAM user's access key
aws configure --profile deploy
# AWS Access Key ID: AKIA...
# AWS Secret Access Key: ********
# Default region name: ap-south-1
# Default output format: json
# Use the profile for any command
aws sts get-caller-identity --profile deployNamed profiles over a single default
Use--profile with named profiles in ~/.aws/credentials for every account/role you touch, rather than overwriting the default profile — this avoids accidentally running a command against the wrong account.[profile deploy]
region = ap-south-1
output = json
[profile prod-readonly]
role_arn = arn:aws:iam::123456789012:role/ReadOnlyAuditor
source_profile = deploy
region = ap-south-1Configuration
IAM — users, roles, and least privilege
Create IAM users only for humans; give services and EC2 instances roles instead. A least-privilege policy names specific actions and resources rather than "Action": "*":
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadAppBucket",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::app-uploads-prod",
"arn:aws:s3:::app-uploads-prod/*"
]
},
{
"Sid": "AllowOwnLogGroup",
"Effect": "Allow",
"Action": ["logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:ap-south-1:123456789012:log-group:/app/prod:*"
}
]
}A role that EC2 instances assume needs a trust policy separate from its permissions policy, naming the EC2 service as the trusted principal:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ec2.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}# Create the role, attach the trust policy, then attach the permissions policy
aws iam create-role \
--role-name AppServerRole \
--assume-role-policy-document file://ec2-trust-policy.json
aws iam put-role-policy \
--role-name AppServerRole \
--policy-name S3ReadOnlyAndLogs \
--policy-document file://ec2-s3-readonly-policy.json
# Wrap the role in an instance profile so EC2 can use it
aws iam create-instance-profile --instance-profile-name AppServerProfile
aws iam add-role-to-instance-profile \
--instance-profile-name AppServerProfile \
--role-name AppServerRoleVPC — subnets, route tables, gateways
A production VPC splits into at least three subnet tiers per Availability Zone: public (ALB, NAT Gateway), private-app (EC2), and private-db (RDS) — each with its own route table.
# VPC and subnets
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --tag-specifications \
'ResourceType=vpc,Tags=[{Key=Name,Value=prod-vpc}]'
aws ec2 create-subnet --vpc-id vpc-0abc123 --cidr-block 10.0.0.0/24 \
--availability-zone ap-south-1a --tag-specifications \
'ResourceType=subnet,Tags=[{Key=Name,Value=public-1a}]'
aws ec2 create-subnet --vpc-id vpc-0abc123 --cidr-block 10.0.10.0/24 \
--availability-zone ap-south-1a --tag-specifications \
'ResourceType=subnet,Tags=[{Key=Name,Value=private-app-1a}]'
aws ec2 create-subnet --vpc-id vpc-0abc123 --cidr-block 10.0.20.0/24 \
--availability-zone ap-south-1a --tag-specifications \
'ResourceType=subnet,Tags=[{Key=Name,Value=private-db-1a}]'
# Internet Gateway for the public tier
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --vpc-id vpc-0abc123 --internet-gateway-id igw-0def456
# NAT Gateway (needs an Elastic IP) in the public subnet, for private-tier egress
aws ec2 allocate-address --domain vpc
aws ec2 create-nat-gateway --subnet-id subnet-public1a --allocation-id eipalloc-0aaa111
# Route tables
aws ec2 create-route-table --vpc-id vpc-0abc123
aws ec2 create-route --route-table-id rtb-public \
--destination-cidr-block 0.0.0.0/0 --gateway-id igw-0def456
aws ec2 create-route --route-table-id rtb-private \
--destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-0bbb222
aws ec2 associate-route-table --subnet-id subnet-public1a --route-table-id rtb-public
aws ec2 associate-route-table --subnet-id subnet-private-app1a --route-table-id rtb-privateEC2 and Security Groups
Security groups are stateful virtual firewalls attached to instances or ENIs — a response to allowed inbound traffic is automatically allowed out, and vice versa. Design them narrowly: reference other security groups as the source instead of CIDR blocks wherever possible.
# ALB security group: public HTTPS/HTTP only
aws ec2 create-security-group --group-name alb-sg \
--description "ALB ingress from internet" --vpc-id vpc-0abc123
aws ec2 authorize-security-group-ingress --group-id sg-alb \
--protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id sg-alb \
--protocol tcp --port 80 --cidr 0.0.0.0/0
# App security group: only from the ALB's security group, never the open internet
aws ec2 create-security-group --group-name app-sg \
--description "App servers, ingress from ALB only" --vpc-id vpc-0abc123
aws ec2 authorize-security-group-ingress --group-id sg-app \
--protocol tcp --port 8080 --source-group sg-alb
# DB security group: only from the app tier, on the DB port
aws ec2 create-security-group --group-name db-sg \
--description "RDS ingress from app tier only" --vpc-id vpc-0abc123
aws ec2 authorize-security-group-ingress --group-id sg-db \
--protocol tcp --port 5432 --source-group sg-app
# Launch an EC2 instance with an instance profile, key pair, and user-data bootstrap
aws ec2 run-instances \
--image-id ami-0abcdef1234567890 \
--instance-type t3.small \
--key-name prod-keypair \
--subnet-id subnet-private-app1a \
--security-group-ids sg-app \
--iam-instance-profile Name=AppServerProfile \
--user-data file://bootstrap.sh \
--block-device-mappings 'DeviceName=/dev/xvda,Ebs={VolumeSize=30,VolumeType=gp3}' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app-server-1a}]'AMIs, instance types, and user-data
An AMI is the base image (OS + pre-baked software); an instance type (e.g.t3.small, m6i.large) sets vCPU/RAM/network allowance; user-data is a boot-time script for one-time bootstrap (installing an agent, pulling the latest app release) rather than baking every change into a new AMI.Commands
EC2
aws ec2 describe-instances --filters "Name=tag:Name,Values=app-server-*"
aws ec2 start-instances --instance-ids i-0abc123
aws ec2 stop-instances --instance-ids i-0abc123
aws ec2 reboot-instances --instance-ids i-0abc123
aws ec2 terminate-instances --instance-ids i-0abc123
aws ec2 create-snapshot --volume-id vol-0abc123 --description "pre-upgrade snapshot"
aws ec2 describe-security-groups --group-ids sg-appS3
aws s3 mb s3://app-uploads-prod
aws s3 cp ./report.csv s3://app-uploads-prod/reports/
aws s3 sync ./build s3://app-uploads-prod --delete
aws s3 ls s3://app-uploads-prod --recursive --human-readable
aws s3api put-bucket-versioning --bucket app-uploads-prod \
--versioning-configuration Status=Enabled
aws s3api put-bucket-lifecycle-configuration --bucket app-uploads-prod \
--lifecycle-configuration file://lifecycle.jsonRDS
aws rds create-db-instance \
--db-instance-identifier app-prod-db \
--db-instance-class db.t3.medium \
--engine postgres \
--engine-version 16.3 \
--master-username appadmin \
--master-user-password 'ChangeMe!ReplaceWithSecretsManager' \
--allocated-storage 100 \
--multi-az \
--db-subnet-group-name prod-db-subnet-group \
--vpc-security-group-ids sg-db \
--backup-retention-period 7 \
--no-publicly-accessible
aws rds create-db-instance-read-replica \
--db-instance-identifier app-prod-db-replica-1 \
--source-db-instance-identifier app-prod-db
aws rds describe-db-instances --db-instance-identifier app-prod-db
aws rds create-db-snapshot --db-instance-identifier app-prod-db \
--db-snapshot-identifier app-prod-db-manual-2026-07-02
aws rds modify-db-parameter-group --db-parameter-group-name app-pg16 \
--parameters "ParameterName=max_connections,ParameterValue=200,ApplyMethod=pending-reboot"CloudWatch
aws cloudwatch put-metric-alarm \
--alarm-name high-cpu-app-tier \
--namespace AWS/EC2 --metric-name CPUUtilization \
--dimensions Name=AutoScalingGroupName,Value=app-asg \
--statistic Average --period 300 --evaluation-periods 2 \
--threshold 75 --comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:ap-south-1:123456789012:ops-alerts
aws logs create-log-group --log-group-name /app/prod
aws logs tail /app/prod --follow
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS --metric-name FreeStorageSpace \
--dimensions Name=DBInstanceIdentifier,Value=app-prod-db \
--start-time 2026-07-01T00:00:00Z --end-time 2026-07-02T00:00:00Z \
--period 3600 --statistics AverageRoute 53
aws route53 create-hosted-zone --name app.example.com --caller-reference $(date +%s)
aws route53 list-resource-record-sets --hosted-zone-id Z1ABCDEF23456
aws route53 change-resource-record-sets --hosted-zone-id Z1ABCDEF23456 \
--change-batch file://upsert-alb-alias.json
aws route53 create-health-check --caller-reference $(date +%s) \
--health-check-config file://health-check.jsonCloudFront & ACM
# ACM certificate must be requested in us-east-1 for CloudFront use
aws acm request-certificate --domain-name app.example.com \
--validation-method DNS --region us-east-1
aws cloudfront list-distributions
aws cloudfront create-invalidation --distribution-id EDFDVBD6EXAMPLE --paths "/*"ALB / Elastic Load Balancing
aws elbv2 create-load-balancer \
--name app-alb --type application --scheme internet-facing \
--subnets subnet-public1a subnet-public1b --security-groups sg-alb
aws elbv2 create-target-group \
--name app-tg --protocol HTTP --port 8080 --vpc-id vpc-0abc123 \
--target-type instance --health-check-path /healthz \
--health-check-interval-seconds 15 --healthy-threshold-count 2 \
--unhealthy-threshold-count 3
aws elbv2 create-listener \
--load-balancer-arn arn:aws:elasticloadbalancing:...:loadbalancer/app/app-alb/abc \
--protocol HTTPS --port 443 \
--certificates CertificateArn=arn:aws:acm:...:certificate/xyz \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:...:targetgroup/app-tg/def
aws elbv2 describe-target-health --target-group-arn arn:aws:elasticloadbalancing:...:targetgroup/app-tg/defExamples
A complete deployment of a load-balanced, auto-scaled application with a managed database backend, built up in order:
Provision networking
Create the VPC, public/private-app/private-db subnets across two AZs, an Internet Gateway, a NAT Gateway, and route tables as shown in Configuration.Create the launch template
Define an EC2 launch template with the AMI, instance type, security group, instance profile, and user-data bootstrap script the app servers will run.Create the target group and ALB
aws elbv2 create-target-groupwith a/healthzhealth check, thencreate-load-balancerandcreate-listeneron port 443 with the ACM certificate attached.Create the Auto Scaling Group
Attach the launch template to an ASG spanning both private-app subnets, wired to the target group, with min/desired/max capacity and a target-tracking scaling policy on CPU utilization.Launch RDS Multi-AZ
Create the DB subnet group from the two private-db subnets, thencreate-db-instance --multi-azwith the app tier's security group as the only allowed ingress source.Point DNS at the ALB
Create a Route 53 alias A record forapp.example.comtargeting the ALB's DNS name and hosted zone ID, then confirm end-to-end withcurl -I https://app.example.com.
Screenshots
EC2 console — target group showing all instances in the 'healthy' state behind the ALB
Screenshot placeholder
CloudWatch dashboard — CPUUtilization and RDS FreeableMemory graphed alongside the ALB's HealthyHostCount alarm
Screenshot placeholder
Real World Example
Scenario: launch a production order-processing API for a mid-traffic e-commerce backend, with zero single points of failure and full observability from day one.
IAM foundation
Create anAppServerRolewith least-privilege access to the app's S3 bucket and its own CloudWatch log group only, wrapped in an instance profile — no long-lived access keys anywhere in the fleet.Network and compute
Stand up the 3-tier VPC, then an Auto Scaling Group oft3.smallapp servers across two AZs behind an ALB, with security groups chained ALB → app → db and no direct internet route to either private tier.Database
Launch a Multi-AZ RDS PostgreSQL instance with automated backups, a read replica for reporting queries, and a custom parameter group tuned for connection limits.Observability
Ship application logs to a CloudWatch log group, add alarms on ALB 5xx rate, target group unhealthy host count, and RDS FreeStorageSpace, each notifying an SNS topic that pages on-call.DNS and cutover
Create the Route 53 alias record pointing at the ALB, verify health checks are green, then cut production traffic over during a low-traffic window and watch the CloudWatch dashboard for the first hour.
Common Issues
Security group misconfiguration
A connection that times out (rather than an immediate refusal) almost always points to a security group or NACL blocking the port, not the application. Check the security group's inbound rules first — remember they are stateful, so only inbound rules typically need adjusting for a client-initiated connection.IAM AccessDenied errors
Read the exactAction and Resource named in the error message — IAM denials are usually a missing statement, not a broken one. Use aws iam simulate-principal-policyto test a specific action/resource pair against the caller's actual attached policies before guessing.ALB target group showing unhealthy
Confirm the health check path returns a 200 on the exact port configured on the target group (not the listener port), and that the app security group allows inbound traffic from the ALB's security group on that port — a common cause is the health check path requiring auth or hitting a route that doesn't exist.RDS connection timeout from the app tier
Verify the app server sits in a subnet whose route table can reach the DB subnet (both inside the same VPC), and that the RDS security group's inbound rule sources the app tier's security group, not a stale CIDR block or the wrong SG entirely.ACM certificate region mismatch for CloudFront
CloudFront only accepts ACM certificates issued inus-east-1, regardless of which region the distribution or its origin lives in. A certificate requested in any other region simply won't appear in the CloudFront certificate picker.Best Practices
- Use infrastructure as code (Terraform/CloudFormation) instead of manually clicking through the console for anything beyond a quick experiment.
- Tag every resource consistently (
Environment,Owner,Project) for cost allocation and cleanup. - Prefer managed services (RDS over self-hosted MySQL on EC2) whenever operational overhead outweighs the control you'd gain.
- Spread stateful and stateless resources across at least two Availability Zones by default, not as an afterthought.
- Set a billing alarm and AWS Budgets alert before any account does meaningful work.
- Rotate IAM access keys and prefer roles/OIDC federation for CI/CD instead of long-lived credentials.
Security Hardening
- Least privilege IAM — scope every policy to specific actions and resource ARNs; avoid wildcard
Action: *orResource: *outside of break-glass roles. - MFA everywhere — enforce MFA on the root account and on every IAM user with console access, and require it in policy conditions for destructive actions.
- Security groups as default-deny — start from no inbound rules and add only what's required, sourced from other security groups rather than broad CIDR ranges wherever possible.
- Encryption in transit and at rest — TLS on every ALB listener and RDS connection; enable EBS and RDS storage encryption with KMS by default.
- CloudTrail — enable an organization-wide trail logging to a locked-down S3 bucket so every API call is auditable after the fact.
- No public subnets for app or DB tiers — only load balancers and NAT gateways should have a route to an Internet Gateway.
Interview Questions
Cheat Sheet
# --- Identity ---
aws sts get-caller-identity
aws configure --profile <name>
# --- IAM ---
aws iam create-role --role-name <r> --assume-role-policy-document file://trust.json
aws iam put-role-policy --role-name <r> --policy-name <p> --policy-document file://policy.json
aws iam simulate-principal-policy --policy-source-arn <arn> --action-names <action>
# --- EC2 ---
aws ec2 run-instances --image-id <ami> --instance-type t3.small --key-name <kp> \
--subnet-id <subnet> --security-group-ids <sg> --iam-instance-profile Name=<profile>
aws ec2 describe-instances --filters "Name=tag:Name,Values=<name>"
aws ec2 create-snapshot --volume-id <vol>
# --- VPC ---
aws ec2 create-vpc --cidr-block 10.0.0.0/16
aws ec2 create-subnet --vpc-id <vpc> --cidr-block <cidr> --availability-zone <az>
aws ec2 create-nat-gateway --subnet-id <subnet> --allocation-id <eip>
aws ec2 authorize-security-group-ingress --group-id <sg> --protocol tcp --port <p> --source-group <sg2>
# --- S3 ---
aws s3 sync ./build s3://<bucket> --delete
aws s3api put-bucket-versioning --bucket <bucket> --versioning-configuration Status=Enabled
# --- RDS ---
aws rds create-db-instance --db-instance-identifier <id> --engine postgres --multi-az ...
aws rds create-db-snapshot --db-instance-identifier <id> --db-snapshot-identifier <snap>
aws rds create-db-instance-read-replica --db-instance-identifier <replica> --source-db-instance-identifier <id>
# --- CloudWatch ---
aws cloudwatch put-metric-alarm --alarm-name <name> --namespace AWS/EC2 --metric-name CPUUtilization ...
aws logs tail /app/prod --follow
# --- ALB ---
aws elbv2 create-target-group --name <tg> --protocol HTTP --port 8080 --vpc-id <vpc>
aws elbv2 create-listener --load-balancer-arn <arn> --protocol HTTPS --port 443 --certificates CertificateArn=<arn>
aws elbv2 describe-target-health --target-group-arn <arn>
# --- Route53 / CloudFront / ACM ---
aws route53 change-resource-record-sets --hosted-zone-id <zone> --change-batch file://upsert.json
aws acm request-certificate --domain-name <domain> --validation-method DNS --region us-east-1
aws cloudfront create-invalidation --distribution-id <id> --paths "/*"Summary
The core AWS stack a DevOps engineer runs daily is smaller than the service catalog suggests: IAM identities and least-privilege policies, a VPC with clean public/private tiering, EC2 behind an Auto Scaling Group and ALB, S3 for durable object storage, RDS for a managed relational database with Multi-AZ failover, and CloudWatch tying it all together with metrics, logs, and alarms. Master these seven services and their interactions — not every AWS product — and you can design, deploy, and troubleshoot the overwhelming majority of production architectures.
Resources
- AWS official documentation —
docs.aws.amazon.com - AWS Well-Architected Framework —
aws.amazon.com/architecture/well-architected - AWS CLI command reference —
awscli.amazonaws.com - AWS Architecture Center (reference architectures) —
aws.amazon.com/architecture - AWS Skill Builder (free official training) —
skillbuilder.aws