A secure-by-default AWS network baseline for a typical web app: the exact ruleset
The exact network baseline for a typical web app on AWS: five security groups, two subnet tiers, IAM roles over keys, and what to deliberately skip until later.

Most “AWS security best practices” posts stop at platitudes: least privilege, defense in depth, encrypt everything. This one doesn’t. Below is the exact network baseline for the most common architecture in existence — a frontend + API behind a load balancer, PostgreSQL, Redis, maybe a background worker — with the specific security group rules, what each one is for, and what this baseline deliberately leaves out.
If you adopt nothing else, adopt the rule table. It’s the part people get wrong.
The topology #
VPC (e.g. 10.0.0.0/16), two AZs minimum
Public subnets (2x) → ALB only. Nothing else. Ever.
Private subnets (2x) → ECS tasks / EC2 app, workers,
RDS Postgres, ElastiCache Redis
No public IPs. Egress via NAT Gateway.Checklist form:
- VPC with public and private subnets across 2 AZs (RDS subnet groups require two anyway)
- Public subnets contain only the ALB
- App, workers, DB, Redis all in private subnets with
assign_public_ip = false/map_public_ip_on_launch = false - NAT Gateway in a public subnet; private route tables send
0.0.0.0/0to it — outbound only, nothing can initiate inbound through it - No internet gateway routes in private route tables
- ALB listens on 443 with an ACM cert; port 80 exists only to redirect to 443
The single most common mistake I see in real accounts: an RDS instance with publicly_accessible = true“temporarily, for debugging,” eighteen months ago. The second most common: an app task in a public subnet because someone couldn’t get NAT working and gave a task a public IP instead. Both are eliminated structurally by this layout — there’s no route from the internet to a private subnet, so a fat-fingered security group rule alone can’t expose your database.
The exact security group chain #
Five security groups. Each references the security groupof its allowed caller — never CIDR blocks for internal traffic. SG references survive autoscaling, IP churn, and AZ failover; CIDR rules don’t.
| SG | Inbound | From | Why |
|---|---|---|---|
alb | TCP 443 | 0.0.0.0/0 | The only internet-facing rule in the stack |
app | TCP {app_port} (e.g. 3000/8080) | alb SG only | Nobody talks to the app except the load balancer |
worker | TCP {worker_port} (if it serves anything) | app SG only | Often needs noinbound at all — workers that only pull from a queue should have zero ingress rules |
rds | TCP 5432 | app SG + worker SG | Both tiers query Postgres; nothing else does |
redis | TCP 6379 | app SG + worker SG | Same shape as the DB rule |
In Terraform-ish form, the pattern that matters:
resource "aws_security_group" "app" {
ingress {
from_port = var.app_port
to_port = var.app_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # SG ref, not CIDR
}
}
resource "aws_security_group" "rds" {
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.worker.id]
}
# no egress block needed for RDS
}That’s the whole chain: ALB→app:port, app/worker→DB:5432, app/worker→Redis:6379, nothing else. No SSH rules (use SSM Session Manager or ECS Exec). No “office IP” rules on the database (use a bastion pattern or SSM port forwarding if you genuinely need psql access). No 0.0.0.0/0 anywhere except ALB:443.
Egress: it’s fine to leave app/worker egress open (-1 to 0.0.0.0/0) at this maturity level — they need to pull images, call third-party APIs, and reach DB/Redis. Locking down egress is a real control, but it’s a later one.
IAM roles, not access keys #
Every compute service gets its own IAM role (for ECS, a task role via ecs-tasks.amazonaws.comassume-role trust). Permissions are scoped to what that service actually touches — s3:GetObject/PutObject on one bucket ARN, sqs:SendMessage on one queue, secretsmanager:GetSecretValue on a path prefix like myapp/*. No AWSAdministratorAccesson a task role. No shared “app user” with an access key.
The litmus test: if there’s an AWS_ACCESS_KEY_IDin any environment variable anywhere in your stack, something is misconfigured. Roles issue temporary credentials automatically; there’s nothing to rotate, nothing to leak in a .env committed by accident.
Secrets from Secrets Manager, not env files #
Don’t bake DATABASE_URL into the task definition as a plaintext environment entry — task definitions are visible to anyone with ecs:DescribeTaskDefinition. Use the secrets block instead:
"secrets": [
{ "name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:...:secret:myapp/database-url" }
]The container runtime fetches the value at start, using the task role’s secretsmanager:GetSecretValue permission. The secret never appears in the task definition, the console, or your Terraform state for the service.
Round it out: enforce TLS to Postgres (RDS supports requiring SSL), enable in-transit encryption on ElastiCache, and let ACM manage the ALB cert so renewal is nobody’s job.
What this baseline deliberately skips — and when to add it #
This is a baseline, not a maximal posture. Three omissions are intentional:
WAF.Skipped because it costs money, needs tuning, and a default rule set on day one mostly generates false positives you’ll learn to ignore. Add it when you have real traffic and a concrete threat: credential stuffing on a login endpoint, scraping, an API you’re contractually required to rate-limit, or a compliance regime that names it.
VPC endpoints / PrivateLink.Skipped because NAT covers S3/ECR/Secrets Manager/SQS traffic fine at low volume. Add gateway endpoints (S3, DynamoDB — they’re free) once you’re past the prototype stage, and interface endpoints when NAT data-processing charges show up meaningfully on the bill — commonly ECR image pulls at scale — or when policy requires AWS API traffic never traverses the public internet.
AWS Network Firewall.Skipped because egress filtering at the network layer is an organizational control, not an app control. Add it when you have compliance requirements for egress inspection or you’re running enough workloads that “any compromised task can call any IP on the internet” is a risk someone is paid to care about. Until then it’s several hundred dollars a month of unconfigured appliance.
The point of a baseline is that it’s cheap enough to apply to everyenvironment — including staging, where the laziest configurations live and where real customer data somehow always ends up. Five security groups, two subnet tiers, roles instead of keys, secrets out of the task definition. Copy the table, diff it against your account, and fix what doesn’t match.
Review your cloud plan in Vylara, merge delivery changes as Git PRs, and deploy into your own AWS or Azure account when you’re ready.
Start free


