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
ininfra/bootstrap
andinfra
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:
Multi-AZ Deployment:
Services deployed across multiple availability zones
Auto-scaling groups for ECS tasks
Multi-AZ database setup
Load Balancing:
Application Load Balancers for frontend and backend
Health checks for automatic failover
SSL termination at load balancer
Fault Tolerance:
Database backups and point-in-time recovery
ECS service auto-recovery
Redundant NAT gateways
Security Best Practices
Network Security:
Private subnets for sensitive components
Security groups with minimal required access
VPC flow logs for network monitoring
Data Security:
Encryption at rest for database and secrets
SSL/TLS for all external communication
Secrets rotation capability
Access Control:
IAM roles with least privilege
No direct SSH access to containers
Bastion host for database access
Deployment Process
- Initialize Terraform in
infra/bootstrap
:
terraform init
- 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
- Apply the infrastructure:
terraform apply
- Initialize terraform in
infra
terraform init
- 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.