Infrastructure-as-code for a three-tier web application on AWS (primary focus) and GCP (scaffolded inputs). The goal is a production-style layout: separated concerns, multiple environments, remote state, and clear naming and tagging.
| Area | Status |
|---|---|
AWS root module (aws/) |
Core Terraform files; partial S3 backend (configure at terraform init via backend.hcl per environment) |
| AWS environments | dev / stage / prod: *.tfvars.example + backend.hcl.example (separate state key per env: envs/<env>/terraform.tfstate) |
AWS bootstrap (aws/bootstrap/) |
Dedicated stack that creates S3 (remote state) + DynamoDB (state locking) |
AWS modules (aws/modules/) |
Placeholder folders: network, security, app, db (to be implemented) |
GCP (gcp/) |
Example prod.tfvars.example only; no full Terraform root yet |
Sample app (app/) |
React (Vite) UI, Go API, MySQL schema.sql—deploy steps in app/README.md |
The aws/ stack defines VPC, ALB, ASG, RDS, CloudFront, and related services. Use app/ to build the UI and API, then install artifacts on AWS as described in app/README.md.
This stack provisions a production-style 3-tier web architecture on AWS:
- Presentation tier: CloudFront + private S3 origin for static frontend hosting
- Application tier: Public ALB + private EC2 Auto Scaling Group
- Data tier: Private RDS in dedicated DB subnets
- Cross-cutting controls: IAM least-privilege, optional WAF, backup, monitoring, and optional Route53 aliases
The stack creates a dedicated VPC with DNS support and DNS hostnames enabled, plus three subnet tiers across configured AZs:
- Public subnets
- Host the Application Load Balancer
- Route
0.0.0.0/0to an Internet Gateway - Host one NAT Gateway per public subnet/AZ for app-tier egress
- Private app subnets
- Host EC2 app instances (no public IPs)
- Route outbound traffic through NAT Gateways
- Optionally host interface endpoints for
ssm,ssmmessages, andec2messages
- Private DB subnets
- Used by the RDS DB subnet group
- Isolated from direct internet ingress
Security is enforced with SG boundaries and IAM:
- ALB SG: allows inbound
80/443from internet (IPv4 and optional IPv6) - App SG: allows inbound app port traffic only from ALB SG
- DB SG: allows inbound DB port traffic only from App SG
- EC2 IAM role: includes Session Manager access and scoped Secrets Manager/KMS permissions for the RDS master secret
This design keeps only the load-balancing entrypoint internet-facing while application and data tiers remain private.
- Launch template
- Uses latest Amazon Linux 2 AMI from SSM public parameters
- Attaches app instance profile and app SG
- Writes
/opt/app/app.envwithAPP_PORT,HEALTH_ENDPOINT, andBACKEND_ALLOWED_ORIGINS
- Auto Scaling Group
- Spans private app subnets
- Uses configurable
min/desired/maxcapacity - Registers instances in ALB target group and uses ELB health checks
- ALB listeners
- HTTP listener can redirect to HTTPS (
enable_http_to_https_redirect) or forward directly - HTTPS listener terminates TLS with ACM certificate (
acm_certificate_arn)
- HTTP listener can redirect to HTTPS (
- Scaling policy
- Target tracking on
ALBRequestCountPerTarget
- Target tracking on
- Frontend S3 bucket
- Private, versioned, and locked down with block public access
- Ownership mode
BucketOwnerEnforced - Lifecycle policy to expire noncurrent versions after a configurable number of days
- CloudFront distribution
- Uses Origin Access Control (OAC) with SigV4 for private S3 access
- Redirects viewers to HTTPS
- Supports SPA fallback by mapping
403/404toindex.html(configurable error document) - Supports optional custom domain with ACM cert in
us-east-1
- Bucket policy
- Grants
s3:GetObjectonly to the specific CloudFront distribution ARN
- Grants
- RDS deployment
- Provisioned in private DB subnets using DB subnet group
- Not publicly accessible
- Engine/version/class/storage are variable-driven
- Optional Multi-AZ and backup retention controls
- Encryption
- Dedicated KMS key and alias for RDS at-rest encryption
- Credentials
manage_master_user_password = true- RDS manages master password in Secrets Manager
- Sensitive secret ARN is output as
db_master_user_secret_arn - App EC2 role can read only that secret and decrypt with constrained KMS permissions
When enable_waf = true, a regional WAFv2 Web ACL is attached to the ALB with AWS managed rule groups:
AWSManagedRulesCommonRuleSetAWSManagedRulesKnownBadInputsRuleSetAWSManagedRulesSQLiRuleSet
Optional overrides let selected managed rules run in COUNT mode for tuning.
- ALB access logs (optional):
- Dedicated S3 bucket with SSE-S3 encryption, public access block, and lifecycle retention
- Correct bucket policy for ELB log delivery principals
- CloudWatch alarms (optional and configurable):
- ALB unhealthy hosts
- ALB target
5xxresponses - ALB target response time
- RDS CPU utilization
- RDS free storage space
- RDS database connections
- SNS integration: alarm and OK actions can be routed to configured SNS topic ARNs
When enable_aws_backup = true, the stack creates:
- Primary backup vault
- Backup IAM role + managed policy attachments
- Daily backup plan and lifecycle retention
- Backup selection for RDS (and optionally frontend S3)
When enable_backup_cross_region_copy = true, recovery points are copied to a DR-region vault using the provider alias aws.dr.
If route53_zone_id is supplied:
api_dns_namecreates an aliasArecord to the ALBfrontend_domain_namecreates aliasA/AAAArecords to CloudFront
This expects an existing public hosted zone and manages alias records only.
- Naming convention:
<project>-<environment>-<component> - Default tags:
Project,Environment,ManagedBy=Terraform,Owner - Useful outputs include: VPC/subnet IDs, ALB endpoint, ASG/LT IDs, CloudFront URL, frontend bucket name, DB endpoint, and optional WAF/backup/Route53 outputs
Three-Tier-WebApplication/
├── README.md # This file
├── .gitignore # Terraform state, .terraform/, *.tfvars (examples allowed)
├── docs/ # Extra documentation (see docs/README.md)
├── app/ # Sample React + Go + SQL app (deploy onto aws/)
│ ├── deploy/windows/ # PowerShell: build & publish scripts
├── aws/ # Main AWS Terraform root
│ ├── main.tf # Compose modules here
│ ├── versions.tf
│ ├── providers.tf # AWS provider + default_tags from locals
│ ├── variables.tf
│ ├── locals.tf # name_prefix + common_tags
│ ├── outputs.tf
│ ├── environments/
│ │ ├── dev/ (dev.tfvars.example, backend.hcl.example)
│ │ ├── stage/ (stage.tfvars.example, backend.hcl.example)
│ │ └── prod/ (prod.tfvars.example, backend.hcl.example)
│ ├── modules/ # network, security, app, db (stubs)
│ └── bootstrap/ # One-time (per account/region) state backend
│ ├── main.tf # S3 bucket + DynamoDB lock table
│ ├── variables.tf
│ ├── outputs.tf
│ └── terraform.tfvars.example
└── gcp/
└── prod.tfvars.example # Placeholder variables for future GCP stack
- Resource names:
<project>-<environment>-<component>(e.g.three-tier-webapp-prod-vpc).
Uselocal.name_prefix(<project>-<environment>) and append-<component>per resource. - Default tags (via provider
default_tags):Project,Environment,ManagedBy = Terraform,Owner(from variableowner).
environment is restricted to dev, stage, or prod. project_name must be lowercase alphanumeric with hyphens.
- Do not commit real
*.tfvarsor per-environmentbackend.hcl(account-specific bucket/table); they are gitignored. - Do commit
*.tfvars.exampleandbackend.hcl.exampleas templates. - Keep
.terraform.lock.hclcommitted for reproducible provider versions (underaws/andaws/bootstrap/).
The AWS root module sets manage_master_user_password = true on aws_db_instance.main. RDS creates and rotates the master password in AWS Secrets Manager; Terraform must not copy that password into SSM Parameter Store (avoids drift and fights rotation).
- Operators: after apply, read the sensitive output
db_master_user_secret_arnfromaws/outputs.tf(e.g.terraform output -raw db_master_user_secret_arn). - App tier (EC2): the application instance role
aws_iam_role.app_ec2includes an inline policy (aws/secrets_iam.tf) allowingsecretsmanager:GetSecretValueon that secret ARN only andkms:Decrypton the secret’s CMK withkms:ViaServicescoped to Secrets Manager. At runtime the app should callGetSecretValue, parse the JSON secret string, and use the fields AWS documents for RDS master secrets (including host, port, username, password, and DB identifier fields as provided). Wiring that into application code is outside this repo unless you add it explicitly.
- Terraform
>= 1.5.0 - AWS CLI configured with credentials for the target account
- An AWS account (and a globally unique S3 bucket name for bootstrap)
To build and publish the sample app from Windows you also need:
- Go 1.22+ (backend)
- Node.js LTS /
npm(frontend) - Optional: MySQL/MariaDB client (
mysqlon PATH) if you runApply-DatabaseSchema.ps1from your PC (RDS is private; see below)
Bootstrap creates only the infrastructure Terraform needs to store state safely:
- S3 bucket — versioning, encryption, public access blocked, deny insecure transport
- DynamoDB table — lock table with
LockIDattribute for Terraform state locking
Bootstrap uses local state by default so you are not depending on the bucket before it exists.
cd aws/bootstrap- Copy
terraform.tfvars.exampletoterraform.tfvars(ignored by git) and setstate_bucket_nameto a unique name. - Run
terraform init,terraform plan,terraform apply. - Note the outputs (
state_bucket_name,dynamodb_table_name,aws_region,backend_config_hint) for wiring the mainaws/backend.
The main stack uses a partial backend "s3" {} in aws/versions.tf. Terraform does not allow variables inside backend blocks, so bucket, key, region, DynamoDB table, and encryption are supplied at init time.
Per environment, copy environments/<env>/backend.hcl.example → backend.hcl and set:
bucket— bootstrap outputstate_bucket_namekey—envs/dev/terraform.tfstate,envs/stage/terraform.tfstate, orenvs/prod/terraform.tfstate(one object per environment)region— same region as the bucketdynamodb_table— bootstrap outputdynamodb_table_nameencrypt—true(SSE for state in S3)
Initialize (pick one environment per working copy, or re-init with -reconfigure when switching):
cd aws
terraform init -backend-config=environments/prod/backend.hclIf you previously used local state in aws/ and want to upload it to S3, use:
terraform init -backend-config=environments/prod/backend.hcl -migrate-stateValidate without configuring the backend (CI or quick checks):
cd aws
terraform init -backend=false
terraform validatePlan and apply (after init with the correct backend for that environment):
cd aws
terraform plan -var-file=environments/prod/prod.tfvarsCopy each *.tfvars.example to a matching *.tfvars in the same folder and set owner and other values locally.
The sample UI and API live under app/. Terraform provisions RDS MySQL, ALB + ASG (API on app_port, default 8080), S3 + CloudFront (static site), and writes /opt/app/app.env on new instances (APP_PORT, HEALTH_ENDPOINT, BACKEND_ALLOWED_ORIGINS). You still ship the binary, apply SQL, and upload static files.
From aws/ (same workspace you will use for outputs):
cd aws
terraform apply -var-file=environments\dev\dev.tfvars
terraform output -raw alb_https_endpoint
terraform output -raw frontend_cloudfront_url
terraform output -raw frontend_s3_bucket_name
terraform output -raw frontend_cloudfront_distribution_id
terraform output -raw db_master_user_secret_arnSet backend_allowed_origins in your *.tfvars to your CloudFront URL (e.g. https://d111111abcdef8.cloudfront.net) so the Go API allows browser Origin headers. Include http://localhost:5173 only if you call the API from the browser during local UI dev without the Vite proxy. Re-apply after changing it.
Creates the items table and seed rows. RDS has no public endpoint in the default stack, so choose one approach:
-
From your PC: temporarily allow your IP on the RDS security group (or use a VPN into the VPC), ensure
mysqland AWS CLI work, then run:cd <repo-root> .\app\deploy\windows\Apply-DatabaseSchema.ps1
The script reads
db_master_user_secret_arnviaterraform outputand loads credentials from Secrets Manager. -
From an app EC2 instance: open Session Manager, install
mysqlif needed (sudo yum install -y mariadb1011-clientor similar), uploadapp/database/schema.sql, then:mysql -h <rds_address> -u <user> -p <dbname> < schema.sql
Use the master user and password from the RDS secret in Secrets Manager if you are not using IAM DB auth.
-
Build a Linux amd64 binary on Windows:
cd <repo-root> .\app\deploy\windows\Build-Backend.ps1
This writes
app\backend\server(no extension). -
Copy
serverto/opt/app/serveron each instance (ASG may replace instances—automate with Golden AMI, CI, or S3 + user-data later).Practical options: Session Manager file transfer,
aws s3 cpto a bucket your instance role can read (add IAM if you introduce a “releases” bucket), or SCP if you use key-based SSH. -
On the instance, append
DB_SECRET_ARNto/opt/app/app.env(value =terraform output -raw db_master_user_secret_arn). The instance role already hassecretsmanager:GetSecretValuefor that ARN (aws/secrets_iam.tf). -
Install the systemd unit and start the API:
sudo cp /path/to/backend.service.example /etc/systemd/system/backend.service # Edit if needed: WorkingDirectory=/opt/app, EnvironmentFile=/opt/app/app.env, ExecStart=/opt/app/server sudo chmod +x /opt/app/server sudo systemctl daemon-reload sudo systemctl enable --now backend.service
Template:
app/deploy/backend.service.example. -
Confirm the target group sees healthy targets:
GET https://<alb-dns>/healthshould return200and bodyok.
-
Build with the public API base URL (ALB HTTPS, no trailing slash):
cd <repo-root> .\app\deploy\windows\Build-Frontend.ps1 -ApiBaseUrl "https://<your-alb-dns>.<region>.elb.amazonaws.com"
-
Upload
app/frontend/distand invalidate CloudFront (reads bucket and distribution ID from terraform output inaws/):.\app\deploy\windows\Publish-Frontend.ps1Or pass values explicitly:
.\Publish-Frontend.ps1 -Bucket "..." -DistributionId "E..." -Region "us-east-1". -
Open
terraform output -raw frontend_cloudfront_url(or your custom domain if configured).
| Script | Purpose |
|---|---|
app/deploy/windows/Apply-DatabaseSchema.ps1 |
Apply schema.sql using Secrets Manager + mysql (needs network path to RDS). |
app/deploy/windows/Build-Backend.ps1 |
Cross-compile Go API to app/backend/server for Linux. |
app/deploy/windows/Build-Frontend.ps1 |
npm run build with -ApiBaseUrl → app/frontend/dist. |
app/deploy/windows/Publish-Frontend.ps1 |
aws s3 sync + CloudFront invalidation /*. |
All scripts assume the repository layout is intact and you run them from any directory (they resolve paths from $PSScriptRoot). Terraform commands run in aws/ using your current backend and workspace.
More detail and local dev: app/README.md.
Readiness checks were completed for dev before starting network/app/db implementation.
- Backend lock acquisition (DynamoDB): Passed
Confirmed via Terraform output showingAcquiring state lockandReleasing state lockduring plan/apply operations. - State object written to expected S3 key: Passed
Verified object exists atenvs/dev/terraform.tfstatein the remote state bucket. - Provider versions pinned and reproducible: Passed
aws/versions.tfconstrains AWS provider (~> 5.0) andaws/.terraform.lock.hclpins exact provider checksums/version for teammate consistency. - Example tfvars present and non-sensitive: Passed
dev/stage/prod*.tfvars.examplefiles exist and contain sample placeholders (no secrets committed).
Validation commands used:
cd aws
terraform init -backend-config=environments/dev/backend.hcl -reconfigure
terraform plan -var-file=environments/dev/dev.tfvars -lock-timeout=60s
terraform apply -refresh-only -auto-approve -var-file=environments/dev/dev.tfvarsThe gcp/ directory currently holds example variable values only. A full Terraform root (providers, backend, modules) can be added later to mirror the AWS structure.
- Harden app delivery: bake the Go binary into an AMI or pull from S3 in user-data, add health checks and rollouts.
- Automate
backend_allowed_originsandVITE_API_URLper environment (fixed ALB DNS or Route53api_dns_name). - Wire CI/CD:
terraform plan/apply, thenBuild-*/Publish-Frontend.ps1(or Linux equivalents) with OIDC or deployment roles.
Specify your license here if the repository is public or shared.