Deploy NestJS + Angular to AWS with Terraform: Step-by-Step Guide
Here is the truth nobody tells you: building your NestJS app is the easy part. Getting it to production on infrastructure you actually control? That is where most developers hit a wall. 🏗️ "Push to Heroku" works for demos. But when you have paying customers, you need auto-scaling, private networking, managed databases, and a CDN. And you need it all reproducible with a single command.
I have deployed NestJS + Angular stacks to AWS more times than I can count, and in this guide I will walk you through exactly how to do it with Terraform -- no guesswork, no 2 AM debugging sessions.
Why Terraform for NestJS + Angular
Most NestJS deployment guides end with "push to Heroku" or "deploy to Railway." That works for prototypes, but production SaaS applications need infrastructure you control: auto-scaling, private networking, managed databases, and CDN distribution.
Terraform lets you define all of this as code. Your entire AWS infrastructure lives in version-controlled .tf files. Need a staging environment? Run terraform apply with different variables. Need to roll back? Check the git history.
This guide walks through deploying a complete NestJS + Angular stack to AWS using Terraform. If you are new to NestJS, check out our NestJS REST API tutorial first. For a comparison of deployment approaches between stacks, see NestJS + Angular vs Next.js.
Architecture Overview
The target architecture uses five AWS services:
- ECS Fargate runs your NestJS API in containers without managing servers
- RDS PostgreSQL provides a managed database with automated backups
- S3 + CloudFront serves your Angular frontend as a static site with global CDN
- ElastiCache Redis handles caching and background job queues
- Application Load Balancer routes traffic to ECS with health checks and SSL
Here is how traffic flows: users hit CloudFront for the Angular app. The Angular app makes API calls to the ALB, which routes to ECS Fargate containers running NestJS. NestJS talks to RDS and ElastiCache inside a private VPC.
Cloudrix SaaS Starter ships with this pre-configured and tested.
Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.
Try the live demo →Project Structure
Organize your Terraform files by concern:
infrastructure/
terraform/
environments/
production/
main.tf
variables.tf
terraform.tfvars
staging/
main.tf
variables.tf
terraform.tfvars
modules/
networking/
main.tf
variables.tf
outputs.tf
ecs/
main.tf
variables.tf
outputs.tf
task-definition.json
rds/
main.tf
variables.tf
outputs.tf
frontend/
main.tf
variables.tf
outputs.tf
redis/
main.tf
variables.tf
outputs.tf
Each module handles one piece of infrastructure. Environments compose modules with different variables.
Step 1: Networking Module
Every AWS deployment starts with a VPC. This module creates the network foundation:
# modules/networking/main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project}-vpc"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project}-public-${count.index}"
}
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project}-private-${count.index}"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
}
Public subnets hold the load balancer. Private subnets hold ECS tasks, RDS, and Redis. The NAT gateway lets private resources access the internet for pulling Docker images.
Step 2: RDS PostgreSQL Module
The database module provisions a managed PostgreSQL instance:
# modules/rds/main.tf
resource "aws_db_subnet_group" "main" {
name = "${var.project}-db-subnet"
subnet_ids = var.private_subnet_ids
}
resource "aws_security_group" "rds" {
name_prefix = "${var.project}-rds-"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.ecs_security_group_id]
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project}-db"
engine = "postgres"
engine_version = "16.3"
instance_class = var.db_instance_class
allocated_storage = 20
max_allocated_storage = 100
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
backup_retention_period = 7
multi_az = var.environment == "production"
skip_final_snapshot = var.environment != "production"
tags = {
Environment = var.environment
}
}
Key decisions: storage encryption is always on, backups retain 7 days, multi-AZ is enabled for production only, and the security group only allows connections from ECS containers.
💡 Running multi-tenant SaaS? Your RDS setup needs to support schema isolation. Read our multi-tenancy architecture guide to understand the database patterns before deploying.
Step 3: ECS Fargate Module
This is where your NestJS API runs. ECS Fargate manages containers without EC2 instances:
# modules/ecs/main.tf
resource "aws_ecs_cluster" "main" {
name = "${var.project}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_task_definition" "api" {
family = "${var.project}-api"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([{
name = "api"
image = "${var.ecr_repository_url}:${var.image_tag}"
portMappings = [{
containerPort = 3000
protocol = "tcp"
}]
environment = [
{ name = "NODE_ENV", value = var.environment },
{ name = "DB_HOST", value = var.db_host },
{ name = "DB_PORT", value = "5432" },
{ name = "DB_NAME", value = var.db_name },
{ name = "REDIS_HOST", value = var.redis_host },
]
secrets = [
{ name = "DB_PASSWORD", valueFrom = var.db_password_arn },
{ name = "JWT_SECRET", valueFrom = var.jwt_secret_arn },
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.api.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "api"
}
}
}])
}
resource "aws_ecs_service" "api" {
name = "${var.project}-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 3000
}
}
Secrets come from AWS Secrets Manager, not environment variables. Container insights are enabled for monitoring. The service runs in private subnets behind the load balancer.
Step 4: Angular Frontend on S3 + CloudFront
The Angular app is built as static files and served from S3 through CloudFront:
# modules/frontend/main.tf
resource "aws_s3_bucket" "frontend" {
bucket = "${var.project}-frontend-${var.environment}"
}
resource "aws_s3_bucket_website_configuration" "frontend" {
bucket = aws_s3_bucket.frontend.id
index_document { suffix = "index.html" }
error_document { key = "index.html" }
}
resource "aws_cloudfront_distribution" "frontend" {
origin {
domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name
origin_id = "S3Origin"
origin_access_control_id = aws_cloudfront_origin_access_control.main.id
}
enabled = true
default_root_object = "index.html"
aliases = [var.domain_name]
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3Origin"
viewer_protocol_policy = "redirect-to-https"
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
}
# Handle Angular routing - return index.html for all 404s
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
viewer_certificate {
acm_certificate_arn = var.certificate_arn
ssl_support_method = "sni-only"
}
restrictions {
geo_restriction { restriction_type = "none" }
}
}
The custom_error_response block is critical for Angular. Without it, direct navigation to /dashboard/settings would return a 404 because that path does not exist as a file in S3. By returning index.html for all 404s, Angular's router handles the navigation client-side. Learn more about Angular frontend optimization in our Angular SSR and SEO guide, or see how to build your Angular admin dashboard.
Step 5: Auto-Scaling
Add auto-scaling so your API handles traffic spikes:
resource "aws_appautoscaling_target" "ecs" {
max_capacity = var.max_count
min_capacity = var.min_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${var.project}-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70.0
}
}
This scales ECS tasks between min_count and max_count based on CPU utilization. When average CPU exceeds 70%, ECS launches more containers.
Step 6: Deploy with CI/CD
Add a GitHub Actions workflow that deploys on merge to main:
# .github/workflows/deploy.yml
name: Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Angular
run: npx nx build web --configuration=production
- name: Build and push Docker image
run: |
aws ecr get-login-password | docker login --username AWS --password-stdin $ECR_URL
docker build -t $ECR_URL:${{ github.sha }} -f apps/api/Dockerfile .
docker push $ECR_URL:${{ github.sha }}
- name: Deploy API
run: |
cd infrastructure/terraform/environments/production
terraform init
terraform apply -auto-approve -var="image_tag=${{ github.sha }}"
- name: Deploy Frontend
run: |
aws s3 sync dist/apps/web/browser s3://$FRONTEND_BUCKET --delete
aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths "/*"
Every merge to main builds both apps, pushes a new Docker image, updates the ECS task definition, and syncs the Angular build to S3.
🔥 Skip the infrastructure grind. Every Terraform module in this guide is pre-built, tested, and included in the Cloudrix SaaS Starter. Run
terraform applyand your full stack is live on AWS in under 15 minutes. Try the free demo. 🎯
Cost Estimation
For a typical SaaS with moderate traffic, expect monthly AWS costs around:
| Service | Monthly Cost |
|---|---|
| ECS Fargate (2 tasks, 0.5 vCPU, 1GB) | ~$30 |
| RDS PostgreSQL (db.t3.micro) | ~$15 |
| ElastiCache Redis (cache.t3.micro) | ~$13 |
| S3 + CloudFront | ~$5 |
| ALB | ~$20 |
| NAT Gateway | ~$35 |
| Total | ~$118/month |
Compare this to Vercel Pro ($20/month per team member) or Railway ($5/month per service) at scale. Self-managed AWS is cheaper as your team and traffic grow. Read more about this in our SaaS starter vs building from scratch cost analysis.
Skip the Infrastructure Work 🎯
Every Terraform module described in this guide is included in the Cloudrix SaaS Starter. The infrastructure code is pre-configured, tested, and documented. Run terraform apply and your full stack is live on AWS in under 15 minutes. No infrastructure guesswork, no debugging security groups at 2 AM.
Your SaaS deserves production-grade infrastructure from day one. The stack includes authentication, Stripe billing, and multi-tenancy -- everything you need to go live. Start with the free Lite version, or view pricing for Pro and Enterprise plans.
Try the live demo | Get the free lite version | View pricing
Related guides: Stripe Subscriptions complete guide | Multi-tenancy architecture | NestJS + Angular vs Next.js | Docker Compose for NestJS + Angular | NestJS Testing Guide