Setting up a secure, HA Fullstack App with Terraform & AWS

A step-by-step guide to deploying a full-stack app with ECR, ECS, VPN, IAM, Subnets, and more.

Building a Highly Available Fullstack Application with AWS and Terraform

In this comprehensive guide, we'll explore how to build and deploy a secure, highly available fullstack application using AWS and Terraform. We'll cover everything from initial infrastructure setup to deployment of both frontend and backend services.

Architecture Overview

Our application architecture includes:

  • Frontend and backend services running on Amazon ECS

  • Aurora PostgreSQL database

  • Load balancers for high availability

  • VPC with public and private subnets

  • Bastion host for secure database access

  • Route53 for DNS management

  • AWS Secrets Manager for configuration

  • ECR for container images

Prerequisites

  • AWS Account

  • Terraform installed (version >= 1.0)

  • Docker installed

  • Domain name (we'll use fs0ciety.dev in this example)

  • Create a backend.tf in infra/bootstrap and infra to ensure your state files are stored remotely

      # infra/bootstrap/backend.tf
      terraform {
        backend "s3" {
          bucket         = "traba-terraform-states"  
          key            = "bootstrap/terraform.tfstate"
          region         = "us-east-1"
          encrypt        = true
          dynamodb_table = "terraform-state-locks" 
        }
      }
    
      # infra/backend.tf
      terraform {
        backend "s3" {
          bucket         = "traba-terraform-states"  
          key            = "fullstack-infra/terraform.tfstate"
          region         = "us-east-1"
          encrypt        = true
          dynamodb_table = "terraform-state-locks" 
        }
      }
    

The main.tf file serves as the orchestration point for our entire infrastructure, bringing together all the specialized modules that define our AWS resources. Let's explore each module and understand how they work together to create a robust, production-ready environment:

Each module handles a specific aspect of our infrastructure:

  • Networking: VPC, subnets, and routing

  • Security: Firewall rules and access controls

  • Database: Aurora PostgreSQL cluster

  • Application: ECS services for frontend and backend

  • DNS: Route53 and SSL certificate management

# infra/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  required_version = ">= 1.0"
}

provider "aws" {
  region = var.aws_region
}


locals {
  domain_name     = "fs0ciety.dev"
  frontend_domain = "traba-${var.environment}.${local.domain_name}"
  backend_domain  = "api-traba-${var.environment}.${local.domain_name}"
}

variable "aws_region" {
  description = "AWS region to deploy to"
  type        = string
  default     = "us-east-1"
}


variable "environment" {
  description = "AWS region to deploy to"
  type        = string
}

variable "ecr_repository_url" {
  description = "ECR repository URL"
  type        = string
}

variable "frontend_image_tag" {
  description = "Tag for frontend container image"
  type        = string
}

variable "backend_image_tag" {
  description = "Tag for backend container image"
  type        = string
}


resource "aws_iam_role" "ecs_task_execution_role" {
  name = "traba-${var.environment}-ecs-task-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Environment = var.environment
  }
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" {
  role       = aws_iam_role.ecs_task_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# Task Role - for your application to access AWS services
resource "aws_iam_role" "ecs_task_role" {
  name = "traba-${var.environment}-ecs-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

data "aws_caller_identity" "current" {}
# Policy to allow access to Secrets Manager
resource "aws_iam_role_policy" "ecs_task_secrets" {

  name = "traba-${var.environment}-secrets-policy"
  role = aws_iam_role.ecs_task_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue"
        ]
        Resource = [
          "arn:aws:secretsmanager:us-east-1:${data.aws_caller_identity.current.account_id}:secret:traba-${var.environment}-*"
        ]
      }
    ]
  })
}

# ECS Cluster
resource "aws_ecs_cluster" "main" {
  name = "traba-${var.environment}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Environment = var.environment
  }
}


# environments/staging/main.tf
module "networking" {
  source = "./modules/networking"

  environment          = "staging"
  vpc_cidr             = "10.0.0.0/16"
  public_subnet_count  = 2
  private_subnet_count = 2
}

module "dns" {
  source = "./modules/dns"

