Architecture Overview
Internet → IGW → ALB (Public Subnet) → EKS Pods (Private Subnet)
↓
PostgreSQL RDS Master
(Multi-AZ Slave in AZ-2)
Prerequisites
# Install required tools
aws --version # AWS CLI v2
terraform --version # Terraform >= 1.5
kubectl version # kubectl >= 1.28
helm version # Helm >= 3.12
eksctl version # eksctl >= 0.160
PHASE 1 — Terraform Backend Setup
Step 1.1 — S3 Bucket + DynamoDB for Terraform State
# backend-setup/main.tf
provider "aws" {
region = "eu-central-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "terraform-state-${data.aws_caller_identity.current.account_id}"
force_destroy = false
tags = { Name = "terraform-state", Environment = "production" }
}
resource "aws_s3_bucket_versioning" "state_versioning" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state_encryption" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.terraform_key.arn
}
}
}
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
tags = { Name = "terraform-state-lock" }
}
resource "aws_kms_key" "terraform_key" {
description = "KMS key for Terraform state encryption"
deletion_window_in_days = 7
enable_key_rotation = true
}
data "aws_caller_identity" "current" {}
cd backend-setup
terraform init
terraform apply
Step 1.2 — Configure Terraform Backend
# backend.tf (add to main project)
terraform {
backend "s3" {
bucket = "terraform-state-YOUR_ACCOUNT_ID"
key = "production/terraform.tfstate"
region = "eu-central-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
PHASE 2 — KMS Customer Managed Key
# modules/kms/main.tf
resource "aws_kms_key" "main" {
description = "Customer managed key for RDS, Secrets, EKS"
deletion_window_in_days = 7
enable_key_rotation = true
multi_region = false
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM User Permissions"
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.account_id}:root" }
Action = "kms:*"
Resource = "*"
}
]
})
tags = { Name = "production-cmk" }
}
resource "aws_kms_alias" "main" {
name = "alias/production-cmk"
target_key_id = aws_kms_key.main.key_id
}
output "key_arn" { value = aws_kms_key.main.arn }
output "key_id" { value = aws_kms_key.main.key_id }
PHASE 3 — VPC and Networking
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "production-vpc" }
}
# --- Subnets ---
resource "aws_subnet" "private_az1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "eu-central-1a"
tags = {
Name = "private-subnet-az1"
"kubernetes.io/role/internal-elb" = "1"
}
}
resource "aws_subnet" "private_az2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "eu-central-1b"
tags = {
Name = "private-subnet-az2"
"kubernetes.io/role/internal-elb" = "1"
}
}
resource "aws_subnet" "public_az1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "eu-central-1a"
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-az1"
"kubernetes.io/role/elb" = "1"
}
}
resource "aws_subnet" "public_az2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = "eu-central-1b"
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-az2"
"kubernetes.io/role/elb" = "1"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "production-igw" }
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = { Name = "nat-eip" }
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_az1.id
tags = { Name = "production-nat" }
depends_on = [aws_internet_gateway.main]
}
# --- Route Tables ---
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "public-rt" }
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = { Name = "private-rt" }
}
resource "aws_route_table_association" "public_az1" {
subnet_id = aws_subnet.public_az1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_az2" {
subnet_id = aws_subnet.public_az2.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_az1" {
subnet_id = aws_subnet.private_az1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_az2" {
subnet_id = aws_subnet.private_az2.id
route_table_id = aws_route_table.private.id
}
# --- VPC Flow Logs ---
resource "aws_flow_log" "main" {
iam_role_arn = aws_iam_role.flow_log.arn
log_destination = aws_cloudwatch_log_group.flow_logs.arn
traffic_type = "ALL"
vpc_id = aws_vpc.main.id
}
resource "aws_cloudwatch_log_group" "flow_logs" {
name = "/aws/vpc/flow-logs"
retention_in_days = 30
}
resource "aws_iam_role" "flow_log" {
name = "vpc-flow-log-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "flow_log" {
role = aws_iam_role.flow_log.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "*"
}]
})
}
PHASE 4 — RDS PostgreSQL (Multi-AZ)
# modules/rds/main.tf
resource "aws_db_subnet_group" "main" {
name = "production-db-subnet-group"
subnet_ids = [var.private_subnet_az1_id, var.private_subnet_az2_id]
tags = { Name = "production-db-subnet-group" }
}
resource "aws_security_group" "rds" {
name = "rds-sg"
description = "Allow PostgreSQL from EKS"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.eks_node_sg_id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "master" {
identifier = "production-postgres-master"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.t3.medium"
allocated_storage = 20
max_allocated_storage = 100
storage_type = "gp3"
storage_encrypted = true
kms_key_id = var.kms_key_arn
db_name = "productiondb"
username = "dbadmin"
password = var.db_password # pulled from Secrets Manager
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
deletion_protection = true
skip_final_snapshot = false
final_snapshot_identifier = "production-final-snapshot"
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
tags = { Name = "production-postgres" }
}
PHASE 5 — Secrets Manager
# modules/secrets/main.tf
resource "aws_secretsmanager_secret" "db_credentials" {
name = "production/db/credentials"
kms_key_id = var.kms_key_arn
tags = { Name = "db-credentials" }
}
resource "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = aws_secretsmanager_secret.db_credentials.id
secret_string = jsonencode({
username = "dbadmin"
password = var.db_password
host = var.rds_endpoint
port = 5432
dbname = "productiondb"
})
}
PHASE 6 — ECR Repositories
# modules/ecr/main.tf
locals {
services = ["api-service", "auth-service", "document-service", "notification-service"]
}
resource "aws_ecr_repository" "services" {
for_each = toset(local.services)
name = each.value
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "KMS"
kms_key = var.kms_key_arn
}
tags = { Name = each.value }
}
resource "aws_ecr_lifecycle_policy" "services" {
for_each = aws_ecr_repository.services
repository = each.value.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = { type = "expire" }
}]
})
}
PHASE 7 — EKS Cluster
# modules/eks/main.tf
resource "aws_eks_cluster" "main" {
name = "production-eks"
role_arn = aws_iam_role.eks_cluster.arn
version = "1.28"
vpc_config {
subnet_ids = [var.private_subnet_az1_id, var.private_subnet_az2_id]
endpoint_private_access = true
endpoint_public_access = true
public_access_cidrs = ["0.0.0.0/0"]
}
encryption_config {
provider { key_arn = var.kms_key_arn }
resources = ["secrets"]
}
enabled_cluster_log_types = ["api", "audit", "authenticator"]
depends_on = [
aws_iam_role_policy_attachment.eks_cluster_policy
]
tags = { Name = "production-eks" }
}
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "production-node-group"
node_role_arn = aws_iam_role.eks_node.arn
subnet_ids = [var.private_subnet_az1_id, var.private_subnet_az2_id]
instance_types = ["t3.medium"]
disk_size = 20
scaling_config {
desired_size = 2
min_size = 1
max_size = 4
}
update_config {
max_unavailable = 1
}
depends_on = [
aws_iam_role_policy_attachment.eks_worker_node_policy,
aws_iam_role_policy_attachment.eks_cni_policy,
aws_iam_role_policy_attachment.ecr_read_only,
]
}
# --- IAM Roles ---
resource "aws_iam_role" "eks_cluster" {
name = "eks-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.eks_cluster.name
}
resource "aws_iam_role" "eks_node" {
name = "eks-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "eks_worker_node_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
role = aws_iam_role.eks_node.name
}
resource "aws_iam_role_policy_attachment" "eks_cni_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
role = aws_iam_role.eks_node.name
}
resource "aws_iam_role_policy_attachment" "ecr_read_only" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
role = aws_iam_role.eks_node.name
}
PHASE 8 — Kubernetes Manifests (Microservices)
Step 8.1 — Namespaces and Secrets
# Connect to cluster
aws eks update-kubeconfig --region eu-central-1 --name production-eks
# Create namespace
kubectl create namespace production
# k8s/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: production
type: Opaque
stringData:
DB_HOST: "your-rds-endpoint.eu-central-1.rds.amazonaws.com"
DB_PORT: "5432"
DB_NAME: "productiondb"
DB_USER: "dbadmin"
DB_PASSWORD: "your-password" # use External Secrets Operator in production
Step 8.2 — API Service
# k8s/api-service.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: api-service
template:
metadata:
labels:
app: api-service
spec:
containers:
- name: api-service
image: YOUR_ACCOUNT_ID.dkr.ecr.eu-central-1.amazonaws.com/api-service:latest
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: db-credentials
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: api-service
namespace: production
spec:
selector:
app: api-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP
Step 8.3 — ALB Ingress with Path-Based Routing
# Install AWS Load Balancer Controller
helm repo add eks https://aws.github.io/eks-charts
helm repo update
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=production-eks \
--set serviceAccount.create=true \
--set region=eu-central-1 \
--set vpcId=YOUR_VPC_ID
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: production-ingress
namespace: production
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/ssl-redirect: '443'
spec:
rules:
- http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
- path: /authentication
pathType: Prefix
backend:
service:
name: auth-service
port:
number: 80
- path: /document
pathType: Prefix
backend:
service:
name: document-service
port:
number: 80
- path: /notification
pathType: Prefix
backend:
service:
name: notification-service
port:
number: 80
PHASE 9 — S3 File Storage with VPC Endpoint
# modules/s3/main.tf
resource "aws_s3_bucket" "file_storage" {
bucket = "production-file-storage-${var.account_id}"
tags = { Name = "production-file-storage" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "file_storage" {
bucket = aws_s3_bucket.file_storage.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.kms_key_arn
}
}
}
resource "aws_s3_bucket_versioning" "file_storage" {
bucket = aws_s3_bucket.file_storage.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_public_access_block" "file_storage" {
bucket = aws_s3_bucket.file_storage.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# VPC Endpoint for private S3 access
resource "aws_vpc_endpoint" "s3" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.eu-central-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [var.private_route_table_id]
tags = { Name = "s3-vpc-endpoint" }
}
PHASE 10 — CloudWatch Alarms + SNS
# modules/monitoring/main.tf
resource "aws_sns_topic" "alerts" {
name = "production-alerts"
kms_master_key_id = var.kms_key_id
}
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic.alerts.arn
protocol = "email"
endpoint = var.alert_email
}
# RDS CPU Alarm
resource "aws_cloudwatch_metric_alarm" "rds_cpu" {
alarm_name = "rds-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/RDS"
period = 120
statistic = "Average"
threshold = 80
alarm_description = "RDS CPU above 80%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = { DBInstanceIdentifier = var.rds_identifier }
}
# EKS Node CPU Alarm
resource "aws_cloudwatch_metric_alarm" "eks_cpu" {
alarm_name = "eks-node-high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "node_cpu_utilization"
namespace = "ContainerInsights"
period = 60
statistic = "Average"
threshold = 80
alarm_description = "EKS node CPU above 80%"
alarm_actions = [aws_sns_topic.alerts.arn]
dimensions = { ClusterName = var.eks_cluster_name }
}
PHASE 11 — Security (GuardDuty, Security Hub, CloudTrail)
# modules/security/main.tf
# GuardDuty
resource "aws_guardduty_detector" "main" {
enable = true
datasources {
s3_logs { enable = true }
kubernetes { audit_logs { enable = true } }
malware_protection { scan_ec2_instance_with_findings { ebs_volumes { enable = true } } }
}
}
# Security Hub
resource "aws_securityhub_account" "main" {}
resource "aws_securityhub_standards_subscription" "cis" {
depends_on = [aws_securityhub_account.main]
standards_arn = "arn:aws:securityhub:eu-central-1::standards/cis-aws-foundations-benchmark/v/1.4.0"
}
# CloudTrail
resource "aws_cloudtrail" "main" {
name = "production-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail.id
include_global_service_events = true
is_multi_region_trail = true
enable_log_file_validation = true
kms_key_id = var.kms_key_arn
event_selector {
read_write_type = "All"
include_management_events = true
data_resource {
type = "AWS::S3::Object"
values = ["arn:aws:s3:::"]
}
}
}
resource "aws_s3_bucket" "cloudtrail" {
bucket = "cloudtrail-logs-${var.account_id}"
force_destroy = false
}
PHASE 12 — AWS Amplify Frontend
# modules/amplify/main.tf
resource "aws_amplify_app" "frontend" {
name = "production-frontend"
repository = var.github_repo_url
build_spec = <<-EOT
version: 1
frontend:
phases:
preBuild:
commands:
- npm install
build:
commands:
- npm run build
artifacts:
baseDirectory: build
files:
- '**/*'
cache:
paths:
- node_modules/**/*
EOT
environment_variables = {
REACT_APP_API_URL = var.alb_dns_name
}
}
resource "aws_amplify_branch" "main" {
app_id = aws_amplify_app.frontend.id
branch_name = "main"
stage = "PRODUCTION"
framework = "React"
}
PHASE 13 — Deploy Everything
# 1. Init and apply Terraform
terraform init
terraform plan -out=tfplan
terraform apply tfplan
# 2. Connect to EKS
aws eks update-kubeconfig --region eu-central-1 --name production-eks
# 3. Deploy Kubernetes manifests
kubectl apply -f k8s/secrets.yaml
kubectl apply -f k8s/api-service.yaml
kubectl apply -f k8s/auth-service.yaml
kubectl apply -f k8s/document-service.yaml
kubectl apply -f k8s/notification-service.yaml
kubectl apply -f k8s/ingress.yaml
# 4. Verify everything
kubectl get pods -n production
kubectl get ingress -n production
kubectl get svc -n production
Project Folder Structure
.
├── backend-setup/
│ └── main.tf
├── modules/
│ ├── kms/
│ ├── vpc/
│ ├── rds/
│ ├── secrets/
│ ├── ecr/
│ ├── eks/
│ ├── s3/
│ ├── monitoring/
│ ├── security/
│ └── amplify/
├── k8s/
│ ├── secrets.yaml
│ ├── api-service.yaml
│ ├── auth-service.yaml
│ ├── document-service.yaml
│ ├── notification-service.yaml
│ └── ingress.yaml
├── main.tf
├── variables.tf
├── outputs.tf
└── backend.tf
Estimated Cost (eu-central-1)
| Service | Cost/Month |
|---|---|
| EKS Cluster | ~$73 |
| EC2 Nodes (2x t3.medium) | ~$60 |
| RDS PostgreSQL Multi-AZ (t3.medium) | ~$90 |
| NAT Gateway | ~$35 |
| ALB | ~$20 |
| S3 + CloudTrail | ~$5 |
| Total | ~$283/month |
Tip: Use spot instances for EKS nodes in dev/staging to cut costs by 70%.
Top comments (0)