DevOps on AWS, part 4: VPC, IAM, and secrets — the security skeleton
Part 4from the DevOps on AWS series · 6 parts in all
Part 4 is the part beginners skip and auditors ask about first: the network and identity skeleton. Done right once, it disappears; done wrong, everything above it is decoration.
VPC: your private slice of AWS
A VPC is a private IP space (say 10.0.0.0/16) carved into subnets
per availability zone. The canonical pattern — memorize it:
- Public subnets: only the load balancers (and NAT gateways). They have a route to an Internet Gateway.
- Private subnets: your application (ECS tasks, EC2). Outbound internet via NAT; no inbound route from the internet, ever.
- Isolated subnets: databases. No internet route at all, either direction.
# The security-group trio (stateful firewalls by reference, not IP):
aws ec2 create-security-group --group-name sg-alb --description "ALB: 443 from world" --vpc-id $VPC
aws ec2 authorize-security-group-ingress --group-id $SG_ALB --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 create-security-group --group-name sg-web --description "web: 8080 from ALB only" --vpc-id $VPC
aws ec2 authorize-security-group-ingress --group-id $SG_WEB --protocol tcp --port 8080 \
--source-group $SG_ALB # <- reference, not an IP list
aws ec2 create-security-group --group-name sg-db --description "db: 5432 from web only" --vpc-id $VPC
aws ec2 authorize-security-group-ingress --group-id $SG_DB --protocol tcp --port 5432 \
--source-group $SG_WEB
Read that chain again: the database is unreachable except from the web tier, which is unreachable except from the ALB, which only speaks 443. An attacker has to walk the whole chain — and each hop is a different control plane.
IAM: roles, not keys
The rule with no exceptions in this series: workloads get IAM roles; humans get SSO; nobody keeps long-lived access keys. A role is assumed at runtime; permissions are JSON policies; credentials are rotated automatically under the hood:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::maplecart-media/*" }, # one bucket, two verbs
{ "Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:...:secret:maplecart/db-*" },
{ "Effect": "Deny", "Action": "s3:*", # guardrail:
"Resource": ["arn:aws:s3:::maplecart-backups*"] } # app can't touch backups
]
}
Note the explicit Deny: least privilege is not only "allow what's needed" but
"deny what must never happen". The application code is then credential-free — boto3 and the
AWS SDKs pick the role up automatically.
Secrets Manager: passwords with a lifecycle
# Store the DB master password; rotation is a checkbox, not a runbook
aws secretsmanager create-secret --name maplecart/db-master \
--secret-string file://db-password.json
aws secretsmanager rotate-secret --secret-id maplecart/db-master \
--rotation-lambda-arn <rotation-lambda> --rotation-rules AutomaticallyAfterDays=30
# In the app (Python): fetch at boot, cache in memory
import boto3, json
secret = json.loads(boto3.client("secretsmanager").get_secret_value(
SecretId="maplecart/db-master")["SecretString"])
With the skeleton in place — network segmentation, role-based access, managed secrets — the deployment pipeline in the next part can be simple, because it can be trusted.