  environment          = "staging"
  domain_name          = local.domain_name
  frontend_domain      = local.frontend_domain
  backend_domain       = local.backend_domain
  frontend_lb_dns_name = module.frontend.alb_dns_name
  frontend_lb_zone_id  = module.frontend.alb_zone_id
  backend_lb_dns_name  = module.backend.alb_dns_name
  backend_lb_zone_id   = module.backend.alb_zone_id
}

module "security" {
  source = "./modules/security-group"

  environment             = "staging"
  vpc_id                  = module.networking.vpc_id
  frontend_container_port = 80
  backend_container_port  = 3000
  bastion_allowed_cidrs   = ["0.0.0.0/0"] # Replace with your IP
}

module "database" {
  source = "./modules/database"

  environment        = "staging"
  vpc_id             = module.networking.vpc_id
  private_subnet_ids = module.networking.private_subnet_ids
  security_group_ids = [module.security.aurora_sg_id]

  instance_class = "db.t4g.medium"
  engine_version = "15.4"
  database_name  = "traba"

  # Additional configurations for staging
  backup_retention_period = 7
  deletion_protection     = false
}

module "bastion" {
  source = "./modules/bastion"

  environment   = "staging"
  vpc_id        = module.networking.vpc_id
  vpc_security_group_ids = [module.security.bastion_sg_id]
  subnet_id     = module.networking.public_subnet_ids[0]
  allowed_cidrs = ["0.0.0.0/0"] # Replace with your IP
  instance_type = "t3.micro"
  key_name      = "bastion-key-pair-2"
}



# environments/staging/main.tf

module "frontend" {
  source = "./modules/frontend"

  environment        = "staging"
  vpc_id             = module.networking.vpc_id
  public_subnet_ids  = module.networking.public_subnet_ids
  private_subnet_ids = module.networking.private_subnet_ids
  security_group_ids = [module.security.frontend_sg_id]
  certificate_arn    = module.dns.certificate_arn
  container_image    = "${var.ecr_repository_url}:${var.frontend_image_tag}"
  cluster_id         = aws_ecs_cluster.main.id
  execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn      = aws_iam_role.ecs_task_role.arn

  container_port    = 80
  health_check_path = "/"
}

module "backend" {
  source = "./modules/backend"

  environment        = "staging"
  vpc_id             = module.networking.vpc_id
  public_subnet_ids  = module.networking.public_subnet_ids
  private_subnet_ids = module.networking.private_subnet_ids
  security_group_ids = [module.security.backend_sg_id]
  certificate_arn    = module.dns.certificate_arn
  container_image    = "${var.ecr_repository_url}:${var.backend_image_tag}"
  cluster_id         = aws_ecs_cluster.main.id
  execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn      = aws_iam_role.ecs_task_role.arn

  container_port    = 3000
  health_check_path = "/health"

  depends_on = [
    module.database
  ]
}

data "aws_secretsmanager_secret" "backend_config" {
  name  = "traba-${var.environment}-backend-config" # Use name instead of secret_id
}

data "aws_secretsmanager_secret_version" "backend_config" {
  secret_id = data.aws_secretsmanager_secret.backend_config.id
}

resource "aws_secretsmanager_secret_version" "backend_config" {
  secret_id = data.aws_secretsmanager_secret.backend_config.id

  secret_string = jsonencode(
    merge(
      jsondecode(data.aws_secretsmanager_secret_version.backend_config.secret_string),
      {
        CONN_STRING = "postgresql://${module.database.master_username}:${module.database.master_password}@${module.database.cluster_endpoint}:5432/${module.database.database_name}"
      }
    )
  )

  depends_on = [
    module.database
  ]
}


output "ecs_cluster_name" {
  value       = aws_ecs_cluster.main.name
  description = "Name of the ECS cluster"
}

Part 1: Bootstrap Infrastructure

First, we need to set up our foundational infrastructure. This includes our terraform state management and container registry.

# infra/bootstrap/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.50.0"
    }
  }
}

