Back to Resume
PrivateDecember 2023· 1 week

Enterprise EKS Automation & Deployment Orchestration Platform

An EKS Deployment Platform for a B2C App Migration

RoleCloud Engineer

A B2C fintech product was running on Azure App Service and a VPS in Germany and needed to move onto AWS without downtime or a rewrite. I built the target AWS platform from scratch: a Terraform-provisioned VPC, EKS cluster, and RDS Postgres instance, a GitHub Actions pipeline that authenticates to AWS over OIDC and pushes images to ECR, and a Kubernetes Deployment behind an Application Load Balancer. DNS only moved to the new stack once the app was verified healthy behind the load balancer.

The problem

The client's B2C application, a .NET Core backend with a React frontend and a Postgres database, was running on Azure App Service with a VPS in Germany filling gaps the platform couldn't cover. That setup worked but didn't scale as a long-term home: it split operations across two unrelated platforms, gave the team no consistent way to roll out a new version, and had no repeatable path from a code push to a running container. They wanted to move to AWS, standardize on Kubernetes so the app could scale and self-heal, and keep the existing GitHub-based CI/CD workflow rather than replace it. The naive version of this migration, point the same app at a new VM and change DNS, would have kept every one of those problems. The actual need was a reproducible, code-defined platform with an automated build-and-deploy path.

Constraints

  • ·A live, already-used application had to move with no unplanned downtime, so DNS cutover could not happen until the new stack was verified.
  • ·The existing GitHub Actions CI/CD workflow had to keep working, not be replaced with a different toolchain.
  • ·AWS credentials for CI could not be long-lived static keys sitting in a GitHub repo.
  • ·Database credentials had to be generated and stored, not hand-typed into infrastructure code.
  • ·Built and delivered by a single cloud engineer against a fixed weeks-long timeline.

Architecture

Terraform provisions a VPC across two Availability Zones (public and private subnets, NAT gateways), an EKS cluster with a managed node group plus the AWS Load Balancer Controller and Cluster Autoscaler as EKS addons, and an RDS Postgres instance whose password is generated by Terraform's random_password resource and stored in Secrets Manager rather than written into any file. A bastion EC2 host in a public subnet is the only path into the private subnet for database administration. GitHub Actions authenticates to AWS through an OIDC-federated IAM role (no stored access keys), builds the application image, and pushes it to ECR. The Kubernetes Deployment and Service reference that image and sit behind an ALB Ingress, health-checked on an HTTP path before it ever takes traffic. Route53 is the last piece: the domain is pointed at the ALB only once the app is confirmed healthy through the load balancer's own hostname.

  • VPC: 2 AZs, public + private subnets, NAT gateways, Internet Gateway
  • EKS managed node group + AWS Load Balancer Controller + Cluster Autoscaler (EKS addons)
  • EBS CSI driver bound through an IRSA role scoped to its own service account
  • RDS Postgres: KMS-encrypted, password generated and held in Secrets Manager
  • Bastion EC2 host: the only route into the private subnet for DB administration
  • GitHub Actions -> ECR: OIDC role-assume, no static AWS keys in CI
  • Kubernetes Deployment + Service + ALB Ingress: rolling updates, health-checked target group
  • Route53: DNS pointed at the ALB only after the app is verified behind it

Read the diagram left to right for the build path (GitHub Actions to the running pods), with the database and its bastion hanging below the cluster, and DNS as the last arrow into the load balancer, not the first.

How one request flows

  1. 01Provision the platform from code: Terraform creates the VPC, the EKS cluster with its managed node group and addons, the RDS instance, and the bastion host.
  2. 02Push a change: GitHub Actions assumes an AWS IAM role over OIDC, builds the application's Docker image, and pushes it to ECR under a versioned tag.
  3. 03Deploy: the Kubernetes Deployment is updated to the new image tag and applied. The ALB Ingress Controller keeps the target group in sync with the running pods.
  4. 04Roll out: Kubernetes' native rolling update replaces pods gradually, gated by the readiness probe on the app's health endpoint, so a bad pod never receives traffic.
  5. 05Verify: the app is checked directly against the ALB's own hostname before anything user-facing changes.
  6. 06Cut over: once verified, the Route53 record for the real domain is pointed at the ALB. This is deliberately the last step, not the first.
  7. 07Operate: the app reads and writes Postgres over the private subnet. An operator who needs direct database access goes through the bastion host, never a public RDS endpoint.

Engineering decisions

OIDC-federated CI identity, not stored AWS keys

What. GitHub Actions assumes an IAM role via OpenID Connect (role-to-assume, no static access key pair in the workflow or the repo).

Why. A static key pair in a GitHub repo is a standing liability: if it leaks, it works until someone manually revokes it. A federated role-assume is scoped to that one workflow and expires with the run.

Tradeoff. Setting up the OIDC provider and trust policy once takes more up-front work than pasting in two secrets, which is exactly the point.

Database credentials generated, not typed into Terraform

What. The RDS password comes from a random_password resource, stored in Secrets Manager, and read back into the aws_db_instance resource from the secret, never written as a literal string.

Why. A password sitting in a .tf file ends up in state files, diffs, and anyone's shell history the moment terraform plan runs. Generating and fetching it removes that path entirely.

