Design a PCI-DSS Compliant VPC for a Multi-Tier Application
Design a VPC architecture with isolated PCI cardholder data environment (CDE), network segmentation using NACLs and Security Groups, WAF, and Shield Advanced.
Your company processes credit card payments and must pass a PCI-DSS Level 1 assessment. The QSA (Qualified Security Assessor) requires network segmentation that isolates the Card Data Environment (CDE) from all other systems. You need to design the VPC architecture, prove network isolation with technical controls, and document your compensating controls.
The Problem
PCI-DSS requires that systems in the Cardholder Data Environment (CDE) be isolated from all other systems. “Isolated” means network controls that provably prevent unauthorized traffic — not just a firewall rule that could be misconfigured. AWS gives you multiple overlapping controls: NACLs, security groups, route tables, and VPC endpoints.
Requirement 1.3: Network access controls restrict inbound and outbound traffic to only that which is necessary.
Requirement 1.3.2: Restrict inbound traffic to system components in the CDE to that which is necessary.
VPC Architecture
VPC: 10.0.0.0/16
│
├── Public Subnets (10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24) — 3 AZs
│ ├── ALB (internet-facing, WAF + Shield attached)
│ ├── NAT Gateway (HA across AZs)
│ └── Bastion Host (replaced by Session Manager — no SSH port open)
│
├── Private App Subnets (10.0.10.0/24, 10.0.11.0/24, 10.0.12.0/24)
│ ├── ECS/EKS Worker Nodes (non-PCI services)
│ └── Lambda Functions (VPC-attached)
│
├── Private Data Subnets (10.0.20.0/24, 10.0.21.0/24, 10.0.22.0/24)
│ ├── RDS Cluster (Multi-AZ) — non-cardholder data only
│ ├── ElastiCache
│ └── OpenSearch
│
└── Isolated PCI Subnet (10.0.30.0/24, 10.0.31.0/24) — CDE
├── Payment Processing Service ONLY
├── Tokenization Service
├── No internet route table — ever
├── Dedicated NACLs (deny-all default, explicit allow only)
└── Only reachable from Private App Subnet on port 8443
Step 1: Route Table Isolation for CDE Subnet
The CDE subnet route table has no route to the internet gateway or NAT gateway — a misconfigured security group cannot override a missing route:
# Create route table for CDE subnets (no internet route)
aws ec2 create-route-table \
--vpc-id vpc-0abc123 \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=rtb-pci-cde}]'
# Add ONLY local VPC route (10.0.0.0/16 → local)
# Do NOT add: 0.0.0.0/0 → igw or 0.0.0.0/0 → nat
# Associate with CDE subnets
aws ec2 associate-route-table \
--subnet-id subnet-pci-az1 \
--route-table-id rtb-pci-cde
aws ec2 associate-route-table \
--subnet-id subnet-pci-az2 \
--route-table-id rtb-pci-cde
This guarantees that even if every security group is accidentally set to 0.0.0.0/0, the CDE cannot reach the internet.
Step 2: Network ACLs — Deny-All Default for CDE
NACLs are stateless and process rules in order. Set a deny-all default and add only necessary allows:
resource "aws_network_acl" "pci_cde" {
vpc_id = aws_vpc.main.id
subnet_ids = [aws_subnet.pci_az1.id, aws_subnet.pci_az2.id]
tags = { Name = "nacl-pci-cde" }
}
# Allow inbound from App tier on port 8443 only
resource "aws_network_acl_rule" "pci_allow_inbound_app" {
network_acl_id = aws_network_acl.pci_cde.id
rule_number = 100
egress = false
protocol = "tcp"
rule_action = "allow"
cidr_block = "10.0.10.0/22" # App subnet CIDR only
from_port = 8443
to_port = 8443
}
# Allow return traffic (ephemeral ports)
resource "aws_network_acl_rule" "pci_allow_return_traffic" {
network_acl_id = aws_network_acl.pci_cde.id
rule_number = 110
egress = true
protocol = "tcp"
rule_action = "allow"
cidr_block = "10.0.10.0/22"
from_port = 1024
to_port = 65535
}
# Deny everything else (explicit — belt AND suspenders)
resource "aws_network_acl_rule" "pci_deny_all_inbound" {
network_acl_id = aws_network_acl.pci_cde.id
rule_number = 32766
egress = false
protocol = "-1"
rule_action = "deny"
cidr_block = "0.0.0.0/0"
}
resource "aws_network_acl_rule" "pci_deny_all_outbound" {
network_acl_id = aws_network_acl.pci_cde.id
rule_number = 32766
egress = true
protocol = "-1"
rule_action = "deny"
cidr_block = "0.0.0.0/0"
}
Step 3: Security Groups — Least Privilege per Service
# CDE payment service security group
resource "aws_security_group" "pci_payment_service" {
name = "sg-pci-payment-service"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8443
to_port = 8443
protocol = "tcp"
security_groups = [aws_security_group.app_tier.id] # Only from app tier SG
description = "Payment API from app tier only"
}
# Egress: only to tokenization service and secrets manager VPC endpoint
egress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.pci_tokenization.id]
description = "Tokenization service"
}
egress {
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.secretsmanager.id]
description = "Secrets Manager VPC endpoint"
}
tags = { Name = "sg-pci-payment-service", PCIScope = "in-scope" }
}
Step 4: VPC Endpoints — No Internet Egress for PCI Data
The CDE must reach AWS services (Secrets Manager, KMS, CloudWatch Logs) without an internet path:
# Interface VPC endpoint for Secrets Manager
resource "aws_vpc_endpoint" "secretsmanager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.pci_az1.id, aws_subnet.pci_az2.id]
security_group_ids = [aws_security_group.vpc_endpoint_sg.id]
private_dns_enabled = true
tags = { Name = "vpce-secretsmanager-pci" }
}
# KMS endpoint — for decrypting card data
resource "aws_vpc_endpoint" "kms" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.kms"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.pci_az1.id, aws_subnet.pci_az2.id]
security_group_ids = [aws_security_group.vpc_endpoint_sg.id]
private_dns_enabled = true
}
Step 5: WAF on Public ALB
Attach WAF to the internet-facing ALB to protect against OWASP Top 10 attacks:
resource "aws_wafv2_web_acl" "public_alb" {
name = "public-alb-waf"
scope = "REGIONAL"
default_action { allow {} }
# AWS Managed Rules — OWASP Top 10
rule {
name = "AWSManagedRulesCommonRuleSet"
priority = 1
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "CommonRuleSet"
sampled_requests_enabled = true
}
}
# AWS Managed Rules — Known Bad Inputs (SQL injection, XSS, etc.)
rule {
name = "AWSManagedRulesKnownBadInputsRuleSet"
priority = 2
override_action { none {} }
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "KnownBadInputs"
sampled_requests_enabled = true
}
}
visibility_config {
cloudwatch_metrics_enabled = true
metric_name = "PublicALBWebACL"
sampled_requests_enabled = true
}
}
resource "aws_wafv2_web_acl_association" "alb" {
resource_arn = aws_lb.public.arn
web_acl_arn = aws_wafv2_web_acl.public_alb.arn
}
Step 6: VPC Flow Logs — Evidence for Auditors
PCI-DSS requires logging of all network traffic to and from the CDE:
resource "aws_flow_log" "pci_cde" {
iam_role_arn = aws_iam_role.flow_log.arn
log_destination = aws_cloudwatch_log_group.pci_flow_logs.arn
traffic_type = "ALL"
subnet_id = aws_subnet.pci_az1.id # Log per-subnet for CDE
}
# Retention: PCI-DSS requires 12 months minimum
resource "aws_cloudwatch_log_group" "pci_flow_logs" {
name = "/aws/vpc/flowlogs/pci-cde"
retention_in_days = 365
kms_key_id = aws_kms_key.log_encryption.arn
}
PCI-DSS Evidence Checklist
| Requirement | Technical Control | Evidence |
|---|---|---|
| 1.3: Restrict inbound CDE traffic | NACL deny-all + allow port 8443 from App tier only | NACL configuration export |
| 1.3.2: No direct public access to CDE | Route table has no IGW/NAT route | Route table screenshot + Terraform state |
| 4.2.1: Encrypt cardholder data in transit | All communication on port 443/8443 with TLS 1.2+ | ACM certificate + ALB listener config |
| 10.2: Audit logs for CDE | VPC Flow Logs + CloudTrail enabled | Log group with 365-day retention |
| 6.4: WAF protecting public web applications | WAF v2 with OWASP managed rules on ALB | WAF web ACL configuration |
- How to segment a VPC into public, private app, private data, and isolated PCI layers
- The difference between NACLs (stateless) and Security Groups (stateful) for defense in depth
- Why VPC endpoints eliminate internet egress for PCI data flows
- How to configure WAF for OWASP Top 10 protection on public-facing ALBs
- What evidence to collect for a PCI-DSS network segmentation audit
Have a similar scenario to share?
Production incidents are the best teachers. Submit your real-world scenario and help others learn.
Open Google FormRelated Scenarios
AWS Cloud Engineer Learning Path
Who Is This Path For? This path is designed for complete beginners who want to break into cloud computing as an AWS engineer. If you know …
Design a Multi-Region AWS Architecture for 99.99% Uptime
The Problem Your e-commerce application runs entirely in us-east-1. A single region failure would take the site down for hours — …
Design an IAM Strategy for a 500-Engineer Organization With SOC2 & PCI-DSS Compliance
The Problem Shared admin credentials violate the most fundamental security principle: least privilege. With shared credentials, you …