# Create ECR repository for our containers' images
resource "aws_ecr_repository" "traba" {
  name                 = "traba-${local.environment}"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

# Set up secrets manager for configurations
resource "aws_secretsmanager_secret" "frontend_config" {
  name = "traba-${local.environment}-frontend-config"
}

resource "aws_secretsmanager_secret" "backend_config" {
  name = "traba-${local.environment}-backend-config"
}

Part 2: Networking Setup

Our application uses a multi-AZ VPC setup with public and private subnets:

  • Public subnets for load balancers

  • Private subnets for ECS tasks and database

  • NAT gateways for private subnet internet access. Example:

    • Connecting to DB with Bastion Host (SSH tunneling in)

    • Allowing the Backend to make external API calls

# infra/modules/networking/main.tf
variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_count" {
  description = "Number of public subnets to create"
  type        = number
  default     = 2
}

variable "private_subnet_count" {
  description = "Number of private subnets to create"
  type        = number
  default     = 2
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "traba-${var.environment}-vpc"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  count             = var.public_subnet_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 7, count.index + 1)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name        = "traba-${var.environment}-public-${count.index + 1}"
    Environment = var.environment
  }
}

resource "aws_subnet" "private" {
  count             = var.private_subnet_count
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 7, count.index + var.public_subnet_count + 1)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name        = "traba-${var.environment}-private-${count.index + 1}"
    Environment = var.environment
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "traba-${var.environment}-igw"
    Environment = var.environment
  }
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name        = "traba-${var.environment}-nat-eip"
    Environment = var.environment
  }
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name        = "traba-${var.environment}-nat"
    Environment = var.environment
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"          # All outbound traffic
    gateway_id = aws_internet_gateway.main.id  # Goes through Internet Gateway
  }

  tags = {
    Name        = "traba-${var.environment}-public-rt"
    Environment = var.environment
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"          # All outbound traffic
    nat_gateway_id = aws_nat_gateway.main.id  # Goes through NAT Gateway
  }

  tags = {
    Name        = "traba-${var.environment}-private-rt"
    Environment = var.environment
  }
}

resource "aws_route_table_association" "public" {
  count = var.public_subnet_count

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count = var.private_subnet_count

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = aws_subnet.private[*].id
}

output "public_route_table_id" {
  description = "ID of the public route table"
  value       = aws_route_table.public.id
}

output "private_route_table_id" {
  description = "ID of the private route table"
  value       = aws_route_table.private.id
}

Part 3: Security Configuration

In our AWS infrastructure, security groups act as virtual firewalls that precisely control the flow of traffic to and from our services. Let's break down our security architecture:

Application Load Balancers (ALB)

  • Frontend ALB: Accepts incoming HTTP (port 80) and HTTPS (port 443) traffic from the internet

  • Backend ALB: Exclusively accepts HTTPS traffic and internal health checks

Application Services

  • Frontend containers: Only accept traffic from their associated ALB

  • Backend containers: Only accept traffic from the backend ALB

  • Both services can make outbound connections as needed

Database Layer

  • Aurora PostgreSQL: Only accepts traffic on port 5432 from:

    • Backend services for application requests

    • Bastion host for administrative access

      • Ex: Querying the DB, running migrations, etc.

Administrative Access

  • Bastion Host: Functions as a secure jump server that only permits SSH connections (port 22) from authorized users using SSH key pairs.
# modules/security/variables.tf
variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "frontend_container_port" {
  description = "Port the frontend container listens on"
  type        = number
  default     = 80
}

variable "backend_container_port" {
  description = "Port the backend container listens on"
  type        = number
  default     = 3000
}

variable "bastion_allowed_cidrs" {
  description = "List of CIDR blocks allowed to connect to bastion"
  type        = list(string)
  default     = ["0.0.0.0/0"]  # Should be restricted in production
}

# modules/security/main.tf
# Frontend ALB Security Group
resource "aws_security_group" "frontend_alb" {
  name_prefix = "traba-${var.environment}-frontend-alb-sg"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-frontend-alb-sg"
    Environment = var.environment
  }
}

# Backend ALB Security Group
resource "aws_security_group" "backend_alb" {
  name_prefix = "traba-${var.environment}-backend-alb-sg"
  vpc_id      = var.vpc_id

  ingress {
    from_port   = var.backend_container_port
    to_port     = var.backend_container_port
    protocol    = "tcp"
    self        = true
    description = "Allow health check traffic from ALB"
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"] // Need to be open to all IP for the webhook // todo only make available to FE 
    description = "Allow HTTPS traffic from frontend and Auth0 webhooks"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-backend-alb-sg"
    Environment = var.environment
  }
}