Tradeoff. None meaningful, this is close to free once the pattern exists.

IRSA for cluster addons instead of broad node permissions

What. The EBS CSI driver's AWS permissions come from an IAM role bound to its own Kubernetes service account through the cluster's OIDC provider, not from the node group's IAM role.

Why. A permission attached to the node applies to every pod scheduled on it. A permission attached to a service account applies only to the workload that actually needs it.

Tradeoff. One extra IAM role and trust relationship to define, worth it to avoid over-granting by default.

A bastion host, not a public database endpoint

What. RDS sits in a private subnet with no public access. The only way in for administration is a small EC2 bastion in the public subnet, reachable over SSH.

Why. A publicly reachable database endpoint is one misconfigured security group rule away from exposure. A bastion is a single, deliberately narrow door instead.

Tradeoff. Considered a client VPN instead, but for a single engineer's occasional administrative access, a bastion is proportionate. A full VPN would have been more infrastructure than the access pattern justified.

Kubernetes' own rolling update, not a custom deployment tool

What. The app deploys as a standard Kubernetes Deployment with a readiness-probe-gated rolling update, applied through kubectl.

Why. For a single-replica, low-traffic-at-cutover service, the built-in rolling update already does what's needed: replace pods gradually and only route traffic to ones that pass their health check.

Tradeoff. A canary or blue-green pipeline would add real operational value at higher traffic or with multiple replicas, but building one here would have been complexity the actual deployment didn't call for. Native rolling updates were the right-sized choice, not a shortcut.

Route53 cutover last, verified against the ALB hostname first

What. The runbook explicitly sequences DNS as the final step, after the ECR pipeline, the EKS deployment, and the ALB target group's port are all confirmed correct.

Why. Moving DNS before the new stack is proven working turns any misconfiguration into a live outage for real users. Verifying against the load balancer's own hostname first catches port and health-check mismatches while nothing user-facing has changed yet.

Tradeoff. Adds a manual verification step before cutover instead of a single automated switch, an acceptable cost for a live production migration with paying users.

What changed along the way

  • The CI/target-group pairing wasn't right on the first pass: the ALB Ingress's target port had to be corrected to match the port the client's application actually listened on, discovered only after asking the client directly, not assumed from a default.
  • A dev/test iteration of the same platform pattern first proved the pipeline end to end with a placeholder container image before the client's real application image was ever pointed at it.
  • Two separate Terraform trees ended up describing the same shape of infrastructure (the original production migration, and a later rebuild of the pattern for a second environment) rather than one shared module, since the second pass came well after the first.

Security and reliability

  • ·CI identity: GitHub Actions authenticates to AWS through an OIDC-federated IAM role, no long-lived access keys stored anywhere.
  • ·Cluster identity: the EBS CSI driver and load balancer controller run under IRSA roles scoped to their own service accounts, not the node group's IAM role.
  • ·Data at rest: the RDS instance is KMS-encrypted, with its password generated by Terraform and held in Secrets Manager.
  • ·Network isolation: RDS and the EKS node group sit in private subnets. The only inbound path to the database is through a bastion host and one operator IP allow-listed on the database security group.
  • ·Availability: the ALB Ingress health-checks the app before routing traffic, and Kubernetes' rolling update keeps a bad pod from ever receiving requests.
  • ·Change safety: DNS cutover happens only after the app is verified behind the ALB's own hostname, not before.

Metrics

0
long-lived AWS keys in the CI pipeline (OIDC role-assume only)
measured
5 pods
EKS node group sized to the SOW's application scope
target
2
environments built on the same Terraform pattern (initial migration + a later CI/CD-focused rebuild)
measured

What shipped

  • Migrated a live B2C application off Azure App Service and a VPS onto a Terraform-provisioned AWS EKS/RDS platform.
  • Automated the build-and-push path with GitHub Actions and ECR, authenticated through OIDC with no static AWS keys.
  • Exposed the app through a health-checked ALB Ingress and moved DNS only after the new stack was verified working.
  • Gave the client a bastion-gated, KMS-encrypted database with Secrets-Manager-issued credentials instead of a public endpoint or a hand-typed password.
  • Left a repeatable Terraform pattern that was reused to build a second environment on the same platform shape.

Lessons learned

  • ·A standard Kubernetes rolling update, gated by a readiness probe, is enough for a single-replica production cutover. A bespoke blue-green pipeline would have been unused complexity at this scale.
  • ·OIDC-federated CI roles remove an entire class of leaked-key risk that static AWS access keys carry by default.
  • ·Sequencing DNS last, and verifying against the load balancer's own hostname first, turns a migration cutover from a live gamble into a checked step.
  • ·Generating database credentials through Secrets Manager instead of writing them into Terraform closes the single most common secret-leak path in infrastructure code.

Tech stack

Backend
AWS EKSKubernetes Deployment/Service/IngressAWS Load Balancer ControllerCluster AutoscalerEBS CSI driver
Data & state
RDS Postgres (KMS-encrypted)AWS Secrets ManagerAmazon S3
Ops & quality
Terraform (VPC, EKS, RDS, bastion modules)Amazon ECRGitHub Actions with OIDC federationAWS IAM / IRSAkubectlRoute53