Back to Resume
PrivateSeptember 2024· 4 weeks

AWS Infrastructure Foundation with Terraform

A Modular AWS Foundation on Terraform

RoleCloud Infrastructure Engineer

A social media management platform needed its core AWS infrastructure built as code before its application services could go anywhere. I built a Terraform codebase that provisions a VPC, an EKS cluster, KMS encryption keys, and an RDS Postgres database, then structured 8 further services (authentication, a NoSQL store, a cache, a message broker, file transfer) as complete, ready modules that plug into the same network without redesigning it.

The problem

A social media management platform needed a real AWS foundation to build on, not a set of resources clicked together by hand. Its planned services span a NoSQL store, a message broker, a cache, user authentication, and file transfer alongside its core Kubernetes workloads, and each of those needs the same underlying network, encryption, and identity plumbing to sit on. Building that piecemeal in the console does not scale past the first environment, and doing it as one large undifferentiated Terraform file does not scale past the first new service. What they needed was a foundation that could grow one module at a time.

Constraints

  • ·Kubernetes-based application, so the network had to support a managed EKS cluster with private worker nodes.
  • ·A relational store (Postgres) had to be live and encrypted from day one.
  • ·Several other services (auth, NoSQL, cache, broker, file transfer) were on the roadmap but not yet funded or scoped, so the foundation had to leave room for them without being built for them yet.
  • ·Built and maintained by a single engineer, so the module boundaries had to be simple enough to extend without re-reading the whole codebase each time.

Architecture

A Terraform root module (main.tf, providers.tf) composes 4 live modules: vpc, kms, eks, and postgresql. The vpc module builds a 2-AZ network with public and private subnets, a NAT gateway per AZ, and per-service security groups. The kms module creates customer-managed keys for the data stores that need one. The eks module wraps the community terraform-aws-modules/eks/aws module to stand up a managed Kubernetes cluster with the AWS Load Balancer Controller, Cluster Autoscaler, and EBS CSI driver as addons. The postgresql module provisions an RDS Postgres instance in the private subnets, encrypted with its own KMS key, reachable only through the bastion's security group. 8 further modules (cognito, documentdb, mq, redis, s3, sftp, iam, and bastion/ec2_controlPlane/cloudwatch utilities) exist complete in the repo but are commented out at the root, so nothing about them is currently provisioned.

  • Terraform root: main.tf composes vpc, kms, eks, postgresql (the only 4 live module calls)
  • vpc module: 1 VPC, 2 AZs, 2 public + 2 private subnets, 1 NAT gateway per AZ, per-service security groups
  • kms module: customer-managed keys for s3 and postgres, key rotation on (docdb/redis/mq keys staged)
  • eks module: terraform-aws-modules/eks/aws v19.15, Kubernetes 1.30, 1 managed node group (t3.medium, 2-6 nodes), aws-ebs-csi-driver / coredns / vpc-cni / kube-proxy addons, AWS Load Balancer Controller, Cluster Autoscaler
  • postgresql module: RDS Postgres 16.1, db.t3.medium, 20GB gp2, KMS-encrypted, private subnets, not publicly accessible
  • 8 staged modules (cognito, documentdb, mq, redis, s3, sftp, iam, plus bastion/ec2_controlPlane/cloudwatch/ecr utilities), written in full but not called from the root

Only vpc, kms, eks, and postgresql are wired into main.tf. Every other module is a complete, uncalled block, one module reference away from being live.

How one request flows

  1. 01Provision the network first: the vpc module creates the VPC, 2 public and 2 private subnets across 2 AZs, a NAT gateway per AZ, and the security groups each later module will reference.
  2. 02Create the encryption keys ahead of the data they protect: the kms module makes a customer-managed key for Postgres and one for S3, each with rotation on, before anything that needs encrypting exists.
  3. 03Stand up the cluster: the eks module builds a managed EKS cluster on the private subnets through the community module, then layers on the Load Balancer Controller, Cluster Autoscaler, and the EBS CSI driver (given its own IAM role via OIDC/IRSA) as addons.
  4. 04Provision the database: the postgresql module creates the RDS instance in the private subnets, encrypted with its KMS key, with inbound Postgres traffic allowed only from the bastion's security group.
  5. 05Expose what other code needs: root outputs surface the RDS endpoint and instance identifier so an application or a CI job can read them without reaching into module internals.
  6. 06Leave the rest ready, not running: authentication (Cognito), a NoSQL store (DocumentDB), a cache (Redis), a broker (Amazon MQ), file transfer (SFTP, its S3 bucket, and its IAM roles), a bastion host, and log groups all exist as finished modules, staged for the client to enable one at a time.

Engineering decisions

One module per AWS service

What. Every service, live or staged, lives in its own module under modules/ with its own variables and outputs, composed only at the root.

Why. The client's roadmap adds services one at a time (auth, then a NoSQL store, then a cache, and so on). A module boundary means turning one on is a root-level change, not a rewrite of the network layer already in production.

Tradeoff. More files than a single main.tf upfront, worth it the moment a second service needs enabling.

A maintained EKS module over hand-rolled cluster resources

What. Built the cluster with terraform-aws-modules/eks/aws (v19.15) plus eks-blueprints-addons, instead of writing the cluster, node IAM roles, and OIDC wiring from scratch.

