# Terraform Directory structure and FLOW?

# The Final Picture — What We're Building Toward

> **Series: Zero to Production on AWS EKS with Terraform** — Part 2 · The Final Picture The destination episode — the complete platform we're building, end to end.
>
> **Last updated:** 26 October 2026

**In short:** The finished platform is a production-grade AWS EKS environment built entirely from versioned Terraform modules, in five wired layers: network and identity, the EKS control plane, platform tools, GitOps delivery, and observability. Each environment (`dev`, `staging`, `prod`) uses the same pinned module library with its own `tfvars` and remote state.

In Day 0 we got our heads straight: what Terraform is, why modules matter, and how our setup is split into a **library** (`devops-terraform-modules`) and its **consumers** (the per-environment stacks that call it).

This post answers the natural next question: *"Okay… so what does the finished thing actually look like — technically?"*

Think of it as the **photo on the front of the puzzle box**. Every future post builds one piece — this is the full picture, with enough technical detail that an engineer can see *how* the pieces wire together, not just *what* they are.

---

## What is the goal of this platform?

The goal fits in one sentence:

> A complete, production-grade AWS platform — private networking, a managed Kubernetes control plane, GitOps delivery, secrets management, and full observability — described entirely as versioned Terraform, reproducible across `dev`, `staging`, and `prod` from the same module library.

Everything below is that sentence, unpacked layer by layer.

---

## How is the Terraform code structured?

The library defines the resources and consumers supply the values, through `variables.tf`, `main.tf` and `outputs.tf`. Before the architecture, the mechanics — because every layer below follows the same pattern.

**The library defines** `what`**; the consumer supplies** `which values`**.** A module exposes a small surface:

- `variables.tf` — the inputs a caller must/can provide (name, CIDR, instance sizes, env).
- `main.tf` — the resources, wired together, with the hard decisions already made.
- `outputs.tf` — the handful of values other modules need downstream (VPC ID, subnet IDs, cluster endpoint, OIDC issuer).

A consumer stack then does roughly this:

```hcl
module "vpc" {
  source = "git::https://github.com/<org>/devops-terraform-modules.git//modules/vpc?ref=v1.4.0"

  vpc_name             = var.vpc_name
  vpc_cidr             = var.vpc_cidr          # e.g. 10.0.0.0/16
  availability_zones   = var.availability_zones
  private_subnet_cidrs = var.private_subnet_cidrs
  public_subnet_cidrs  = var.public_subnet_cidrs
  environment          = var.environment       # dev | staging | prod
}

module "eks" {
  source = "git::https://github.com/<org>/devops-terraform-modules.git//modules/eks?ref=v1.4.0"

  vpc_id     = module.vpc.vpc_id              # ← output of one module
  subnet_ids = module.vpc.private_subnets     #   becomes input of the next
  # ...
}
```

Three things to notice, because they're the backbone of the whole platform:

1. **Pinned versions (**`?ref=v1.4.0`**)** — pin every environment to an exact module version (a Git tag). Upgrades become an explicit, reviewable bump, not a surprise. Pointing at a moving branch like `main` works too, but then a library change can reach prod without anyone deciding it should.
2. **Outputs → inputs** — modules chain. `vpc` emits subnet IDs; `eks` consumes them. Terraform reads this dependency graph and builds things in the correct order automatically.
3. **State** — each environment keeps its own remote state (S3 backend + DynamoDB lock). State is Terraform's memory of "what exists," and the lock stops two engineers from applying at once.

Every layer below is just more of this: a module, its inputs, its outputs, wired into the graph.

---

## What are the five layers of the finished environment?

The five layers are foundation, control plane, platform tools, delivery and observability. We build from the ground up. Each layer consumes the outputs of the one beneath it, like floors of a building sharing load-bearing walls.

