Provisions a production-grade AWS environment for a containerised Spring Boot API.
One apply = one cluster. Run twice with different state keys for dev and prd.
Internet
|
[Internet Gateway]
/ \
[Public Subnet AZ-a] [Public Subnet AZ-b]
[NAT GW] [NAT GW - prd only]
[Nginx Ingress LB] <-- routes inbound HTTPS
\ /
[Private Subnet AZ-a] [Private Subnet AZ-b]
[EKS Nodes] [EKS Nodes]
[RDS Primary] [RDS Standby]
| Layer | Service | Notes |
|---|---|---|
| Compute | EKS 1.35 managed nodes | AL2023, t3.medium, 2-10 nodes, Cluster Autoscaler |
| Database | RDS PostgreSQL 17 | db.t4g.micro, Multi-AZ, gp3 |
| Storage | S3 | Versioned, private, IRSA-controlled |
| Network | VPC 10.0.0.0/16 | 2 public + 2 private subnets, 2 AZ, per-AZ NAT in prd |
| IAM | IRSA (OIDC) | App pods and Cluster Autoscaler use role-based access |
| File | What it does |
|---|---|
versions.tf |
Terraform version constraint, S3 backend, provider pins and provider blocks |
variables.tf |
All inputs with types, defaults, and descriptions |
locals.tf |
AZ list, shared resource tags, OIDC issuer host |
data.tf |
Reads available AZs and current AWS account ID from AWS |
network.tf |
VPC, subnets, IGW, NAT GW (per-AZ in prd), route tables, RDS security group |
compute.tf |
EKS cluster, IAM roles, node group, EKS addons, OIDC provider, IRSA roles |
database.tf |
RDS instance, subnet group, write-only password |
storage.tf |
S3 bucket (versioned, private) |
addons.tf |
Helm releases: nginx ingress, cert-manager, ArgoCD, Cluster Autoscaler; ClusterIssuers |
outputs.tf |
kubectl config command, ArgoCD admin password, DB endpoint, S3 bucket, app IRSA role ARN |
terraform.tfvars.example |
Copy to terraform.tfvars and fill in before running |
- Terraform >= 1.11
- AWS CLI configured -
aws sts get-caller-identityshould succeed - AWS identity with permissions:
eks:*,ec2:*,rds:*,s3:*,iam:*,logs:*,elasticloadbalancing:* awsCLI available inPATH- used by the Helm provider for EKS auth
Terraform's S3 backend requires the bucket to exist before terraform init can run.
Create it manually once and reuse across all environments:
aws s3api create-bucket \
--bucket inpost-terraform-state \
--region eu-central-1 \
--create-bucket-configuration LocationConstraint=eu-central-1
aws s3api put-bucket-versioning \
--bucket inpost-terraform-state \
--versioning-configuration Status=EnabledThe bucket name is already set in backends/dev.hcl and backends/prd.hcl.
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars: set cluster_name to match ArgoCD (e.g. dev-global-cluster-0)Each environment has its own backend config file under backends/. Pass it at init time:
# dev
terraform init -backend-config=backends/dev.hcl
# prd (switching in the same directory - -reconfigure replaces the active backend)
terraform init -reconfigure -backend-config=backends/prd.hclBoth environments share one S3 bucket but isolate state in separate keys. Using the same key would cause the second apply to overwrite the first environment's state.
terraform plan
terraform applyterraform apply provisions EKS, RDS, S3, nginx, cert-manager, ArgoCD, Cluster
Autoscaler, and Let's Encrypt ClusterIssuers. AppProject and ApplicationSet come from
the app repo.
Configure kubectl after apply:
terraform output -raw kubeconfig_command | bash# Get the initial admin password
terraform output -raw argocd_admin_password | bash
# Find the ArgoCD server URL
kubectl get svc argocd-server -n argocdBootstrap ArgoCD config from the application repo (AppProject, ApplicationSet):
kubectl apply -f argocd/argocd-project.yaml
kubectl apply -f argocd/applicationset.yamlClusterIssuers (letsencrypt-staging and letsencrypt-prod) are created by
terraform apply via kubernetes_manifest in addons.tf - no manual step needed.
On a brand-new cluster the cert-manager CRD schema is unavailable at plan time;
run terraform apply a second time if you see a CRD-not-found error during plan.
Watch application syncs:
argocd login <argocd-server-ip>
argocd app listAnnotate the app's ServiceAccount with the role ARN from Terraform output:
terraform output -raw app_irsa_role_arnapiVersion: v1
kind: ServiceAccount
metadata:
name: webapp # must match var.project
namespace: webapp # must match var.project
annotations:
eks.amazonaws.com/role-arn: <app_irsa_role_arn>RDS has deletion_protection = true. Disable it before terraform destroy:
aws rds modify-db-instance \
--db-instance-identifier webapp-<env> \
--no-deletion-protection \
--apply-immediately
# Wait ~1 minute for the change to apply, then:
terraform destroyWrite-only DB password - password_wo (Terraform >= 1.11, AWS provider >= 5.80)
applies the password to RDS without writing it to .tfstate. The password is unrecoverable
from state after apply; retrieve it from the random_password resource output before
destroying if needed.
RDS Multi-AZ - db.t4g.micro with multi_az = true roughly doubles the DB cost
(~$30 to ~$60/month) for ~60s automatic failover. Graviton over Intel saves ~20% of that.
Per-AZ NAT GW in prd, single in dev - network.tf uses
count = var.environment == "prd" ? length(local.azs) : 1. Single GW in dev costs
$32/month; prd gets one per AZ ($64/month) so an AZ failure doesn't cut egress for
nodes in the other AZ.
Managed nodes over Fargate - vpc-cni, kube-proxy, and nginx ingress run as DaemonSets, which Fargate doesn't support.
Helm add-ons in Terraform - keeps infra in a single apply. ClusterIssuers go in via
kubernetes_manifest so no post-apply kubectl is needed. ArgoCD config (AppProject,
ApplicationSet) stays in the app repo. Before terraform destroy, delete ArgoCD
Applications to avoid finalizer deadlocks.
IRSA over node instance profiles - two roles: app-s3 scoped to the app's S3 bucket,
cluster-autoscaler scoped to autoscaling actions. Bound by OIDC sub claim to specific
service accounts so neither role can be assumed by arbitrary pods.
No DynamoDB state locking - safe for a single operator; add dynamodb_table to the
backend for team use.