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(oraws configure sso). Verify withaws 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-apiimage; pointcontainer_registryat your own (ECR/GHCR) if you publish your own builds.
Tooling
| Tool | Version | Needed for |
|---|---|---|
| Terraform | >= 1.15.4 | everything |
| AWS CLI | v2 | auth, S3 sync, CloudFront invalidation |
| jq | any | the deploy script reads Terraform outputs |
| Node | 20+ | building the React apps |
| .NET SDK | 10 | only with --build-api (building/pushing the API image) |
Verify:
terraform version # >= 1.15.4aws sts get-caller-identityjq --versionnode --version # >= 20Step 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:
cd deploy/terraform/bootstrapterraform initterraform apply -var bucket_name=my-org-fsh-tfstateThis 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:
bucket = "my-org-fsh-tfstate"key = "dev/us-east-1/terraform.tfstate"region = "us-east-1"encrypt = trueuse_lockfile = trueStep 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:
environment = "dev"region = "us-east-1"
# S3 buckets — must be globally unique across all of AWSapp_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/:
./deploy.sh dev./deploy.ps1 -Environment devThat single command:
terraform init+applyfor the environment (all the infra above).- (optional)
--build-api/-BuildApibuilds and pushes the API container image at the current git SHA and deploys that tag. npm run buildfor each React app,aws s3 syncto its bucket (keeping the Terraform-managedconfig.json), and a CloudFront invalidation.
First deploy, building the API image yourself, with no prompts:
./deploy.sh prod --build-api --auto-approvePrefer raw Terraform?
cd deploy/terraform/apps/starter/app_stackterraform init -backend-config=../envs/dev/us-east-1/backend.hclterraform apply -var-file=../envs/dev/us-east-1/terraform.tfvarsThen publish each SPA by hand (see the app stack README.md).
Step 4 — Find your endpoints
cd deploy/terraform/apps/starter/app_stackterraform output api_url # ALB URL for the APIterraform output dashboard_site # { bucket_name, cloudfront_domain_name, url, ... }terraform output admin_siteOpen 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 deploySecurity 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
defensivemode. - 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
cd deploy/terraform/apps/starter/app_stackterraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvarsNext
- Local Orchestration with Aspire — the dev-time mirror of this topology.