Skip to content
← Back to blog
NestJSAngularTerraformAWSDevOpsDeployment

Deploy NestJS + Angular to AWS with Terraform: Step-by-Step Guide

Firas Sayah·May 14, 2026·9 min read

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.

Cloud infrastructure and server deployment

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.

Server room with networking equipment

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 apply and 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.

Dashboard showing cloud cost analytics

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

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.