DEV Community

Cover image for Building a DevSecOps Pipeline on AWS EKS with Terraform, ArgoCD, and GitHub Actions
Victor Robin
Victor Robin

Posted on

Building a DevSecOps Pipeline on AWS EKS with Terraform, ArgoCD, and GitHub Actions

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Infrastructure diagram


Prerequisites

Before following along, have these ready:

  • An active AWS account
  • A domain name with a Route53 public hosted zone (.xyz domains 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then apply:

cd bootstrap/
terraform init
terraform validate
terraform apply
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
  • 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
  ...
}
Enter fullscreen mode Exit fullscreen mode

ii. EKS access entryterraform/eks-cluster/main.tf — lets your local kubectl work:

access_entries = {
  my_user = {
    principal_arn = "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_USERNAME"
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

iii. ArgoCD repo URLterraform/argocd-app/main.tf:

repo_url = "https://github.com/YOUR_USERNAME/microservices-demo"
Enter fullscreen mode Exit fullscreen mode

iv. Grafanaterraform/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
Enter fullscreen mode Exit fullscreen mode

v. SonarQubeterraform/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
Enter fullscreen mode Exit fullscreen mode

vi. Frontendkubernetes-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Visit https://sonarqube.yourdomain.com
  2. Login: admin / admin; you'll be prompted to change the password
  3. Go to My Account → Security → Generate Token, name it github-actions
  4. 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
Enter fullscreen mode Exit fullscreen mode

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')]"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Teardown

To destroy everything, trigger the teardown workflow manually from GitHub Actions:

GitHub Actions → Teardown → Run workflow
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ingress has no ADDRESS
The LBC is not running or healthy:

kubectl logs -n kube-system deployment/aws-load-balancer-controller
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 → prod namespaces 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)