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 ──▶ 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-migrator ECS task the deploy runs after apply (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 (or aws configure sso). Verify with aws 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; 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, the migrator ECS task
jqanyreading Terraform outputs in deploy.sh (the PowerShell deploy.ps1 uses ConvertFrom-Json — no jq)
Node20+building the React apps
.NET SDK10only with --build-api (building/pushing the API + migrator images)

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"
# 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):

bash / CI / macOS / Linux
./deploy.sh dev us-east-1
Windows PowerShell
./deploy.ps1 -Environment dev -Region us-east-1

That single command runs, in order:

  1. terraform init + apply for the env/region (all the infra above).
  2. (optional, --build-api) builds and pushes both the API and migrator container images at the current git SHA and deploys that tag.
  3. runs the fsh-db-migrator one-shot ECS task (apply / apply --seed) and waits for it to exit 0 — the schema is applied here, not at API startup.
  4. 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 images yourself, with no prompts:

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

Flags

deploy.shdeploy.ps1What
--build-api-BuildApiBuild & 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 TAGDeploy a specific, already-published tag.
--registry REG-Registry REGContainer registry (default ghcr.io/fullstackhero); only used with build.
--skip-migrate-SkipMigrateSkip the migrator ECS task after apply.
--seed-demo-SeedDemoAfter migrating, also run the migrator’s seed-demo verb (acme/globex demo tenants).
--skip-frontend-SkipFrontendSkip building/publishing the SPAs (infra + migrate only).
--auto-approve-AutoApproveDon’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?

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 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

Terminal window
cd deploy/terraform/apps/starter/app_stack
terraform output api_url # HTTPS CloudFront URL when enable_api_cloudfront, else the ALB URL
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/
│ ├── 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 down

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 (1654) on the .NET noble runtime base image.

Teardown

The destroy.ps1 helper re-points the backend and destroys one env/region in one step:

Windows PowerShell
./destroy.ps1 -Environment dev -Region us-east-1
raw Terraform (bash)
cd deploy/terraform/apps/starter/app_stack
terraform init -reconfigure -backend-config=../envs/dev/us-east-1/backend.hcl
terraform destroy -var-file=../envs/dev/us-east-1/terraform.tfvars

Next