# Frontend ECS Service Security Group
resource "aws_security_group" "frontend" {
  name_prefix = "traba-${var.environment}-frontend-sg"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = var.frontend_container_port
    to_port         = var.frontend_container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.frontend_alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-frontend-sg"
    Environment = var.environment
  }
}

# Backend ECS Service Security Group
resource "aws_security_group" "backend" {
  name_prefix = "traba-${var.environment}-backend-sg"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = var.backend_container_port
    to_port         = var.backend_container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.backend_alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-backend-sg"
    Environment = var.environment
  }
}

# Aurora Security Group
resource "aws_security_group" "aurora" {
  name_prefix = "traba-${var.environment}-aurora-sg"
  vpc_id      = var.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.backend.id]
    description     = "Allow PostgreSQL access from backend service"
  }

  ingress {
    from_port       = 5432 
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.bastion.id]  # or your bastion security group ID
    description     = "Allow PostgreSQL access from bastion host"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-aurora-sg"
    Environment = var.environment
    Service     = "database"
  }
}

# Bastion Security Group
resource "aws_security_group" "bastion" {
  name_prefix = "traba-${var.environment}-bastion-sg"
  vpc_id      = var.vpc_id

  ingress {
    description = "SSH from allowed IPs"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = var.bastion_allowed_cidrs
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "traba-${var.environment}-bastion-sg"
    Environment = var.environment
  }

  lifecycle {
    create_before_destroy = true
  }
}

# modules/security/outputs.tf
output "frontend_alb_sg_id" {
  description = "ID of the frontend ALB security group"
  value       = aws_security_group.frontend_alb.id
}

output "backend_alb_sg_id" {
  description = "ID of the backend ALB security group"
  value       = aws_security_group.backend_alb.id
}

output "frontend_sg_id" {
  description = "ID of the frontend service security group"
  value       = aws_security_group.frontend.id
}

output "backend_sg_id" {
  description = "ID of the backend service security group"
  value       = aws_security_group.backend.id
}

output "aurora_sg_id" {
  description = "ID of the Aurora security group"
  value       = aws_security_group.aurora.id
}

output "bastion_sg_id" {
  description = "ID of the bastion security group"
  value       = aws_security_group.bastion.id
}

Part 4: Database Layer

We use Amazon Aurora PostgreSQL for our database, but I recommend using regular RDS instance to save on costs:

# modules/database/variables.tf
variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs"
  type        = list(string)
}

variable "security_group_ids" {
  description = "List of security group IDs"
  type        = list(string)
}

variable "instance_class" {
  description = "Instance class for Aurora instances"
  type        = string
  default     = "db.t4g.medium"
}

variable "engine_version" {
  description = "Aurora PostgreSQL engine version"
  type        = string
  default     = "15.4"
}

variable "database_name" {
  description = "Name of the database to create"
  type        = string
  default     = "traba"
}

variable "backup_retention_period" {
  description = "Number of days to retain backups"
  type        = number
  default     = 7
}

variable "deletion_protection" {
  description = "Enable deletion protection"
  type        = bool
  default     = false
}

# modules/database/main.tf
resource "aws_db_subnet_group" "aurora" {
  name        = "traba-${var.environment}-aurora"
  description = "Subnet group for Aurora cluster"
  subnet_ids  = var.private_subnet_ids

  tags = {
    Environment = var.environment
    Service     = "database"
  }
}

resource "random_password" "master_password" {
  length  = 16
  special = false
  upper   = true
  lower   = true
  numeric = true
}

resource "aws_rds_cluster" "aurora_cluster" {
  cluster_identifier = "traba-${var.environment}"
  engine            = "aurora-postgresql"
  engine_version    = var.engine_version
  database_name     = var.database_name
  master_username   = "traba_admin"
  master_password   = random_password.master_password.result

  backup_retention_period = var.backup_retention_period
  preferred_backup_window = "07:00-09:00"
  deletion_protection     = var.deletion_protection
  skip_final_snapshot     = var.environment != "prod"

  db_subnet_group_name   = aws_db_subnet_group.aurora.name
  vpc_security_group_ids = var.security_group_ids

  tags = {
    Environment = var.environment
    Service     = "database"
  }
}

