Back to Resume
PrivateDecember 2023· 4 weeks

AWS Infrastructure and CI/CD with Terraform

Provisioning a Production AWS Platform with Terraform and GitHub Actions

RoleCloud/DevOps Engineer

A fintech proof-of-concept client needed a production AWS platform for a containerized credit-scoring application, built from code and not clicked together by hand. I built the Terraform that provisions the network, the Kubernetes cluster, and the database, and the GitHub Actions pipeline that builds and ships their application's Docker image. The database sits on a private network reachable only through a named list of security groups, and the CI pipeline authenticates to AWS with short-lived OIDC credentials instead of static keys.

The problem

A fintech proof-of-concept client had an application ready to run but no AWS platform to run it on. They needed a Kubernetes cluster for the app, a Postgres database behind it, and a way to get a new Docker image from GitHub into that cluster without anyone hand-running AWS CLI commands. A one-off console setup would not survive a rebuild, and a database reachable from the open internet was not acceptable for a credit-scoring product. They needed the infrastructure defined as code and the database locked down to only the services that needed it.

Constraints

  • ·AWS only, since the client's application and their existing AWS account were the given starting point.
  • ·Kubernetes for the app, so the network had to support an EKS cluster with public and private subnets.
  • ·The database could not be reachable from the open internet, given the sensitivity of a credit-scoring product.
  • ·No static AWS credentials in CI, so the build pipeline needed a federated identity, not long-lived keys.
  • ·Built and run by a single engineer, so the setup had to be provisioned from code, not memory.

Architecture

Terraform provisions a VPC with two public and two private subnets across two availability zones, an EKS cluster (via the community terraform-aws-modules/eks/aws module, with the AWS Load Balancer Controller, cluster autoscaler, and EBS CSI driver enabled through eks-blueprints-addons), and an RDS Postgres instance whose master password is generated and stored in Secrets Manager rather than hardcoded. A GitHub Actions workflow authenticates to AWS over OIDC, builds the application's Docker image, and pushes it to ECR. Kubernetes manifests deploy that image behind an ALB ingress with a health check. The database's security group allows only a named list of security group IDs, the bastion host and the EKS nodes, not the open internet, and an operator bastion EC2 host provides direct SSH access to the database for one-off work.

  • Terraform root module composing VPC, RDS, and EKS (module/s3, a logging bucket, exists but is not called from root)
  • VPC: 2 public + 2 private subnets across 2 AZs, one NAT gateway per AZ, an internet gateway
  • EKS cluster (terraform-aws-modules/eks/aws, k8s 1.28) with the AWS Load Balancer Controller, cluster autoscaler, and EBS CSI driver via IRSA
  • RDS Postgres 16.1, master password generated with random_password and stored in AWS Secrets Manager
  • Security group on RDS scoped to a named list of security group IDs (bastion + EKS nodes), not an open CIDR
  • GitHub Actions workflow: OIDC federation to assume an IAM role, Docker build, ECR push
  • Kubernetes Deployment/Service/Ingress (ALB) for the application, with a `/health/recent` health check
  • A bastion EC2 host for direct operator access to the private database

Provisioning (Terraform) and delivery (GitHub Actions to ECR) are two separate pipelines. Getting a built image from ECR onto the cluster is a manual kubectl apply, not a wired CD step.

How one request flows

  1. 01Provision the network: Terraform creates a VPC with public and private subnets across two availability zones, with one NAT gateway per AZ.
  2. 02Provision the data layer: an RDS Postgres instance goes into the private subnets, with its master password generated and stored in Secrets Manager, and its security group scoped to a named list of security groups rather than the open internet.
  3. 03Provision the cluster: an EKS cluster comes up in the same VPC, with the Load Balancer Controller, autoscaler, and EBS CSI driver added and IAM-scoped through OIDC.
  4. 04Build the image: a push to the application's release branch triggers GitHub Actions, which assumes an AWS role over OIDC (no static keys), builds the Docker image, and pushes it to ECR.
  5. 05Deploy the image: an operator applies the Kubernetes manifests, which pull the image from ECR and expose it through an ALB ingress with a health check path.
  6. 06Reach the database when needed: an operator connects through a bastion EC2 host over SSH, since the database itself is never exposed publicly.

Engineering decisions

OIDC federation instead of static AWS keys in CI

What. GitHub Actions assumes an AWS IAM role through OIDC to build and push the Docker image.

Why. A long-lived AWS access key sitting in GitHub secrets is a standing risk, an OIDC role only grants a token for the run and only to that repo and branch.

Tradeoff. A little more setup than pasting a key into GitHub secrets, worth it to remove a static credential from the pipeline.

A community EKS module instead of hand-rolled cluster resources

What. The cluster is built with terraform-aws-modules/eks/aws and eks-blueprints-addons rather than raw aws_eks_cluster and manually installed addons.

Why. Cluster upgrades, node group management, and add-on wiring (load balancer controller, autoscaler, EBS CSI) are maintained upstream instead of re-implemented and kept in sync by hand.

Tradeoff. Less control over the exact resource shape than writing it from scratch, acceptable since the module covers the standard EKS setup this project needed.