```plaintext
   ┌─────────────────────────────────────────────────────────────┐
   │  LAYER 5 — Observability   Prometheus · Loki · Tempo · Grafana │  "Is everything ok?"
   ├─────────────────────────────────────────────────────────────┤
   │  LAYER 4 — Delivery        ArgoCD · GitHub Actions Runners    │  "Ship code to prod"
   ├─────────────────────────────────────────────────────────────┤
   │  LAYER 3 — Platform tools  ALB Ctlr · ExternalSecrets · EBS   │  "Make the cluster usable"
   ├─────────────────────────────────────────────────────────────┤
   │  LAYER 2 — Control plane   EKS + managed node groups          │  "Where apps run"
   ├─────────────────────────────────────────────────────────────┤
   │  LAYER 1 — Foundation      VPC · Subnets · SGs · TGW · IAM     │  "The private land"
   └─────────────────────────────────────────────────────────────┘
```

### What does Layer 1, the foundation, contain?

Layer 1 is the network and identity: VPC, subnets, security groups, Transit Gateway and IAM policies. The land everything stands on. *Built by* `vpc`*,* `security-group`*,* `tgw` */* `tgw-attachment`*,* `iam-policy`*.*

- A **VPC** with a deliberate CIDR (e.g. `10.0.0.0/16`) carved into **public and private subnets across multiple Availability Zones** for redundancy.
- **NAT gateways** so private workloads can reach out to the internet for image pulls and API calls, while nothing reaches *in* to them uninvited.
- An **S3 gateway endpoint** so traffic to S3 stays on AWS's backbone (cheaper, faster, no NAT charges).
- **Security groups** as the stateful firewall — least-privilege rules describing exactly what may talk to what.
- **Transit Gateway** + attachments to bridge separate VPCs (e.g. a shared monitoring VPC peering into workload VPCs) without exposing anything publicly.
- **IAM policies** as the identity backbone — the foundation for the fine-grained, per-workload permissions used higher up.

Subnets are **tagged for Kubernetes** here (`kubernetes.io/role/elb`, `internal-elb`) so that, two layers up, the ALB controller can auto-discover where to place load balancers. The foundation quietly sets up the layers above it.

### What does Layer 2, the EKS control plane, provide?

Layer 2 provides the managed EKS cluster, its nodes and the OIDC provider for IRSA. *Built by the* `eks` *module.*

- A managed **EKS cluster** — AWS runs the Kubernetes control plane (API server, etcd) so we don't babysit it.
- **Managed node groups** (or Karpenter-provisioned nodes) supplying the actual compute, placed in the **private** subnets from Layer 1, scaling on demand.
- An **OIDC provider** wired to the cluster — this is the keystone for **IRSA (IAM Roles for Service Accounts)** / **EKS Pod Identity**: it lets a specific pod assume a specific AWS IAM role, with no static credentials anywhere. Layers 3 and 4 lean on this constantly.

The `eks` module's outputs — cluster name, API endpoint, certificate authority data, OIDC issuer URL — are exactly what the `kubernetes`, `helm`, and `kubectl` providers need to start installing things on top.

### Which platform tools does Layer 3 add?

Layer 3 adds the AWS Load Balancer Controller, ACM certificates, External Secrets Operator and the EBS CSI driver. A fresh cluster is an empty room. These add the furniture, mostly via Helm releases bundled in `eks-addon`. *Built by* `eks-addon`*,* `acm`*,* `ebs-csi`*,* `alb`*.*

- **AWS Load Balancer Controller** → watches `Ingress`/`Service` objects and provisions real ALBs/NLBs, auto-discovering subnets via the Layer 1 tags. "I want a website" → a real DNS-addressable endpoint.
- **ACM certificates** → ACM-issued TLS certs attached to those load balancers — the 🔒 padlock on every hostname, with auto-renewal.
- **External Secrets Operator** → syncs secrets from **AWS Secrets Manager / SSM Parameter Store** into native Kubernetes `Secret`s. Apps read a normal secret; the real value never lives in Git. Authenticated via IRSA from Layer 2.
- **EBS CSI driver** → backs `PersistentVolumeClaim`s with real **gp3** EBS volumes and a default `StorageClass`, so stateful workloads get durable disk. Runs with pod-identity on the addon itself to avoid the controller-credentials bootstrap deadlock.

### How does Layer 4 deliver code to production?

Layer 4 delivers code through ArgoCD (GitOps) and self-hosted GitHub Actions runners. *Built inside* `eks-addon` *plus the* `monitoring-runner-cluster` *stack.*

- **ArgoCD** → the GitOps engine. It continuously reconciles cluster state against a Git repo: merge to the target branch → ArgoCD detects the diff → rolls it out → reports health and sync status. Deploys become **declarative and auditable** — the repo *is* the source of truth, and drift is corrected automatically.
- **GitHub Actions Runner Controller (ARC)** → runs CI/CD jobs on **self-hosted runners inside our own cluster**, scaling pods up for queued jobs and down to zero when idle. Cheaper than hosted runners and able to reach private resources directly.

ArgoCD typically needs a token to register/manage itself; that token-minting is isolated in a dedicated `argocd-token` module so the shared `eks-addon` stays GitOps-engine-agnostic and clusters that don't run ArgoCD (like pure monitoring clusters) stay clean.

### What does Layer 5 use for observability?

Layer 5 uses Promtail, Loki, a Prometheus agent, Tempo and Grafana for logs, metrics and traces. *Built inside the* `eks-addon` *bundle.*

- **Promtail** (DaemonSet) → tails container logs on every node and ships them with labels.
- **Loki** → stores logs cheaply and makes them queryable with LogQL.
- **Prometheus agent** → scrapes metrics on each workload cluster and remote-writes them to a central Prometheus in the shared monitoring cluster.
- **Tempo** → stores distributed traces, so you can follow one slow request across every service it touched.
- **Grafana** → dashboards and alerts over logs, metrics and traces, so a misbehaving service is *seen*, not guessed at.

When all five layers exist and reconcile cleanly, the environment is **done** — and every byte of it came from version-controlled text.

---

## How do the Terraform modules connect to each other?

Modules connect by passing outputs as inputs, and IRSA links pods to IAM roles. The layers aren't just stacked — they're wired. The important data-flows:

```plaintext
  vpc.outputs.private_subnets ────────────────► eks.subnet_ids
  vpc.outputs.vpc_id ────────────────────────► eks / security-group
  eks.outputs.oidc_provider_arn ──────────────► IRSA roles for:
                                                   • ALB controller
                                                   • External Secrets
                                                   • GitHub runners
  eks.outputs.cluster_name ──────────────────► EBS CSI addon (EKS Pod Identity)
  eks.outputs.cluster_endpoint + ca + token ──► kubernetes / helm providers
  acm.outputs.certificate_arn ────────────────► ALB ingress annotations
  subnet tags (Layer 1) ──────────────────────► ALB controller auto-discovery
  Git repo  ──► ArgoCD ──► live Kubernetes objects (Layer 4 reconcile loop)
```

This dependency graph is why ordering "just works": Terraform builds Layer 1 before Layer 2 because Layer 2 *references* Layer 1's outputs. You never hand-sequence the build — the references do it.

---

## What does the final repo topology look like?

One versioned module library is consumed by several per-environment stacks, each with its own state. By the end of the series, this is the full shape — one shared, versioned library, several environments that consume it:

```plaintext
   ┌────────────────────────────────────────────────────────────┐
   │            devops-terraform-modules  (versioned: vX.Y.Z)     │
   │            THE LIBRARY — stateless, provisions nothing       │
   │   vpc · eks · eks-addon · acm · ecr · ec2 · ebs-csi           │
   │   iam-policy · alb · security-group · tgw                     │
   └────────────────────────────────────────────────────────────┘
            ▲                    ▲                      ▲
            │  source + ?ref=    │                      │
   ┌────────┴──────┐   ┌─────────┴────────┐   ┌──────────┴──────────┐
   │ platform-infra│   │ platform-addons  │   │ monitoring-runner-  │
   │  network+EKS  │   │  ArgoCD, logs,   │   │ cluster (shared     │
   │               │   │  secrets, ALB    │   │ monitoring + CI)    │
   │  per env      │   │  per env         │   │  per env            │
   │  + own state  │   │  + own state     │   │  + own state        │
   └───────────────┘   └──────────────────┘   └─────────────────────┘
```

Each environment folder carries its **own** `tfvars` (the values that *should* differ — CIDRs, sizes, hostnames, region) and its **own remote state**. The library carries the engineering. The only thing separating `dev` from `prod` is a config file, so they **cannot drift apart by accident** — they're stamped from the identical modules at the identical pinned version.

---

## What does "done" look like?

When the series is finished, provisioning a brand-new environment is:

1. Copy an environment folder, rename it, point its backend at a fresh state key.
2. Edit a handful of `tfvars` values — name, region, CIDR, instance types, domain.
3. `terraform init` (pulls the pinned module versions + providers).
4. `terraform plan` — read the diff, confirm it creates what you expect.
5. `terraform apply` → ☕.
6. ~15–20 minutes later (mostly AWS spinning up the EKS control plane): a fully-wired environment — VPC, cluster, ALB + TLS, secrets sync, GitOps delivery, observability — **alive and structurally identical to its siblings**.

No console clicking. No "did I forget a step?" No drift. That's the destination.

---

## How does the series build the platform?

The series builds one layer per post, bottom to top. Each upcoming post builds one layer, bottom to top — module internals, the inputs that matter, the outputs it hands upward, and the gotchas:

| Episode | We build | Layer | Key modules |
| --- | --- | --- | --- |
| Day 0 ✅ | The mental model | — | — |
| Day 1 | The private network | 1 — foundation | `vpc` |
| Day 2 | Firewalls + bridges between networks | 1 — foundation | `security-group`, `tgw`, `tgw-attachment` |
| Day 3 | Permissions + the image registry | 1 — foundation | `iam-policy`, `ecr` |
| Day 4 | The EKS control plane, nodes and IRSA | 2 — control plane | `eks` |
| Day 5 | Storage, TLS and load balancing | 3 — platform tools | `ebs-csi`, `acm`, `alb`, ALB controller |
| Day 6 | Secrets sync + smart node autoscaling | 3 — platform tools | `eks-addon` (External Secrets, Karpenter) |
| Day 7 | GitOps delivery + self-hosted CI | 4 — delivery | `eks-addon` (ArgoCD, runners) |
| Day 8 | Logs, metrics, traces + the finale | 5 — observability | `eks-addon`, `ec2` |
| Bonus 1–3 | What broke in production, and the fixes | all | — |

By the finale, every piece in the puzzle-box photo is real, reconciling, and reproducible.

---

## Frequently asked questions

### Why pin Terraform modules to a Git tag?

Pinning with `?ref=v1.4.0` locks every environment to an exact module version. Upgrades become an explicit, reviewable bump, instead of a library change reaching prod without anyone deciding it should.

### How does Terraform know which order to build modules in?

Terraform reads the dependency graph created when one module's outputs are used as another's inputs. Because `eks` references `vpc` outputs, the VPC is built first automatically.

### Where should Terraform state live for each environment?

Each environment keeps its own remote state in an S3 backend with a DynamoDB lock. The lock stops two engineers from applying at the same time.

### What is IRSA and why does the platform depend on it?

IRSA (IAM Roles for Service Accounts) uses the cluster's OIDC provider to let a specific pod assume a specific IAM role. The ALB controller, External Secrets and GitHub runners all use it, so no static credentials are needed.

### How long does it take to create a new environment?

About 15–20 minutes after `terraform apply`, mostly AWS creating the EKS control plane. The result is a fully wired environment structurally identical to its siblings.

---

## What's the takeaway?

- The end goal is **one complete, production-grade AWS platform**, expressed entirely as versioned Terraform.
- It assembles in **five wired layers**: foundation → control plane → platform tools → delivery → observability — connected by **outputs → inputs** and **IRSA**, so ordering is automatic and credentials stay dynamic.
- One **versioned library** powers **many** environments; each keeps its own `tfvars` and remote state, so environments stay identical by design and upgrade by explicit version bump.
- "Done" means a fresh, full-stack environment is `terraform apply` **+ a coffee away** — not a two-week project.

Now that you can see the finished picture *and* how the wiring runs underneath it, the rest of the series is just building it, one well-defined layer at a time.

See you in Day 1, where we lay the foundation. 🚀
