AWS platform hardening with multi-account VPC design
Bringing order to AWS with Terraform-managed multi-account VPCs, IAM least privilege, subnet segmentation, and Kubernetes governance patterns.
The problem
The estate ran 20+ customer environments on AWS — the classic mid-stage setup: everything in one account, a handful of broad IAM roles shared by every service, and VPCs that had grown organically since the first deploy. A compromised EC2 key could read almost anything, a noisy neighbor could degrade the shared cluster, and nobody could say which security group rule was actually still in use.
The work was to bring structure and guardrails without breaking running workloads — which ruled out a big-bang rebuild. The resolution was a multi-account model with Terraform as the single source of truth, subnet segmentation as the blast-radius boundary, and Kubernetes governance that mirrored the account discipline one level down.
Multi-account and VPC design
I moved the estate toward a multi-account model with Terraform as the single source of truth:
- Network accounts and workload accounts separated so blast radius is contained per environment.
- VPC design with purpose-built tiers — public subnets only where traffic genuinely needs to reach the internet, private subnets with NAT for everything else.
- IAM least privilege — roles scoped to what a service actually needs, with policies reviewed as part of the code review.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "vpc-prod" }
}
resource "aws_subnet" "private_app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
tags = { Tier = "private" }
}
IAM least privilege was applied the same way — every role declares exactly the permissions its workload needs, reviewed as part of the code review:
resource "aws_iam_role" "app_service" {
name = "app-service"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "app_service" {
name = "app-service-s3"
role = aws_iam_role.app_service.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = ["arn:aws:s3:::app-bucket/app/*"]
}]
})
}
Kubernetes governance on EKS
The clusters needed the same discipline:
- RBAC and namespace isolation so teams could not reach across boundaries.
- Resource quotas and network policies so noisy neighbors could not degrade a shared cluster.
A default-deny network policy made the intent explicit — this one lets the app tier reach only the database tier, on one port:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-app-db
namespace: app
spec:
podSelector:
matchLabels: { tier: app }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { tier: db }
ports:
- protocol: TCP
port: 5432
- Centralized monitoring with CloudWatch alarms, log aggregation, and Grafana so the platform was observable before problems became incidents.
The operational layer
IaC only gets you halfway. I also established the runbooks and standards — cloud DevOps practices for pipeline patterns, change management, and incident response. A hardened platform without operating discipline reverts to chaos within a quarter.
Lessons learned
- Separation of accounts beats clever tagging. Tagging is advisory; account boundaries are enforced.
- Subnet segmentation is cheap insurance. It costs nothing at design time and prevents most lateral movement scenarios.
- Governance and automation are the same system. If the runbook is a document, it will go stale. If it is code, it stays honest.
Hardening is not a sprint. It is the accumulated effect of many small, boring, correct decisions applied consistently.