In this article, we'll walk through how I built a full DevSecOps pipeline on AWS EKS — from infrastructure provisioning with Terraform to GitOps delivery with ArgoCD, automated DNS with ExternalDNS, security scanning at every stage, and observability with Grafana. We'll cover every decision, every tool, and every step needed to replicate it.
Introduction
Deploying a microservices application to production isn't just about getting pods running. It's about building the surrounding system: automated infrastructure, secure pipelines, GitOps delivery, and observability — in a way that's repeatable and maintainable.
For this project, I took Google's Online Boutique (an 11-service e-commerce demo app) and built a production-grade AWS platform around it. The goal was to treat everything as code: infrastructure, delivery, DNS, monitoring — all automated, nothing manual.
Key Terminologies
Terraform: Infrastructure as Code tool used to provision and manage all AWS resources declaratively.
EKS (Elastic Kubernetes Service): AWS's managed Kubernetes service. Handles the control plane; we manage the worker nodes via managed node groups.
ArgoCD: A GitOps continuous delivery tool for Kubernetes. It watches a Git repository and automatically syncs the cluster state to match what's in the repo.
GitHub Actions: CI/CD platform built into GitHub. Used here for two pipelines: one for infrastructure, one for building and pushing container images.
AWS Load Balancer Controller (LBC): A Kubernetes controller that provisions AWS ALBs from Ingress objects. All three services (shop, Grafana, SonarQube) share a single ALB via ingress groups.
ExternalDNS: A Kubernetes controller that watches Ingress objects and automatically creates Route 53 DNS records when an ALB is provisioned. No manual DNS steps needed.
SonarQube: Static code analysis tool. Enforces a Quality Gate — the pipeline fails if code doesn't meet the defined standard.
Trivy: Container image vulnerability scanner. Runs before every ECR push.
Gitleaks: Scans commits for accidentally exposed secrets before anything else runs.
IRSA (IAM Roles for Service Accounts): AWS mechanism that gives Kubernetes pods scoped IAM permissions without static credentials.
Architecture Overview
The platform is split into two pipelines and five Terraform stacks.
Infrastructure pipeline
vpc → eks-cluster + ecr (parallel) → rds → eks-tools → argocd-app
Each stack reads outputs from the previous one via Terraform remote state; no hardcoded ARNs are passed between stacks.
Build pipeline
Push to main (src/)
│
▼
GitHub Actions (build.yml)
├── Gitleaks secret scan
├── SonarQube code quality gate
├── Docker build only changed services detected via git diff
├── Trivy image CVE scan
└── ECR push + commit updated image tags
│
▼
ArgoCD detects the new commit
│
▼
Cluster synced — new images running
Infrastructure diagram
Prerequisites
Before following along, have these ready:
- An active AWS account
- A domain name with a Route53 public hosted zone (
.xyzdomains are usually under $1), i.e., your domain points to the nameserver in your hosted zone - A GitHub account with the repo forked from here
- AWS CLI and Terraform installed locally (for the one-time bootstrap only)
Step 1: Fork and clone
Fork the repo and clone it locally:
git clone https://github.com/YOUR_USERNAME/microservices-demo.git
cd microservices-demo
The repo structure looks like this:
├── bootstrap/ # S3 state bucket (apply locally, one-time)
├── terraform/
│ ├── vpc/
│ ├── eks-cluster/
│ ├── eks-tools/ # LBC, ExternalDNS, ArgoCD, Grafana, SonarQube
│ ├── rds/
│ ├── ecr/
│ └── argocd-app/
├── kubernetes-manifests/ # All K8s objects — ArgoCD watches this
├── src/ # Source code for all 11 microservices
├── teardown.sh # Destroys all AWS resources ( can be run locally, but use the teardown pipeline)
└── .github/workflows/
├── terraform.yml # Infrastructure pipeline
├── build.yml # Build, scan and push pipeline
└── teardown.yml # Manual-only — triggers full teardown
Step 2: Bootstrap the S3 state bucket (local, one-time)
Terraform needs an S3 bucket to store remote state before it can run in CI. This is the only step you run locally; everything else is handled by the pipeline.
Open bootstrap/main.tf and change the bucket name:
resource "aws_s3_bucket" "terraform_state" {
bucket = "YOUR-BUCKET-NAME" # ← choose a unique name
}
Then apply:
cd bootstrap/
terraform init
terraform validate
terraform apply
This creates the bucket with versioning enabled and native S3 locking; no DynamoDB table needed.
Step 3: GitHub Actions IAM role (OIDC)
The pipeline authenticates to AWS using OIDC; no static access keys are stored in GitHub secrets.
In the AWS console, go to IAM → Identity providers → Add provider:
- Provider type:
OpenID Connect - Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
Create an IAM role with this trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USERNAME/microservices-demo:*"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}
Attach AdministratorAccess (this is a demo — scope it down for production). Note the role ARN.
Update the actuall values for YOUR_ACCOUNT_ID, and YOUR_GITHUB_USERNAME in the above policy.
Step 4: ACM Certificate
Go to AWS Certificate Manager → Request certificate:
- Add your domains or use a wildcard:
*.yourdomain.com
- Choose DNS validation: AWS offers a one-click button to create the validation CNAME in Route 53
- Wait until the status shows Issued
Note the certificate ARN.
Step 5: Update the code for your environment
Go through each file below and swap in your values.
i. S3 bucket name — every backend "s3" block and terraform_remote_state block across all stacks:
backend "s3" {
bucket = "YOUR-BUCKET-NAME" # ← same name from Step 2
...
}
ii. EKS access entry — terraform/eks-cluster/main.tf — lets your local kubectl work:
access_entries = {
my_user = {
principal_arn = "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_USERNAME"
...
}
}
iii. ArgoCD repo URL — terraform/argocd-app/main.tf:
repo_url = "https://github.com/YOUR_USERNAME/microservices-demo"
iv. Grafana — terraform/eks-tools/grafana-values.yaml:
grafana:
grafana.ini:
server:
root_url: https://grafana.yourdomain.com
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: YOUR_ACM_CERT_ARN
external-dns.alpha.kubernetes.io/hostname: grafana.yourdomain.com
hosts:
- grafana.yourdomain.com
v. SonarQube — terraform/eks-tools/sonarqube-values.yaml:
ingress:
annotations:
alb.ingress.kubernetes.io/certificate-arn: YOUR_ACM_CERT_ARN
external-dns.alpha.kubernetes.io/hostname: sonarqube.yourdomain.com
hosts:
- name: sonarqube.yourdomain.com
vi. Frontend — kubernetes-manifests/frontend.yaml:
annotations:
alb.ingress.kubernetes.io/certificate-arn: YOUR_ACM_CERT_ARN
external-dns.alpha.kubernetes.io/hostname: shop.yourdomain.com
spec:
rules:
- host: shop.yourdomain.com
Leave every other thing, especially alb.ingress.kubernetes.io/group.name, unchanged across all three — this is what makes all three services share a single ALB.
The exact values to update are YOUR_ACM_CERT_ARN, and .yourdomain.com
Step 6: GitHub Secrets
Go to Settings → Secrets and variables → Actions → New repository secret:
| Secret | Value | When |
|---|---|---|
AWS_ROLE_ARN |
ARN from Step 3 | Now |
SONAR_HOST_URL |
https://sonarqube.yourdomain.com |
Now (placeholder) |
SONAR_TOKEN |
Any random string | Now (updated after Step 8) |
SONAR_HOST_URL and SONAR_TOKEN must exist for the build pipeline to parse even before SonarQube is live — you'll replace them with real values after Step 8.
Step 7: Run the Terraform pipeline
Commit and push your changes to main, or trigger manually:
GitHub Actions → Terraform → Run workflow
The pipeline applies all stacks in order; this takes 20–30 minutes, mostly EKS cluster creation.
What gets created:
- VPC with public/private subnets across 2 AZs
- EKS cluster (Kubernetes 1.32) with 2× t3.large nodes, autoscaling to 6
- ECR repositories for all 11 services
- RDS PostgreSQL for SonarQube
- All cluster tooling: LBC, ExternalDNS, ArgoCD, Grafana, SonarQube, cert-manager
- ArgoCD Application pointing at your fork
Once eks-tools completes, ExternalDNS is running. As soon as ArgoCD syncs your Ingress objects and the ALB is provisioned, ExternalDNS automatically creates the Route 53 A records for all three subdomains; no manual DNS steps.
Step 8: SonarQube setup
Wait a few minutes for DNS to propagate, then:
- Visit
https://sonarqube.yourdomain.com - Login:
admin/admin; you'll be prompted to change the password - Go to My Account → Security → Generate Token, name it
github-actions - Copy the token; shown only once
Update your GitHub secrets with the real values:
| Secret | Value |
|---|---|
SONAR_HOST_URL |
https://sonarqube.yourdomain.com |
SONAR_TOKEN |
Token from above |
Fix the Quality Gate [optional*, *better to leave for later]** — the default gate requires test coverage that Google's demo code doesn't have. Go to Administration → Quality Gates, remove the coverage condition or create a permissive gate, and set it as the default.
You can skip this if you follow my code word-for-word; nothing breaks.
Step 9: Run the build pipeline [Trigger Manually]
Push any change inside src/, or trigger manually:
GitHub Actions → Build, Scan and Push → Run workflow
On the first run, all 11 services are built in parallel. On subsequent runs, only changed services are detected via git diff.
Step 10: Verify
# Configure local kubectl
aws eks update-kubeconfig --name online-boutique --region us-east-1
# All pods running
kubectl get pods -A
# ArgoCD app synced and healthy
kubectl get application -n argocd
# Confirm ExternalDNS created Route 53 records
aws route53 list-resource-record-sets \
--hosted-zone-id YOUR_HOSTED_ZONE_ID \
--query "ResourceRecordSets[?contains(Name, 'yourdomain')]"
Visit https://shop.yourdomain.com: you should see the Online Boutique storefront.
Accessing cluster tools
Grafana: https://grafana.yourdomain.com
username = admin
password = use the CLI command below to get it, or check from the console in Secret Manager.
aws secretsmanager get-secret-value \
--secret-id online-boutique/grafana-admin \
--region us-east-1 \
--query 'SecretString' \
--output text
ArgoCD
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d
kubectl port-forward svc/argocd-server -n argocd 8080:443
# Visit https://localhost:8080
Teardown
To destroy everything, trigger the teardown workflow manually from GitHub Actions:
GitHub Actions → Teardown → Run workflow
Or run locally: This will spam your folder with Terraform binaries and files, so you want to avoid this and use the pipeline.
chmod +x teardown.sh
./teardown.sh
The S3 state bucket is not deleted — remove it manually if needed.
Troubleshooting
DNS records not created
ExternalDNS only creates records after the ALB hostname appears in the Ingress status. Check logs:
kubectl logs -n kube-system deployment/external-dns
Ingress has no ADDRESS
The LBC is not running or healthy:
kubectl logs -n kube-system deployment/aws-load-balancer-controller
ArgoCD shows ImagePullBackOff
The build pipeline hasn't run yet or Trivy blocked a push. Check the build pipeline logs in GitHub Actions.
SonarQube quality gate fails
Assign a permissive quality gate under SonarQube → Administration → Quality Gates.
kubectl Unauthorised from your machine
Confirm your IAM user ARN matches the access_entries block in terraform/eks-cluster/main.tf, then re-run:
aws eks update-kubeconfig --name online-boutique --region us-east-1
Taking it further
This setup is intentionally straightforward — here are production-grade alternatives worth exploring as the project scales:
Log aggregation: the current setup has no centralised logging. EFK (Elasticsearch, Fluentd, Kibana) or Grafana Loki would give you log correlation alongside the existing Prometheus metrics
Service mesh: adding Istio or Linkerd gives you mTLS between pods, fine-grained traffic control, and distributed tracing without touching application code
Image signing: Cosign + Sigstore to sign images post-build and enforce signature verification at the cluster level with Kyverno or OPA Gatekeeper
Secrets management: External Secrets Operator pulling from AWS Secrets Manager instead of passing credentials through Helm values at apply time
Node provisioning: replace the managed node group with Karpenter for faster, more cost-efficient scaling with Spot instance support
Multi-environment: promote builds through
dev → staging → prodnamespaces or clusters using ArgoCD ApplicationSets and Kustomize overlays
Conclusion
If you followed these steps, you now have a full DevSecOps platform running on AWS EKS; infrastructure managed by Terraform and applied by CI, images built and scanned on every push, GitOps delivery via ArgoCD, DNS fully automated by ExternalDNS, and everything observable through Grafana. I hope this was helpful. Drop any questions in the comments below!





Top comments (0)