Skip to content
fullstackhero

Guide

Deploy to AWS with Terraform

End-to-end AWS deployment — prerequisites, bootstrapping the state backend, configuring an environment, and the one-command deploy that ships the API and both React apps.

views 0 Last updated

The kit ships production-grade Terraform that stands up the whole stack on AWS and publishes the API and both React apps with one command. This guide takes you from nothing installed to a running deployment.

The Terraform lives under deploy/terraform/.

What gets provisioned

┌─────────────── CloudFront (OAC) ──────────────┐
Browser ──▶ admin SPA ─┤ private S3 │
──▶ dashboard ─┘ private S3 │
Browser/API ──▶ ALB (+ WAF) ──▶ ECS Fargate: fsh-api ──▶ RDS PostgreSQL
│ └─▶ ElastiCache Redis
└─────────────▶ S3 (file uploads, + CloudFront)
  • Network — VPC across your AZs, public + private subnets, NAT gateway(s), and VPC endpoints (S3, ECR, Logs, Secrets Manager) to keep traffic off the public internet.
  • API — ECS Fargate service behind an Application Load Balancer, fronted by AWS WAF (rate limiting + managed rule sets). Autoscaling, deployment circuit breaker, container in private subnets.
  • Data — RDS PostgreSQL (optional Multi-AZ, AWS-managed master password in Secrets Manager) and ElastiCache Redis (encryption in transit + at rest).
  • Files — an S3 bucket for application uploads, optionally fronted by CloudFront.
  • Frontends — the admin and dashboard React SPAs each on a private S3 bucket + CloudFront (Origin Access Control), with SPA routing, default-on security headers (HSTS, nosniff, frame, referrer), and a Terraform-managed config.json.
  • Observability — CloudWatch alarms (opt-in) for ECS, RDS, Redis, and the ALB.

Prerequisites

AWS

  • An AWS account and an IAM principal (user or SSO role) with permissions to create the resources above. The simplest start is an admin-level role; scope it down later.
  • AWS CLI v2, configured: aws configure (or aws configure sso). Verify with aws sts get-caller-identity.
  • A container registry the ECS tasks can pull the API image from. The default is the public ghcr.io/fullstackhero/fsh-api image; point container_registry at your own (ECR/GHCR) if you publish your own builds.

Tooling

ToolVersionNeeded for
Terraform>= 1.15.4everything
AWS CLIv2auth, S3 sync, CloudFront invalidation
jqanythe deploy script reads Terraform outputs
Node20+building the React apps
.NET SDK10only with --build-api (building/pushing the API image)

Verify:

Terminal window
terraform version # >= 1.15.4
aws sts get-caller-identity
jq --version
node --version # >= 20

Step 1 — Bootstrap the state backend (one-time)

Terraform stores its state in an S3 bucket with native locking (no DynamoDB needed). Create it once per account:

Terminal window
cd deploy/terraform/bootstrap
terraform init
terraform apply -var bucket_name=my-org-fsh-tfstate

This creates a versioned, encrypted, TLS-only S3 bucket with prevent_destroy. Put its name into each environment’s backend config at deploy/terraform/apps/starter/envs/<env>/<region>/backend.hcl:

envs/dev/us-east-1/backend.hcl
bucket = "my-org-fsh-tfstate"
key = "dev/us-east-1/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true

Step 2 — Configure your environment

Each environment is a *.tfvars file under deploy/terraform/apps/starter/envs/<env>/<region>/. Edit the values for your account — at minimum the globally unique S3 bucket names:

envs/dev/us-east-1/terraform.tfvars
environment = "dev"
region = "us-east-1"
# S3 buckets — must be globally unique across all of AWS
app_s3_bucket_name = "my-org-dev-fsh-app"
dashboard_s3_bucket_name = "my-org-dev-fsh-dashboard"
admin_s3_bucket_name = "my-org-dev-fsh-admin"
db_name = "fshdb"
db_username = "fshadmin"
db_manage_master_user_password = true # AWS manages the password in Secrets Manager
# Immutable image tag (git SHA or semver) — never "latest"
container_image_tag = "v1.0.0"

dev, staging, and prod ship as ready-to-edit examples (prod enables Multi-AZ, deletion protection, autoscaling, and CloudWatch alarms).

Step 3 — Deploy (one command)

From deploy/terraform/apps/starter/:

bash / CI / macOS / Linux
./deploy.sh dev
Windows PowerShell
./deploy.ps1 -Environment dev

That single command:

  1. terraform init + apply for the environment (all the infra above).
  2. (optional) --build-api / -BuildApi builds and pushes the API container image at the current git SHA and deploys that tag.
  3. npm run build for each React app, aws s3 sync to its bucket (keeping the Terraform-managed config.json), and a CloudFront invalidation.

First deploy, building the API image yourself, with no prompts:

Terminal window
./deploy.sh prod --build-api --auto-approve

Prefer raw Terraform?

Terminal window
cd deploy/terraform/apps/starter/app_stack
terraform init -backend-config=../envs/dev/us-east-1/backend.hcl
terraform apply -var-file=../envs/dev/us-east-1/terraform.tfvars

Then publish each SPA by hand (see the app stack README.md).

Step 4 — Find your endpoints

Terminal window
cd deploy/terraform/apps/starter/app_stack
terraform output api_url # ALB URL for the API
terraform output dashboard_site # { bucket_name, cloudfront_domain_name, url, ... }
terraform output admin_site

Open the CloudFront url from each site output to reach the apps. The API’s CORS allow-list is wired automatically to include both SPA origins.

Layout reference

deploy/terraform/
├── bootstrap/ # one-time S3 state backend
├── modules/ # reusable building blocks
│ ├── network/ alb/ waf/ ecs_cluster/ ecs_service/
│ ├── rds_postgres/ elasticache_redis/ s3_bucket/
│ ├── static_site/ # private S3 + CloudFront for an SPA
│ └── cloudwatch_alarms/
└── apps/starter/
├── app_stack/ # the root config — run Terraform here
├── envs/<env>/<region>/ # backend.hcl + terraform.tfvars per environment
└── deploy.sh / deploy.ps1 # one-command deploy

Security defaults

  • API tasks run in private subnets with no public IP; only the ALB is internet-facing, and it sits behind WAF.
  • The ALB drops invalid headers and runs desync mitigation in defensive mode.
  • RDS and Redis security groups are scoped to the VPC CIDR; Redis uses encryption in transit + at rest; the DB password is managed in Secrets Manager and injected via the ECS task execution role.
  • SPA buckets are private — CloudFront reads them through Origin Access Control, and a managed response-headers policy adds HSTS and the usual hardening headers.
  • The API container runs as a non-root user on a chiseled (.NET) base image.

Teardown

Terminal window
cd deploy/terraform/apps/starter/app_stack
terraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvars

Next