Polyglot stack demonstrating Zero Trust on Amazon EKS with SPIFFE/SPIRE mTLS, Kubernetes NetworkPolicy (AWS VPC CNI), and AWS IRSA.
┌────────────────────────────────────────────────────────────────────────────┐
│ AWS EKS Cluster │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────────────┐ │
│ │ public │ │ services namespace │ │
│ │ namespace │ │ │ │
│ │ │ │ ┌───────────┐ mTLS ┌─────────────┐ │ │
│ │ ┌─────────┐ │ HTTP │ │ api │◄──────►│ url-service │ │ │
│ │ │frontend │─┼────────►│ │ gateway │ SPIFFE │ (Go) │ │ │
│ │ │ (React) │ │ :8000 │ │ (Laravel) │ └──────┬──────┘ │ │
│ │ └─────────┘ │ │ └───────────┘ │ │ │
│ │ │ │ │ :5432 │ │
│ └─────────────┘ │ ┌───────────┐ ▼ │ │
│ │ │ worker │ ┌──────────┐ │ │
│ │ │ (Go) │────────►│ RDS │ │ │
│ │ └─────┬─────┘ IRSA │ Postgres │ │ │
│ │ │ └──────────┘ │ │
│ │ │ :443 │ │
│ │ ▼ │ │
│ │ ┌─────────┐ │ │
│ │ │ SQS │ │ │
│ │ └─────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
| Service | Technology | Namespace | Authentication |
|---|---|---|---|
| Frontend | React + Vite + Nginx | public | Network Policy |
| API Gateway | Laravel 12 (PHP) | services | SPIFFE mTLS (client) |
| URL Service | Go | services | SPIFFE mTLS (server) |
| Worker | Go | services | AWS IRSA |
| Database | RDS PostgreSQL | AWS | Secrets Manager |
| Queue | SQS | AWS | IRSA |
This demo implements defense in depth using two complementary security layers:
┌──────────────────────────────────────────────────────────────────┐
│ IDENTITY LAYER (L7) │
│ SPIFFE/SPIRE mTLS │
│ ──────────────────────────────────────────────────────── │
│ • X.509 SVID certificates per workload │
│ • Cryptographic identity verification │
│ • Mutual TLS between api-gateway ↔ url-service │
│ • No sidecar proxy - certificates used directly by app │
│ • Controls: CRYPTOGRAPHIC PROOF of caller identity │
│ │
│ api-gateway ◄──────── mTLS (X.509 SVID) ────────► url-service │
└──────────────────────────────────────────────────────────────────┘
+
┌──────────────────────────────────────────────────────────────────┐
│ NETWORK LAYER (L3/L4) - Swappable │
│ │
│ ┌─────────────────────┐ OR ┌─────────────────────┐ │
│ │ AWS VPC CNI │ │ Cilium │ │
│ │ + Network Policy │ │ │ │
│ │ Agent (current) │ │ │ │
│ │ │ │ │ │
│ │ • eBPF enforcement │ │ • eBPF enforcement │ │
│ │ • K8s NetworkPolicy│ │ • K8s NetworkPolicy│ │
│ │ • AWS native │ │ • L7 policy option │ │
│ │ • Low complexity │ │ • Hubble observ. │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ Same NetworkPolicy YAML manifests work with both! │
│ │
│ • Default deny all traffic │
│ • Explicit allow rules per service │
│ • IP/Port based filtering │
│ • Controls: WHO can connect to WHOM at network level │
└──────────────────────────────────────────────────────────────────┘
| NetworkPolicy Alone | SPIFFE/SPIRE Alone | Both Together |
|---|---|---|
| Can't verify WHO is calling | Can't block at network level | Defense in depth |
| IP spoofing possible | Pod could bypass if network allows | Network + Identity |
| No cryptographic identity | Relies on app to enforce | Layered security |
Example Attack Scenario:
- Attacker compromises a pod in
servicesnamespace - NetworkPolicy blocks - attacker can't reach
url-service(wrong pod label) - Even if attacker bypasses NetworkPolicy, SPIFFE/SPIRE blocks - no valid SVID certificate
- Workload identity via X.509 SVIDs
- Trust domain:
mol.la - Automatic certificate rotation
- api-gateway → url-service authenticated via SPIFFE ID
- url-service only accepts calls from
spiffe://mol.la/ns/services/sa/api-gateway
- AWS VPC CNI with Network Policy Agent (eBPF)
- Default deny all in
servicesnamespace - Explicit allow rules for each traffic flow
- Enforced at kernel level before traffic reaches pods
- IRSA (IAM Roles for Service Accounts)
- Secrets Manager for database credentials
- Least-privilege access
- No long-lived credentials in pods
All policies are in kubernetes/network-policies/. The services namespace uses a default-deny baseline.
| # | File | Purpose | From | To | Port |
|---|---|---|---|---|---|
| 00 | default-deny-all.yaml |
Block all traffic by default | * | * | * |
| 01 | allow-frontend-to-api-gateway.yaml |
Frontend → API Gateway | public/frontend | api-gateway | TCP 8000 |
| 02 | allow-api-gateway-to-url-service.yaml |
API Gateway → URL Service (ingress) | api-gateway | url-service | TCP 8443 |
| 03 | allow-url-service-egress-to-rds.yaml |
URL Service → RDS | url-service | 10.0.0.0/16 | TCP 5432 |
| 04 | allow-worker-egress-to-sqs.yaml |
Worker → AWS SQS | worker | any | TCP 443 |
| 05 | allow-kubelet-probes.yaml |
Health checks from kubelet | 10.0.0.0/16 | pods | TCP 8000/8080 |
| 06 | allow-dns-egress.yaml |
DNS resolution | all pods | kube-system | UDP/TCP 53 |
| 07 | allow-api-gateway-egress-to-url-service.yaml |
API Gateway → URL Service (egress) | api-gateway | url-service | TCP 8443 |
┌─────────────────────────────────────────┐
│ services namespace │
│ │
┌──────────┐ │ ┌─────────────┐ ┌──────────────┐ │ ┌─────────┐
│ frontend │──TCP:8000──▶│ │ api-gateway │─8443─│ url-service │──┼────▶│ RDS │
│ (public) │ │ └─────────────┘ mTLS └──────────────┘ │ │ :5432 │
└──────────┘ │ │ │ │ └─────────┘
│ │ │ │
│ ▼ ▼ │ ┌─────────┐
│ DNS:53 DNS:53 │ │ SQS │
│ │ │ :443 │
│ ┌─────────────┐ │ └────▲────┘
│ │ worker │────────────────────────┼──────────┘
│ └─────────────┘ │
│ │
└─────────────────────────────────────────┘
| Blocked Path | Reason |
|---|---|
| worker → api-gateway | No policy allows it |
| worker → url-service | No policy allows it |
| frontend → url-service | No policy allows it (must go through api-gateway) |
| url-service → api-gateway | No policy allows it |
| External → any pod | No ingress from outside cluster |
- AWS CLI configured with credentials (SSO or static keys)
- Terraform >= 1.6
- kubectl
- Docker (or Colima on macOS)
Apple Silicon (M-series) note: Images are built for
linux/amd64using Go's native cross-compiler. No QEMU emulation required. If using Colima, setDOCKER_HOSTbefore running scripts:export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock"
Provisions VPC, EKS, RDS, ECR, SQS, IAM roles, SPIRE, and the AWS Load Balancer Controller in two phases (AWS resources first, then Kubernetes/Helm components after kubeconfig is updated).
export AWS_PROFILE=your-profile
export AWS_REGION=us-east-1
export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
./scripts/terraform-apply.shAWS_ACCOUNT_ID=$AWS_ACCOUNT_ID AWS_REGION=$AWS_REGION ./scripts/build-and-push.shAWS_ACCOUNT_ID=$AWS_ACCOUNT_ID AWS_REGION=$AWS_REGION ./scripts/deploy-k8s.shA random APP_KEY is generated automatically on each deploy. To reuse a key across deploys (preserves sessions):
export APP_KEY="base64:$(python3 -c 'import base64,os; print(base64.b64encode(os.urandom(32)).decode())')"
APP_KEY=$APP_KEY AWS_ACCOUNT_ID=$AWS_ACCOUNT_ID AWS_REGION=$AWS_REGION ./scripts/deploy-k8s.sh# Check pods
kubectl get pods -n services
kubectl get pods -n public
# Check network policies
kubectl get networkpolicies -n services
# Get load balancer hostname
ALB=$(kubectl get svc frontend -n public -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "http://$ALB"mol.la
| Service | SPIFFE ID |
|---|---|
| api-gateway | spiffe://mol.la/ns/services/sa/api-gateway |
| url-service | spiffe://mol.la/ns/services/sa/url-service |
| worker | spiffe://mol.la/ns/services/sa/worker |
- SPIRE agent provides X.509 SVID to each workload
- api-gateway presents its SVID when calling url-service
- url-service validates api-gateway's SPIFFE ID
- Only
spiffe://mol.la/ns/services/sa/api-gatewayis authorized
| Service | Endpoint | Port | Protocol |
|---|---|---|---|
| Frontend | / |
80 | HTTP |
| API Gateway | /api/health |
8000 | HTTP |
| API Gateway | /api/shorten |
8000 | HTTP |
| API Gateway | /api/u/{code} |
8000 | HTTP |
| API Gateway | /api/identities |
8000 | HTTP |
| URL Service | /shorten |
8443 | HTTPS/mTLS |
| URL Service | /u/{code} |
8443 | HTTPS/mTLS |
| URL Service | /identity |
8443 | HTTPS/mTLS |
| URL Service | /health |
8080 | HTTP |
| Worker | /health |
8080 | HTTP |
# Get ALB hostname
ALB=$(kubectl get ingress -n public -o jsonpath='{.items[0].status.loadBalancer.ingress[0].hostname}')
# Shorten a URL
curl -X POST "http://$ALB/api/shorten" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
# Response: {"code":"abc123","url":"https://example.com"}
# Resolve the code
curl "http://$ALB/api/u/abc123"# Get all workload identities and trust chain
curl "http://$ALB/api/identities"# This should work (allowed by policy)
kubectl exec -n public deployment/frontend -- \
curl -s http://api-gateway.services.svc.cluster.local:80/health
# This should be BLOCKED (no policy)
kubectl exec -n services deployment/worker -- \
wget -q -O- --timeout=5 http://api-gateway.services.svc.cluster.local:80/healthRemove all AWS resources provisioned by this demo:
export AWS_PROFILE=your-profile
export AWS_REGION=us-east-1
# Delete Kubernetes resources first (removes ALB, target groups, security groups)
kubectl delete ingress --all -n public
kubectl delete svc --all -n public
kubectl delete svc --all -n services
# Destroy Terraform-managed infrastructure
terraform -chdir=infrastructure destroy -auto-approve
# Delete the manually-created ALB controller IAM resources (if applicable)
aws iam detach-role-policy \
--role-name zero-trust-eks-alb-controller \
--policy-arn arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):policy/AWSLoadBalancerControllerIAMPolicy \
--region $AWS_REGION 2>/dev/null || true
aws iam delete-role --role-name zero-trust-eks-alb-controller --region $AWS_REGION 2>/dev/null || true
aws iam delete-policy \
--policy-arn arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):policy/AWSLoadBalancerControllerIAMPolicy \
--region $AWS_REGION 2>/dev/null || trueNote: Delete Kubernetes LoadBalancer services and ingresses before running
terraform destroy, otherwise AWS will leave behind orphaned load balancers, target groups, and security groups that block VPC deletion.
- SPIFFE Trust Domain: Uses
mol.la. Updatekubernetes/identity/spire-registration-entries.yamlto change it. - IRSA Roles: Provisioned by Terraform. Service accounts are annotated with IAM role ARNs automatically.
- ALB Controller: Provisioned by Terraform via the
eks-chartsHelm repository. Subnet tags (kubernetes.io/role/elb) are applied automatically by the VPC module. - ECR Repositories: Created by Terraform with names
zero-trust-eks-{api-gateway,url-service,worker,frontend}. - APP_KEY: Laravel's encryption key. Generated fresh on each
deploy-k8s.shrun unlessAPP_KEYis set in the environment. A new key invalidates existing encrypted cookies.