resource "aws_rds_cluster_instance" "aurora_instances" {
  identifier         = "traba-${var.environment}-aurora-1"
  cluster_identifier = aws_rds_cluster.aurora_cluster.id
  instance_class     = var.instance_class
  engine             = aws_rds_cluster.aurora_cluster.engine
  engine_version     = aws_rds_cluster.aurora_cluster.engine_version

  tags = {
    Environment = var.environment
    Service     = "database"
  }
}

# Create a secret for the database connection string
resource "aws_secretsmanager_secret" "db_config" {
  name = "traba-${var.environment}-db-config"

  tags = {
    Environment = var.environment
    Service     = "database"
  }
}

resource "aws_secretsmanager_secret_version" "db_config" {
  secret_id = aws_secretsmanager_secret.db_config.id
  secret_string = jsonencode({
    CONN_STRING = "postgresql://${aws_rds_cluster.aurora_cluster.master_username}:${aws_rds_cluster.aurora_cluster.master_password}@${aws_rds_cluster.aurora_cluster.endpoint}:5432/${aws_rds_cluster.aurora_cluster.database_name}"
  })
}

# modules/database/outputs.tf
output "cluster_endpoint" {
  description = "The cluster endpoint"
  value       = aws_rds_cluster.aurora_cluster.endpoint
}

output "cluster_identifier" {
  description = "The cluster identifier"
  value       = aws_rds_cluster.aurora_cluster.cluster_identifier
}

output "database_name" {
  description = "The name of the database"
  value       = aws_rds_cluster.aurora_cluster.database_name
}

output "master_username" {
  description = "The master username"
  value       = aws_rds_cluster.aurora_cluster.master_username
}

output "master_password" {
  description = "The master username"
  value       = aws_rds_cluster.aurora_cluster.master_password
}

output "connection_secret_arn" {
  description = "ARN of the secret containing the connection string"
  value       = aws_secretsmanager_secret.db_config.arn
}

Part 5: Application Layer

Our application runs on ECS with Fargate, which receives it’s image from ECR:

Frontend Service:

# modules/frontend/variables.tf
variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs for ALB"
  type        = list(string)
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs for ECS tasks"
  type        = list(string)
}

variable "security_group_ids" {
  description = "Security group IDs for the ECS service"
  type        = list(string)
}

variable "certificate_arn" {
  description = "ARN of the ACM certificate"
  type        = string
}

variable "container_port" {
  description = "Port the frontend container listens on"
  type        = number
  default     = 80
}

variable "health_check_path" {
  description = "Health check path for frontend service"
  type        = string
  default     = "/"
}

variable "container_image" {
  description = "Container image for frontend service"
  type        = string
}

variable "cluster_id" {
  description = "ID of the ECS cluster"
  type        = string
}

variable "execution_role_arn" {
  description = "ARN of the ECS task execution role"
  type        = string
}

variable "task_role_arn" {
  description = "ARN of the ECS task role"
  type        = string
}

# modules/frontend/main.tf
# Load Balancer
resource "aws_lb" "frontend" {
  name               = "traba-${var.environment}-frontend-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = var.security_group_ids
  subnets           = var.public_subnet_ids

  tags = {
    Name        = "traba-${var.environment}-frontend-alb"
    Environment = var.environment
  }
}

resource "aws_lb_target_group" "frontend" {
  name        = "traba-${var.environment}-frontend-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200,302,404"
    path                = var.health_check_path
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }

  tags = {
    Environment = var.environment
  }
}

# HTTPS Listener
resource "aws_lb_listener" "frontend_https" {
  load_balancer_arn = aws_lb.frontend.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.frontend.arn
  }
}