Secrets Manager for the RDS password, not a hardcoded variable

What. The RDS master password is generated with Terraform's random_password and stored in Secrets Manager, referenced by the database resource rather than typed into a variable default.

Why. A password typed into a .tf file ends up in version control and in every teammate's clone. Generating it and storing it in Secrets Manager means the real value exists in exactly one place.

Tradeoff. None significant. It costs one extra resource block.

Security-group allow-list on the database, not an open CIDR

What. The RDS security group's ingress rule names a specific list of security group IDs (the bastion and the EKS nodes) as the allowed source, not a public CIDR range.

Why. A credit-scoring product's database should be reachable only by the services that need it. A leftover commented-out block in the same file shows an earlier, fully open draft of this rule, so this was a deliberate tightening, not the first attempt.

Tradeoff. Every new service that needs DB access must be added to the allow-list explicitly, which is the point.

A bastion host instead of a full VPN

What. Direct operator access to the private database goes through a single bastion EC2 instance over SSH, not a client or site-to-site VPN.

Why. A proof-of-concept engagement with one or two operators needing occasional DB access does not justify standing up and maintaining a VPN. A bastion is one instance, one security group rule, and no ongoing VPN infrastructure to run.

Tradeoff. Less robust than a VPN for a larger team, and correct in scope for how few people needed access.

One NAT gateway per availability zone

What. The VPC has two NAT gateways, one in each AZ's public subnet, rather than a single shared NAT gateway.

Why. A single NAT gateway is a single point of failure for all outbound traffic from every private subnet. Pairing one NAT per AZ means an AZ outage only affects that AZ's outbound path.

Tradeoff. Roughly double the NAT gateway cost of a single shared one, a reasonable tradeoff for a production database and cluster.

IRSA-scoped IAM for the storage driver, not node-wide permissions

What. The EBS CSI driver gets its AWS permissions through a dedicated IAM role bound to its own Kubernetes service account via OIDC (IRSA), not through the EKS node role.

Why. Attaching storage permissions to the node role would give every pod on that node the same access. Scoping it to the driver's own service account limits the blast radius if any other pod on the node is compromised.

Tradeoff. One extra IAM role and OIDC binding to set up, standard practice for this size of cluster.

What changed along the way

  • An S3 logging bucket module was written early on but never wired into the root Terraform. It stayed in the repo as an unused module rather than a delivered feature.
  • An earlier ingress design routed a single path for the whole application. It was replaced with host-based routing once the client's application split into a standard scoring API and a separate crypto-scoring API, each with its own service.
  • The RDS security group went through at least one more permissive draft (visible as commented-out code in the same file) before landing on the named security-group allow-list that shipped.

Security and reliability

  • ·OIDC federation for both GitHub Actions (build pipeline) and the EBS CSI driver (IRSA), so neither uses a static or node-wide credential.
  • ·The RDS master password is generated at apply time and stored in Secrets Manager, never typed into a Terraform file.
  • ·The database's security group allows only a named list of security groups, not a public CIDR.
  • ·Private subnets across two AZs for the database and the EKS nodes, with two NAT gateways so outbound traffic doesn't depend on a single AZ.
  • ·An ALB health check path (`/health/recent`) gates traffic to the application pod.
  • ·A single bastion EC2 host, reached over SSH, is the only path to the database for a human operator.

Metrics

0
static AWS credentials in the CI pipeline (OIDC federation only)
measured
2 AZ
subnets and NAT gateways for the network and database
measured
1 list
named security groups allowed to reach the database, not the open internet
measured

What shipped

  • Delivered a working AWS platform from code: VPC, EKS cluster, and RDS Postgres instance, provisioned by Terraform.
  • Delivered a GitHub Actions pipeline that builds and ships the application's Docker image to ECR over OIDC.
  • The database is reachable only from a named list of security groups, with its password generated and stored in Secrets Manager.
  • The application deploys behind an ALB ingress with a health check, and later split into two routed services (standard and crypto scoring) as the client's product grew.
  • An operator bastion host gives direct database access without exposing the database itself to the internet.

Lessons learned

  • ·OIDC federation removes an entire class of leaked-credential risk from a CI pipeline, and it costs little extra setup.
  • ·A security group allow-list is a cleaner default for a sensitive database than an open CIDR gated only by a rule you have to remember to tighten later.
  • ·A bastion host is the right-sized answer for occasional operator access, a VPN is not always worth standing up for a small team.
  • ·Writing a module (like the S3 logging bucket here) and not wiring it into root is a common way infrastructure code accumulates dead weight. Worth a pass to remove or connect it.

Tech stack

Backend
AWS EKS 1.28Kubernetes Deployment/Service/IngressAWS ALB ingress controllerDocker
Data & state
AWS RDS Postgres 16.1AWS Secrets ManagerAWS EBS CSI driver
Ops & quality
Terraform (VPC, RDS, EKS modules)GitHub Actions with OIDCAWS ECRAWS IAM / OIDC / IRSAAWS VPC (2 AZ, 2 NAT gateways)bastion EC2 for DB access