All QuorumProof cloud infrastructure is defined in Terraform under
infra/terraform/ and changed only through
pull requests. Manual console changes are treated as drift and reported
automatically.
| Path | Purpose |
|---|---|
infra/terraform/modules/network |
VPC, public/private subnets across AZs, NAT gateways, VPC flow logs |
infra/terraform/modules/compute |
ECS Fargate cluster, api-server service + autoscaling, ALB (HTTPS, /health/ready target health) |
infra/terraform/modules/database |
Aurora PostgreSQL, Aurora Global Database membership, replication-lag alarm |
infra/terraform/modules/storage |
Encrypted, versioned, private S3 bucket with optional cross-region replication |
infra/terraform/modules/failover |
Route 53 health checks, failover records, failover SNS alarms |
infra/terraform/modules/log-archive |
Log archive bucket, lifecycle tiering, Object Lock, Firehose, Athena |
infra/terraform/environments/staging |
Single-region stack (cost-reduced) |
infra/terraform/environments/production |
Two-region active/passive stack (multi-region-failover.md) |
Modules never declare providers; environment roots pass in (aliased) providers, so the same module is instantiated once per region.
- Terraform 1.9+
- AWS credentials for the target account
- A Route 53 hosted zone and ACM certificates in each region for the API hostname (these are account-level and deliberately not managed here)
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
aws s3api create-bucket --bucket "quorumproof-terraform-state-$ACCOUNT" --region us-east-1
aws s3api put-bucket-versioning --bucket "quorumproof-terraform-state-$ACCOUNT" \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket "quorumproof-terraform-state-$ACCOUNT" \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms"}}]}'
aws s3api put-public-access-block --bucket "quorumproof-terraform-state-$ACCOUNT" \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws dynamodb create-table --table-name quorumproof-terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH --billing-mode PAY_PER_REQUESTThen in each environment directory copy backend.hcl.example to
backend.hcl (git-ignored) and fill in the bucket name.
cd infra/terraform/environments/staging
cp ../production/terraform.tfvars.example terraform.tfvars # edit values
terraform init -backend-config=backend.hcl
terraform plan -out=tfplan- Open a PR with the change. CI (
.github/workflows/terraform.yml) runs:terraform fmt -checkterraform validateon every module and environmentterraform test(module unit tests, see below)tflint(recommended + AWS rulesets)checkovsecurity scan (SARIF → code scanning)terraform planfor staging and production, posted as a PR comment
- After review and merge, a maintainer runs the Terraform workflow
manually with
action=applyand the target environment. Apply jobs use the protectedinfra-staging/infra-productionGitHub environments, which require reviewer approval, and assume a dedicated apply role via OIDC — no long-lived AWS keys in GitHub. - Always apply to staging first.
| Secret | Used by |
|---|---|
AWS_TERRAFORM_PLAN_ROLE_ARN |
plan + drift jobs (read-only role, OIDC trust on this repo) |
AWS_TERRAFORM_APPLY_ROLE_ARN |
apply job (write role, OIDC trust restricted to main + environment) |
TF_BACKEND_HCL |
contents of backend.hcl |
TF_VARS_STAGING, TF_VARS_PRODUCTION |
contents of each environment's tfvars |
- The Terraform code, provider lock files (
.terraform.lock.hcl, commit them after the firstinit) and module tests live in git; state lives in the versioned S3 bucket, so every past state is recoverable. - Provider and Terraform versions are pinned in
versions.tf. - Git-ignored:
.terraform/,*.tfstate*, plan files,backend.hcl, real*.tfvars(only*.tfvars.exampleis committed). - Drift detection runs every Monday: a non-empty
plan -detailed-exitcodeopens / updates aninfra-driftGitHub issue.
| Layer | Tool | Where |
|---|---|---|
| Formatting | terraform fmt |
CI |
| Static validity | terraform validate |
CI, every module + root |
| Unit tests | terraform test with mock_provider (plan-only, no credentials) |
infra/terraform/modules/*/tests/*.tftest.hcl |
| Lint | tflint |
infra/terraform/.tflint.hcl |
| Security policy | checkov |
infra/terraform/.checkov.yml (each skip justified) |
| Integration | terraform plan against real state |
CI on PRs |
| Behavioural | failover drills | multi-region-failover.md |
Run the unit tests for a module locally:
cd infra/terraform/modules/failover
terraform init -backend=false
terraform testThe tests assert module contracts — e.g. the failover module rejects two primaries, the log archive enforces ≥ 365-day retention and Object Lock by default, S3 replication is only configured when a destination is given, a secondary Aurora cluster never creates its own global cluster.
For resources that already exist, use import blocks in the environment root
rather than recreating them:
import {
to = module.storage_primary.aws_s3_bucket.this
id = "existing-bucket-name"
}Run terraform plan until it shows no changes for imported resources, then
remove the import blocks in a follow-up PR.