Projects

Start here

Client project · ChainGPT

Legacy Platform → Consolidated EKS

Scattered ECS, EC2, Lambda, and per-account stacks onto two GitOps-managed clusters.

N stacks → 2 clusters

ECS, EC2, and account sprawl onto EKS

10+ services

GitOps per environment

Public / internal

ingress as a first-class split

ELK fleet gone

forwarders replaced in-cluster

Overview

I migrated a production platform from legacy, dispersed AWS onto consolidated Kubernetes.

Before, workloads lived in different AWS accounts: ECS Fargate stacks per service, EC2 boxes with cron, Lambdas, and EventBridge kicking ECS tasks. Each app often brought its own ALB. Logs shipped through dozens of CloudWatch → Lambda → ELK forwarders. Environments drifted. Deploys were not one pipeline. After, dev and prod share one VPC and two EKS clusters. Apps ship through reusable CI → ECR → GitOps → Argo CD. Traffic goes through Istio. Operators reach private APIs over a Defguard split-tunnel VPN and private DNS. Secrets sync from AWS. Nodes scale with Karpenter. Cron and EventBridge/ECS jobs became CronJobs and KEDA-scaled workloads. Extra load balancers went away.

The problem

The estate had grown service-by-service and account-by-account, not as a platform. Pain: slow new services, inconsistent security, extra load balancers, expensive log plumbing, and no single place to see what is running where.

  • Accounts: services running in different AWS accounts, with their own IAM, networking, and deploy habits
  • Compute: ECS Fargate stacks per app, plus EC2 servers for leftovers that never made it onto Fargate
  • Jobs: cron on EC2, EventBridge + ECS tasks, and Lambda + SQS for pipelines that did not fit cleanly
  • Envs: copies that drifted; staging later dropped
  • Logs: about 30 CloudWatch → Lambda → ELK forwarders, one per log group
  • Secrets: per-stack Secrets Manager, no cluster-wide operator
  • Networking: public ALBs as the default — multiple unnecessary load balancers, little consistent private ingress
  • Deploys: per-repo, per-stack, or per-account updates — no shared GitOps source of truth
  • Scale: always-on EC2, idle Fargate capacity; no cluster-wide bin-packing

The solution

Treat Kubernetes as the runtime for app workloads. Clusters and data plane stay in Terraform. Everything that changes weekly — images, routes, replicas — lives in git and is synced by Argo CD. Istio owns north-south traffic. Defguard split-tunnel VPN plus private hosted zones is how humans reach the internal mesh. Secrets and AWS access are pod-scoped. One ingress path instead of a load balancer per service.

Cluster

Terraform EKS: KMS-encrypted secrets, EKS Pod Identity, pinned add-ons

Packaging

Kustomize overlays per env; one Argo CD Application per service

Traffic

Istio — public NLB vs internal gateway; NetworkPolicies on the VPC CNI; leftover ALBs retired

Access

Defguard split-tunnel VPN and Route 53 private zones for operator APIs

TLS

cert-manager and Let's Encrypt DNS-01 for wildcard internal certs

Secrets

External Secrets Operator from AWS Secrets Manager

Scale

Small system node group plus Karpenter for everything else

Jobs

CronJobs instead of EC2 cron; KEDA on SQS instead of Lambda and EventBridge + ECS

CI

Shared workflows and self-hosted runners inside the cluster for rollout checks

Obs

kube-prometheus-stack, Loki, Tempo — ELK and log-forwarder Lambdas gone

Architecture

platform.txt

App repo push
    → reusable GitHub Actions (OIDC → ECR)
    → patch GitOps manifests
    → Argo CD syncs the cluster
    → Istio VirtualService
         ├── public gateway   (internet / CDN)
         └── internal gateway (VPN + private DNS)

What I implemented

  • Terraform for dev and prod EKS — cluster, KMS, add-ons, Karpenter, ESO, IAM, private hosted zones. IAM policy names namespaced per env so reusable modules did not hit AWS’s global unique-name constraint
  • Argo CD bootstrap and one Application per workload / env. Chart repositories added in CI (Helm home on the runner), so istio was never “repo not found” because it only existed on a laptop
  • Istio public and internal gateways; VirtualServices that split internet vs VPN-only APIs
  • Defguard split tunnel: VPC traffic and VPC DNS only — internet stays local
  • cert-manager wildcards so new internal services do not need hand-rolled certs
  • External Secrets and EKS Pod Identity so pods never use node-wide AWS keys. Access-denied debug splits authentication (who called) from authorization (IAM vs KMS key policy)
  • Reusable build → ECR → GitOps tag bump workflows (OIDC, no long-lived AWS keys). Git refs listed before checkout when gitops is both a branch and a path
  • In-cluster Actions runners for post-sync verification
  • NetworkPolicies, deletion protection, private API CIDR controls
  • Cut-over of ECS, EC2, Lambda, EventBridge, extra ALBs, and ELK; archive-only Terraform left as reference

How we cut over

  1. Stand up clusters, mesh, GitOps, Defguard VPN, secrets, and observability first
  2. Move services one by one — including those in other AWS accounts and on EC2: containerize → GitOps overlay → dual-run if needed → flip DNS
  3. Jobs: EC2 cron → CronJobs; EventBridge + ECS and Lambda/SQS → KEDA-scaled Deployments on the same queues
  4. Logs: stop per-service forwarders once Loki and Prometheus were live
  5. Retire extra load balancers; destroy ECS / EC2 / Lambda / ELK Terraform; keep one reference copy, not live state

Before / after

Before

  • Services split across AWS accounts
  • Per-service ECS + extra ALBs
  • EC2 servers and cron on the box
  • EventBridge + ECS tasks, Lambda + SQS
  • Staging drift
  • ELK + ~30 log Lambdas
  • Public load balancers as the default
  • Deploy knowledge trapped in each repo or account

After

  • Two EKS clusters, one VPC
  • Argo CD + Kustomize for every app
  • Istio public / internal gateways — leftover ALBs gone
  • Defguard VPN + private DNS for operator access
  • CronJobs and KEDA instead of EC2 cron / EventBridge / Lambda
  • Karpenter
  • Prometheus / Loki / Tempo
  • Shared CI, in-cluster runners

Results

  • One platform instead of a Terraform repo — or AWS account — per service
  • Private-by-default APIs — internal hosts only on Defguard VPN and private DNS
  • Same deploy path for every backend: GitOps, not ECS vs EC2 vs Lambda vs EventBridge
  • Fewer load balancers; Istio owns public vs internal ingress
  • Cheaper, simpler ops — no ELK forwarder fleet, no always-on EC2 cron boxes; Karpenter instead of idle Fargate
  • Faster new services — overlay + VirtualService + Argo app, not a new stack in another account
  • Clear blast radius — two clusters, shared VPC, explicit public vs internal gateways

Stack

AWS EKSTerraformArgo CDIstioKarpenterExternal SecretsKEDADefguardGitHub Actions

Have a project in mind? Let's build something together.