A step-by-step training guide to provision infrastructure and deploy the Blog Platform microservices from scratch on AWS.
Internet
│
▼
AWS ALB (Application Load Balancer)
│
├── /api/auth ──► auth-service (port 3001) ──► RDS auth_db
├── /api/posts ──► post-service (port 3002) ──► RDS posts_db
├── /api/comments──► comment-service(port 3003) ──► RDS comments_db
└── / ──► frontend (port 80)
AWS Services used:
- ECR – stores Docker images for all 4 services
- EKS – Kubernetes cluster running all service pods
- RDS MySQL 8.0 – managed relational database (one instance, three databases)
- ALB – routes external traffic into the cluster via AWS Load Balancer Controller
export AWS_ACCOUNT_ID=508262720940
export AWS_REGION=us-west-2
export CLUSTER_NAME=blog-cluster
export ECR_REGISTRY=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
export APP_DOMAIN=amazontechspace.com
# RDS
export RDS_IDENTIFIER=blog-platform-db
export RDS_HOST=blog-platform-db.cbgew20su7tx.us-west-2.rds.amazonaws.com
export DB_USER=admin
export DB_PASS=BlogPlatform2026Install the following tools before starting:
# AWS CLI
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target / # macOS
# For Linux: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-linux.html
# Configure AWS credentials
aws configure
# Enter: AWS Access Key ID, Secret Access Key, Region (us-west-2), Output format (json)
# eksctl
brew tap weaveworks/tap
brew install weaveworks/tap/eksctl
# kubectl
brew install kubectl
# Helm (for ALB controller)
brew install helm
# Docker Desktop – https://www.docker.com/products/docker-desktop/
# Node.js 20+
brew install node@20Verify installs:
aws --version
eksctl version
kubectl version --client
helm version
docker --version
node --versionECR stores the Docker images that EKS pulls during deployment.
# Authenticate Docker with ECR
aws ecr get-login-password --region $AWS_REGION \
| docker login --username AWS --password-stdin $ECR_REGISTRY
# Create one repository per service
aws ecr create-repository --repository-name blog-auth-service --region $AWS_REGION
aws ecr create-repository --repository-name blog-post-service --region $AWS_REGION
aws ecr create-repository --repository-name blog-comment-service --region $AWS_REGION
aws ecr create-repository --repository-name blog-frontend --region $AWS_REGION
# Verify
aws ecr describe-repositories --region $AWS_REGION \
--query 'repositories[].repositoryName' --output tableYou should see four repositories:
blog-auth-service
blog-post-service
blog-comment-service
blog-frontend
This creates a managed Kubernetes cluster with 2 worker nodes (auto-scaling to 3).
eksctl create cluster \
--name $CLUSTER_NAME \
--region $AWS_REGION \
--nodegroup-name blog-nodes \
--node-type t3.medium \
--nodes 2 \
--nodes-min 1 \
--nodes-max 3 \
--managedThis takes 15–20 minutes. eksctl creates the VPC, subnets, node group, and configures kubectl automatically.
Verify the cluster is ready:
kubectl get nodes
# Expected: 2 nodes with STATUS = ReadySave the VPC ID for the RDS step:
export VPC_ID=$(aws eks describe-cluster \
--name $CLUSTER_NAME --region $AWS_REGION \
--query 'cluster.resourcesVpcConfig.vpcId' --output text)
echo "VPC ID: $VPC_ID"# Create the security group
export RDS_SG_ID=$(aws ec2 create-security-group \
--group-name blog-rds-sg \
--description "Allow MySQL from EKS nodes" \
--vpc-id $VPC_ID \
--region $AWS_REGION \
--query 'GroupId' --output text)
echo "RDS Security Group: $RDS_SG_ID"Get the EKS node security group and allow it to reach MySQL:
# Get the EKS node security group ID
export EKS_NODE_SG=$(aws eks describe-cluster \
--name $CLUSTER_NAME --region $AWS_REGION \
--query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text)
# Allow MySQL (3306) from EKS nodes
aws ec2 authorize-security-group-ingress \
--group-id $RDS_SG_ID \
--protocol tcp \
--port 3306 \
--source-group $EKS_NODE_SG \
--region $AWS_REGION# Get private subnets from the EKS VPC
export SUBNET_IDS=$(aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'Subnets[].SubnetId' \
--output text | tr '\t' ',')
aws rds create-db-subnet-group \
--db-subnet-group-name blog-db-subnet-group \
--db-subnet-group-description "Blog platform DB subnets" \
--subnet-ids $(echo $SUBNET_IDS | tr ',' ' ') \
--region $AWS_REGIONaws rds create-db-instance \
--db-instance-identifier $RDS_IDENTIFIER \
--db-instance-class db.t3.micro \
--engine mysql \
--engine-version 8.0 \
--master-username $DB_USER \
--master-user-password $DB_PASS \
--allocated-storage 20 \
--storage-type gp2 \
--vpc-security-group-ids $RDS_SG_ID \
--db-subnet-group-name blog-db-subnet-group \
--no-publicly-accessible \
--backup-retention-period 7 \
--region $AWS_REGIONThis takes 5–10 minutes. Wait for it to become available:
aws rds wait db-instance-available \
--db-instance-identifier $RDS_IDENTIFIER \
--region $AWS_REGION
echo "RDS is ready!"
# Get the endpoint
export RDS_HOST=$(aws rds describe-db-instances \
--db-instance-identifier $RDS_IDENTIFIER \
--region $AWS_REGION \
--query 'DBInstances[0].Endpoint.Address' --output text)
echo "RDS Endpoint: $RDS_HOST"Run this from inside a temporary pod in the cluster (since RDS is not publicly accessible):
# Launch a temporary MySQL client pod
kubectl run mysql-client --rm -it \
--image=mysql:8.0 \
--restart=Never \
-- mysql -h $RDS_HOST -u $DB_USER -p$DB_PASS \
-e "CREATE DATABASE IF NOT EXISTS auth_db;
CREATE DATABASE IF NOT EXISTS posts_db;
CREATE DATABASE IF NOT EXISTS comments_db;
SHOW DATABASES;"Expected output includes: auth_db, posts_db, comments_db.
The ALB Controller lets Kubernetes create an AWS Application Load Balancer via an Ingress resource.
eksctl utils associate-iam-oidc-provider \
--cluster $CLUSTER_NAME \
--region $AWS_REGION \
--approvecurl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.7.1/docs/install/iam_policy.json
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file://iam_policy.json \
--region $AWS_REGIONeksctl create iamserviceaccount \
--cluster $CLUSTER_NAME \
--namespace kube-system \
--name aws-load-balancer-controller \
--role-name AmazonEKSLoadBalancerControllerRole \
--attach-policy-arn arn:aws:iam::$AWS_ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy \
--approve \
--region $AWS_REGIONhelm 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=$CLUSTER_NAME \
--set serviceAccount.create=false \
--set serviceAccount.name=aws-load-balancer-controllerVerify it is running:
kubectl get deployment -n kube-system aws-load-balancer-controller
# Expected: READY 2/2The Prisma schema is pushed to RDS once before deploying pods. Pods do NOT run
prisma db push on startup (that would cause data loss with multiple replicas).
Run from inside the cluster using a temporary pod:
# Auth DB schema
kubectl run prisma-auth --rm -it \
--image=$ECR_REGISTRY/blog-auth-service:latest \
--restart=Never \
--env="DATABASE_URL=mysql://$DB_USER:$DB_PASS@$RDS_HOST:3306/auth_db" \
-- sh -c "npx prisma db push --skip-generate"
# Posts DB schema
kubectl run prisma-posts --rm -it \
--image=$ECR_REGISTRY/blog-post-service:latest \
--restart=Never \
--env="DATABASE_URL=mysql://$DB_USER:$DB_PASS@$RDS_HOST:3306/posts_db" \
-- sh -c "npx prisma db push --skip-generate"
# Comments DB schema
kubectl run prisma-comments --rm -it \
--image=$ECR_REGISTRY/blog-comment-service:latest \
--restart=Never \
--env="DATABASE_URL=mysql://$DB_USER:$DB_PASS@$RDS_HOST:3306/comments_db" \
-- sh -c "npx prisma db push --skip-generate"Run this step again only when the Prisma schema file changes.
Run all build commands from the project root directory.
# Authenticate Docker with ECR (refresh token expires in 12 hours)
aws ecr get-login-password --region $AWS_REGION \
| docker login --username AWS --password-stdin $ECR_REGISTRYdocker build -t blog-auth-service ./services/auth-servicedocker build -t blog-post-service ./services/post-servicedocker build -t blog-comment-service ./services/comment-servicedocker build \
--build-arg VITE_API_URL=http://$APP_DOMAIN \
-t blog-frontend \
./frontenddocker tag blog-auth-service $ECR_REGISTRY/blog-auth-service:latest
docker tag blog-post-service $ECR_REGISTRY/blog-post-service:latest
docker tag blog-comment-service $ECR_REGISTRY/blog-comment-service:latest
docker tag blog-frontend $ECR_REGISTRY/blog-frontend:latestdocker push $ECR_REGISTRY/blog-auth-service:latest
docker push $ECR_REGISTRY/blog-post-service:latest
docker push $ECR_REGISTRY/blog-comment-service:latest
docker push $ECR_REGISTRY/blog-frontend:latestVerify images are in ECR:
aws ecr list-images --repository-name blog-auth-service --region $AWS_REGION
aws ecr list-images --repository-name blog-post-service --region $AWS_REGION
aws ecr list-images --repository-name blog-comment-service --region $AWS_REGION
aws ecr list-images --repository-name blog-frontend --region $AWS_REGIONkubectl apply -f k8s/namespace.yaml
kubectl get namespace blogkubectl apply -f k8s/secrets.yaml
kubectl get secret blog-secrets -n blog# Apply Deployment
kubectl apply -f frontend/deployment.yaml
# Apply Service (ClusterIP on port 80)
kubectl apply -f frontend/service.yaml
# Verify
kubectl get pods -n blog -l app=frontend
kubectl get service frontend -n blogWait until pods are Running:
kubectl rollout status deployment/frontend -n blog# Apply Deployment
kubectl apply -f k8s/post-service/deployment.yaml
# Apply Service (ClusterIP on port 3002)
kubectl apply -f k8s/post-service/service.yaml
# Apply HPA (auto-scales 2–5 replicas at 70% CPU)
kubectl apply -f k8s/post-service/hpa.yaml
# Verify
kubectl get pods -n blog -l app=post-service
kubectl rollout status deployment/post-service -n blog# Apply Deployment
kubectl apply -f k8s/comment-service/deployment.yaml
# Apply Service (ClusterIP on port 3003)
kubectl apply -f k8s/comment-service/service.yaml
# Apply HPA (auto-scales 2–5 replicas at 70% CPU)
kubectl apply -f k8s/comment-service/hpa.yaml
# Verify
kubectl get pods -n blog -l app=comment-service
kubectl rollout status deployment/comment-service -n blog# Apply Deployment
kubectl apply -f k8s/auth-service/deployment.yaml
# Apply Service (ClusterIP on port 3001)
kubectl apply -f k8s/auth-service/service.yaml
# Apply HPA (auto-scales 2–5 replicas at 70% CPU)
kubectl apply -f k8s/auth-service/hpa.yaml
# Verify
kubectl get pods -n blog -l app=auth-service
kubectl rollout status deployment/auth-service -n blogkubectl apply -f k8s/ingress.yaml
# Watch ALB being provisioned (takes 2–3 minutes)
kubectl get ingress -n blog --watchOnce the ADDRESS column fills in, note the ALB DNS name:
export ALB_DNS=$(kubectl get ingress blog-ingress -n blog \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "ALB DNS: $ALB_DNS"In your DNS provider (Route 53, Cloudflare, etc.), create a CNAME record:
Type: CNAME
Name: amazontechspace.com (or @ for root)
Value: <ALB_DNS from above>
TTL: 300
If using Route 53:
# Get your hosted zone ID
export ZONE_ID=$(aws route53 list-hosted-zones \
--query "HostedZones[?Name=='amazontechspace.com.'].Id" \
--output text | cut -d'/' -f3)
# Create the record
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch "{
\"Changes\": [{
\"Action\": \"UPSERT\",
\"ResourceRecordSet\": {
\"Name\": \"amazontechspace.com\",
\"Type\": \"CNAME\",
\"TTL\": 300,
\"ResourceRecords\": [{\"Value\": \"$ALB_DNS\"}]
}
}]
}"# All pods in the blog namespace
kubectl get pods -n blog
# All services
kubectl get services -n blog
# Ingress with ALB address
kubectl get ingress -n blog
# HPA status
kubectl get hpa -n blog
# Test each endpoint
curl http://$APP_DOMAIN/api/auth/health
curl http://$APP_DOMAIN/api/posts/health
# (Open browser) http://amazontechspace.comExpected pod output:
NAME READY STATUS RESTARTS
auth-service-xxxx-xxxx 1/1 Running 0
auth-service-xxxx-yyyy 1/1 Running 0
comment-service-xxxx-xxxx 1/1 Running 0
comment-service-xxxx-yyyy 1/1 Running 0
frontend-xxxx-xxxx 1/1 Running 0
frontend-xxxx-yyyy 1/1 Running 0
post-service-xxxx-xxxx 1/1 Running 0
post-service-xxxx-yyyy 1/1 Running 0
# 1. Rebuild the image
docker build -t blog-post-service ./services/post-service
# 2. Tag with a new version (use git SHA for traceability)
export GIT_SHA=$(git rev-parse --short HEAD)
docker tag blog-post-service $ECR_REGISTRY/blog-post-service:$GIT_SHA
docker tag blog-post-service $ECR_REGISTRY/blog-post-service:latest
# 3. Push to ECR
docker push $ECR_REGISTRY/blog-post-service:$GIT_SHA
docker push $ECR_REGISTRY/blog-post-service:latest
# 4. Roll out the new image (triggers a rolling update)
kubectl set image deployment/post-service \
post-service=$ECR_REGISTRY/blog-post-service:$GIT_SHA \
-n blog
# 5. Watch the rollout
kubectl rollout status deployment/post-service -n blog# Describe a pod (see Events if something fails)
kubectl describe pod <pod-name> -n blog
# View logs
kubectl logs -l app=post-service -n blog --tail=50
# Follow live logs from all post-service pods
kubectl logs -f -l app=post-service -n blog
# Exec into a running pod
kubectl exec -it <pod-name> -n blog -- sh
# Check HPA scaling decisions
kubectl describe hpa post-service-hpa -n blog
# Restart all pods of a service (rolling restart)
kubectl rollout restart deployment/post-service -n blog
# Delete and recreate a stuck pod
kubectl delete pod <pod-name> -n blogFor local testing without AWS, use docker-compose which runs MySQL locally:
# Start all services
docker-compose up --build
# Stop (keeps data)
docker-compose down
# Stop AND delete all data
docker-compose down -vServices will be available at:
- Frontend: http://localhost
- Auth service: http://localhost:3001
- Post service: http://localhost:3002
- Comment service: http://localhost:3003
prisma db pushruns automatically on startup in docker-compose (safe for single-instance local dev). In production (k8s), schema is applied once manually (Step 5) — NOT on pod startup.
| Decision | Reason |
|---|---|
prisma db push removed from k8s pod startup |
With replicas: 2, both pods run it simultaneously causing RDS table drops and data loss |
| Schema initialized once via a temp pod (Step 5) | Safe single-run ensures no race conditions |
All services use ClusterIP |
Internal-only; ALB is the single public entry point |
| HPA on all services (2–5 replicas, 70% CPU) | Handles traffic spikes without manual scaling |
| Separate RDS databases per service | Follows microservice principle of isolated data stores |