Why. EKS has enough moving parts, node IAM, launch templates, OIDC, addon versions, that a maintained module absorbs version-to-version changes a bespoke setup would have to re-solve.

Tradeoff. The cluster's shape is bound to what that module version exposes, rather than full control over every underlying resource.

IRSA for the storage driver, not a node-wide role

What. Enabled OIDC on the cluster and bound a scoped IAM role to the EBS CSI driver's own service account.

Why. A permission attached to the node lets every pod on it reach the same AWS APIs. Binding it to one service account keeps storage access to the one component that needs it.

Tradeoff. One extra IAM role and OIDC provider to manage, over letting every pod inherit the node's permissions.

Database access scoped to a security group, not a CIDR block

What. The postgresql security group only allows inbound 5432 from the bastion host's security group, never from a CIDR range, even though the bastion module itself is staged and not currently called from the root.

Why. A database with an open CIDR ingress rule is one misconfigured route away from being internet-reachable. Chaining access to a security group reference means enabling the bastion module later is the only way anything reaches the database, not a route table edit.

Tradeoff. Until the bastion module is turned on, nobody, including an operator, can reach the database directly either. For a foundation phase where the app talks to Postgres from inside the cluster, that is the right default, not a gap.

A key per data store, not one shared key

What. The kms module creates a separate customer-managed key for each data store (Postgres, S3, and staged keys for DocumentDB, Redis, MQ) rather than one account-wide key.

Why. A key rotation or a compromise on one store's key never touches another store's data.

Tradeoff. More keys to track and rotate, the standard cost of least privilege at the encryption layer.

Staged modules committed, not deleted

What. The 8 not-yet-enabled modules are kept fully written in the repo, commented out at the root rather than removed.

Why. The client's own image registry naming (a set of per-platform service repos for a social media scheduler) shows the direction the platform is heading. Writing those modules now, even unused, means turning one on later is uncommenting a block and supplying variables, not starting from zero.

Tradeoff. Unused Terraform sits in the repo doing nothing until it is called, a smaller cost than re-deriving the wiring from scratch when the client is ready.

What changed along the way

  • Scope narrowed from a full platform (auth, NoSQL, cache, broker, file transfer, all live) to shipping the network, compute, and database foundation first, with the rest staged as ready-to-enable modules.
  • The RDS password stayed a Terraform variable default rather than moving to AWS Secrets Manager. The module's own code comment flags this as a follow-up to raise with the client, and it was not closed out in this engagement.
  • main.tf and a second file, mainRemoved.tf, ended up each holding a different subset of the commented-out module calls, never consolidated into one file.

Security and reliability

  • ·Postgres and S3 each encrypted with their own customer-managed KMS key, rotation enabled.
  • ·RDS sits in the private subnets only, not publicly accessible, and accepts inbound 5432 only from a bastion security group reference, which currently has no live host attached since the bastion module is staged.
  • ·EKS addon permissions (the EBS CSI driver) scoped through OIDC and IRSA rather than a broad node-level IAM role.
  • ·A NAT gateway per AZ gives private-subnet workloads outbound internet access without any inbound path.
  • ·Known limitation, stated plainly: the RDS credential is a Terraform variable default, not sourced from AWS Secrets Manager. The code flags this as unresolved.

Metrics

4
Terraform modules actually wired and live (vpc, kms, eks, postgresql)
measured
8
additional modules staged complete, ready to enable with one module block
measured
2
availability zones the network spans
measured

What shipped

  • Delivered a live AWS foundation: a 2-AZ VPC, an EKS cluster with the Load Balancer Controller, Cluster Autoscaler and EBS CSI driver, KMS encryption, and an RDS Postgres database.
  • Structured the codebase so 8 further services (Cognito auth, DocumentDB, Redis, Amazon MQ, SFTP with its S3 bucket and IAM roles, a bastion host, and CloudWatch log groups) can be turned on one module block at a time.
  • Scoped every sensitive permission through OIDC/IRSA and a per-data-store KMS key rather than broad grants.
  • Left the one known gap, the RDS password as a plaintext Terraform default, documented in the code rather than silently shipped as resolved.

Lessons learned

  • ·Splitting each AWS service into its own module pays off before the second service is ever enabled, not after.
  • ·A maintained EKS module absorbs cluster lifecycle details, node IAM, OIDC, addon versions, that a hand-rolled setup would have to keep re-solving.
  • ·IRSA is worth the extra IAM role and OIDC setup over letting every pod on a node inherit the same AWS permissions.
  • ·A code comment flagging a follow-up, like moving a password to Secrets Manager, only closes the gap if someone actually does it later.

Tech stack

Backend
Amazon EKS (Kubernetes 1.30)AWS Load Balancer ControllerCluster AutoscalerAWS EBS CSI driver
Data & state
Amazon RDS (Postgres 16.1)AWS KMS (customer-managed keys)Amazon VPC, NAT gateway per AZ
Ops & quality
Terraform (AWS provider ~> 5.0)terraform-aws-modules/eks/awsaws-ia/eks-blueprints-addonsOIDC / IRSA for scoped IAMone module per AWS service