# HTTP to HTTPS Redirect
resource "aws_lb_listener" "frontend_http" {
  load_balancer_arn = aws_lb.frontend.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "frontend" {
  name              = "/ecs/traba-${var.environment}-frontend"
  retention_in_days = 30

  tags = {
    Environment = var.environment
  }
}

# ECS Task Definition
resource "aws_ecs_task_definition" "frontend" {
  family                   = "traba-${var.environment}-frontend"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = var.execution_role_arn
  task_role_arn           = var.task_role_arn

  container_definitions = jsonencode([
    {
      name  = "frontend"
      image = var.container_image
      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-group"         = aws_cloudwatch_log_group.frontend.name
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])

  tags = {
    Environment = var.environment
  }
}

# ECS Service
resource "aws_ecs_service" "frontend" {
  name            = "traba-${var.environment}-frontend"
  cluster         = var.cluster_id
  task_definition = aws_ecs_task_definition.frontend.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = var.security_group_ids
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.frontend.arn
    container_name   = "frontend"
    container_port   = var.container_port
  }

  depends_on = [aws_lb_listener.frontend_https]

  tags = {
    Environment = var.environment
  }
}

data "aws_region" "current" {}

# modules/frontend/outputs.tf
output "alb_dns_name" {
  description = "DNS name of the frontend ALB"
  value       = aws_lb.frontend.dns_name
}

output "alb_zone_id" {
  description = "Zone ID of the frontend ALB"
  value       = aws_lb.frontend.zone_id
}

output "target_group_arn" {
  description = "ARN of the frontend target group"
  value       = aws_lb_target_group.frontend.arn
}

Backend Service:

# modules/backend/variables.tf
variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "public_subnet_ids" {
  description = "List of public subnet IDs for ALB"
  type        = list(string)
}

variable "private_subnet_ids" {
  description = "List of private subnet IDs for ECS tasks"
  type        = list(string)
}

variable "security_group_ids" {
  description = "Security group IDs for the ECS service"
  type        = list(string)
}

variable "certificate_arn" {
  description = "ARN of the ACM certificate"
  type        = string
}

variable "container_port" {
  description = "Port the backend container listens on"
  type        = number
  default     = 3000
}

variable "health_check_path" {
  description = "Health check path for backend service"
  type        = string
  default     = "/health"
}

variable "container_image" {
  description = "Container image for backend service"
  type        = string
}

variable "cluster_id" {
  description = "ID of the ECS cluster"
  type        = string
}

variable "execution_role_arn" {
  description = "ARN of the ECS task execution role"
  type        = string
}

variable "task_role_arn" {
  description = "ARN of the ECS task role"
  type        = string
}

# modules/backend/main.tf
# Load Balancer
resource "aws_lb" "backend" {
  name               = "traba-${var.environment}-backend-alb"
  internal           = true
  load_balancer_type = "application"
  security_groups    = var.security_group_ids
  subnets           = var.public_subnet_ids

  tags = {
    Name        = "traba-${var.environment}-backend-alb"
    Environment = var.environment
  }
}

resource "aws_lb_target_group" "backend" {
  name        = "traba-${var.environment}-backend-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = var.health_check_path
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }

  tags = {
    Environment = var.environment
  }
}

# HTTP to HTTPS Redirect
resource "aws_lb_listener" "backend_http" {
  load_balancer_arn = aws_lb.backend.arn
  port              = var.container_port
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# HTTPS Listener
resource "aws_lb_listener" "backend_https" {
  load_balancer_arn = aws_lb.backend.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.backend.arn
  }
}

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "backend" {
  name              = "/ecs/traba-${var.environment}-backend"
  retention_in_days = 30

  tags = {
    Environment = var.environment
  }
}

# ECS Task Definition
resource "aws_ecs_task_definition" "backend" {
  family                   = "traba-${var.environment}-backend"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = var.execution_role_arn
  task_role_arn           = var.task_role_arn

  container_definitions = jsonencode([
    {
      name  = "backend"
      image = var.container_image
      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-group"         = aws_cloudwatch_log_group.backend.name
          "awslogs-stream-prefix" = "ecs"
        }
      }
    }
  ])

  tags = {
    Environment = var.environment
  }
}

# ECS Service
resource "aws_ecs_service" "backend" {
  name            = "traba-${var.environment}-backend"
  cluster         = var.cluster_id
  task_definition = aws_ecs_task_definition.backend.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnet_ids
    security_groups  = var.security_group_ids
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.backend.arn
    container_name   = "backend"
    container_port   = var.container_port
  }

  depends_on = [aws_lb_listener.backend_https]

  tags = {
    Environment = var.environment
  }
}

data "aws_region" "current" {}

