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 ──▶ CloudFront ──▶ ALB (+ WAF) ──▶ ECS Fargate: fsh-api ──▶ RDS PostgreSQL (HTTPS, opt) │ └─▶ ElastiCache Redis ├──────────────▶ S3 (file uploads, + CloudFront) │ one-shot ECS task: fsh-db-migrator ─────────┴──────────────▶ RDS (migrate + seed)- 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. Optionally fronted by CloudFront (
enable_api_cloudfront) so the HTTPS SPAs can call the API over HTTPS with no custom domain (no mixed-content). - Migrations — the schema is applied by a one-shot
fsh-db-migratorECS task the deploy runs afterapply(never at API startup). It migrates and — in dev — seeds the admin user and default tenant; demo tenants are opt-in. - Data — RDS PostgreSQL (optional Multi-AZ, AWS-managed master password in Secrets Manager) and ElastiCache Redis/Valkey (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 from. Two images are used — the API (
ghcr.io/fullstackhero/fsh-api) and the migrator (ghcr.io/fullstackhero/fsh-db-migrator), built together from the same commit and sharing one immutable tag. The defaults are the public GHCR images; 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, the migrator ECS task |
| jq | any | reading Terraform outputs in deploy.sh (the PowerShell deploy.ps1 uses ConvertFrom-Json — no jq) |
| Node | 20+ | building the React apps |
| .NET SDK | 10 | only with --build-api (building/pushing the API + migrator images) |
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"
# HTTPS for the API with no custom domain: front the ALB with CloudFront so the# HTTPS SPAs can call it without mixed-content errors.enable_api_cloudfront = true
db_name = "fshdb"db_username = "fshadmin"db_manage_master_user_password = true # AWS manages the password in Secrets Manager
# Container images — the API and migrator share one immutable tag (never "latest").# CI publishes "dev-<full-sha>"; `deploy.sh --build-api` publishes a 12-char short SHA.container_registry = "ghcr.io/fullstackhero"api_image_name = "fsh-api"migrator_image_name = "fsh-db-migrator"container_image_tag = "dev-<sha>"
# What the one-shot migrator task runs. Dev migrates AND seeds (admin + default# tenant); seeding is idempotent. Demo tenants (acme/globex) are opt-in via --seed-demo.migrator_command = ["apply", "--seed"]dev, staging, and prod ship as ready-to-edit examples (prod enables
Multi-AZ, deletion protection, autoscaling, and CloudWatch alarms). dev ships
for two regions — us-east-1 and ap-south-1 — as a multi-region template.
Step 3 — Deploy (one command)
From deploy/terraform/apps/starter/. The region is required — pass it as
the second argument (the scripts never assume one; they prompt when it is
omitted interactively and fail in CI):
./deploy.sh dev us-east-1./deploy.ps1 -Environment dev -Region us-east-1That single command runs, in order:
terraform init+applyfor the env/region (all the infra above).- (optional,
--build-api) builds and pushes both the API and migrator container images at the current git SHA and deploys that tag. - runs the
fsh-db-migratorone-shot ECS task (apply/apply --seed) and waits for it to exit0— the schema is applied here, not at API startup. npm run buildfor each React app,aws s3 syncto its bucket (keeping the Terraform-managedconfig.json), and a CloudFront invalidation.
First deploy, building the images yourself, with no prompts:
./deploy.sh prod us-east-1 --build-api --auto-approveFlags
deploy.sh | deploy.ps1 | What |
|---|---|---|
--build-api | -BuildApi | Build & push the API and migrator images at the current git SHA and deploy that tag (needs the .NET SDK + a registry login). |
--image-tag TAG | -ImageTag TAG | Deploy a specific, already-published tag. |
--registry REG | -Registry REG | Container registry (default ghcr.io/fullstackhero); only used with build. |
--skip-migrate | -SkipMigrate | Skip the migrator ECS task after apply. |
--seed-demo | -SeedDemo | After migrating, also run the migrator’s seed-demo verb (acme/globex demo tenants). |
--skip-frontend | -SkipFrontend | Skip building/publishing the SPAs (infra + migrate only). |
--auto-approve | -AutoApprove | Don’t prompt before terraform apply. |
Database migrations & seeding
The deploy never migrates at API startup. After terraform apply it runs the
fsh-db-migrator image as a one-shot Fargate task in the private subnets,
with the verb from migrator_command (dev: ["apply", "--seed"]), and fails
the deploy if the task exits non-zero (pointing you at its CloudWatch log group).
Seeding is idempotent. The acme/globex demo tenants are opt-in — add
--seed-demo / -SeedDemo to also run the migrator’s seed-demo verb. Skip
the whole step with --skip-migrate / -SkipMigrate.
Prefer 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 run the migrator task and publish each SPA by hand (see the app stack
README.md) — or just use deploy.sh / deploy.ps1, which do all three.
Step 4 — Find your endpoints
cd deploy/terraform/apps/starter/app_stackterraform output api_url # HTTPS CloudFront URL when enable_api_cloudfront, else the ALB URLterraform 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/│ ├── ecs_task/ # the one-shot DbMigrator task│ ├── 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 (infra → migrate → publish SPAs) └── destroy.ps1 # tear one env/region downSecurity 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 (
1654) on the .NETnobleruntime base image.
Teardown
The destroy.ps1 helper re-points the backend and destroys one env/region in
one step:
./destroy.ps1 -Environment dev -Region us-east-1cd deploy/terraform/apps/starter/app_stackterraform init -reconfigure -backend-config=../envs/dev/us-east-1/backend.hclterraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvarsNext
- Local Orchestration with Aspire — the dev-time mirror of this topology.