# modules/backend/outputs.tf
output "alb_dns_name" {
  description = "DNS name of the backend ALB"
  value       = aws_lb.backend.dns_name
}

output "alb_zone_id" {
  description = "Zone ID of the backend ALB"
  value       = aws_lb.backend.zone_id
}

output "target_group_arn" {
  description = "ARN of the backend target group"
  value       = aws_lb_target_group.backend.arn
}

Part 6: DNS and SSL

We use Route53 and ACM for DNS management and SSL:

# modules/certificates/main.tf

variable "environment" {
  description = "Environment name (e.g., staging, prod)"
  type        = string
}

variable "domain_name" {
  description = "Main domain name"
  type        = string
}

variable "frontend_domain" {
  description = "Frontend domain name"
  type        = string
}

variable "backend_domain" {
  description = "Backend domain name"
  type        = string
}

variable "frontend_lb_dns_name" {
  description = "Frontend load balancer DNS name"
  type        = string
}

variable "frontend_lb_zone_id" {
  description = "Frontend load balancer zone ID"
  type        = string
}

variable "backend_lb_dns_name" {
  description = "Backend load balancer DNS name"
  type        = string
}

variable "backend_lb_zone_id" {
  description = "Backend load balancer zone ID"
  type        = string
}

# ACM Certificate
resource "aws_acm_certificate" "main" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]
  validation_method         = "DNS"

  tags = {
    Environment = var.environment
  }

  lifecycle {
    create_before_destroy = true
  }
}

# Route53 Zone Data Source
data "aws_route53_zone" "main" {
  name         = var.domain_name
  private_zone = false
}

# Certificate Validation Records
resource "aws_route53_record" "acm_validation" {
  for_each = {
    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.main.zone_id
}

# Certificate Validation
resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.acm_validation : record.fqdn]
}

# Frontend DNS Record
resource "aws_route53_record" "frontend" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = var.frontend_domain
  type    = "A"

  alias {
    name                   = var.frontend_lb_dns_name
    zone_id                = var.frontend_lb_zone_id
    evaluate_target_health = true
  }
}

# Backend DNS Record
resource "aws_route53_record" "backend" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = var.backend_domain
  type    = "A"

  alias {
    name                   = var.backend_lb_dns_name
    zone_id                = var.backend_lb_zone_id
    evaluate_target_health = true
  }
}

# Outputs
output "certificate_arn" {
  description = "ARN of the ACM certificate"
  value       = aws_acm_certificate.main.arn
}

output "domain_validation_options" {
  description = "Domain validation options for the certificate"
  value       = aws_acm_certificate.main.domain_validation_options 
}

High Availability Features

Our architecture ensures high availability through:

  1. Multi-AZ Deployment:

    • Services deployed across multiple availability zones

    • Auto-scaling groups for ECS tasks

    • Multi-AZ database setup

  2. Load Balancing:

    • Application Load Balancers for frontend and backend

    • Health checks for automatic failover

    • SSL termination at load balancer

  3. Fault Tolerance:

    • Database backups and point-in-time recovery

    • ECS service auto-recovery

    • Redundant NAT gateways

Security Best Practices

  1. Network Security:

    • Private subnets for sensitive components

    • Security groups with minimal required access

    • VPC flow logs for network monitoring

  2. Data Security:

    • Encryption at rest for database and secrets

    • SSL/TLS for all external communication

    • Secrets rotation capability

  3. Access Control:

    • IAM roles with least privilege

    • No direct SSH access to containers

    • Bastion host for database access

Deployment Process

  1. Initialize Terraform in infra/bootstrap:
terraform init
  1. Create a workspace:
terraform workspace new staging

Note on Terraform workspace: Workspaces create separate state files for each environment (dev, staging, prod), preventing accidental modifications to production resources when working on development or staging environments

  1. Apply the infrastructure:
terraform apply
  1. Initialize terraform in infra
terraform init
  1. Create a workspace
terraform workspace new staging

Monitoring and Maintenance

Our setup includes:

  • CloudWatch logs for application monitoring (frontend, backend)

Remember to always review and adjust security groups, IAM policies, and other security measures based on your specific requirements.


The complete code for this implementation is available in the provided terraform configuration files. Make sure to customize variables and configurations according to your specific needs before deployment.