AWS · Terraform example

Terraform Example: Container Web App on ECS Fargate with a Load Balancer

A complete Terraform example for running a containerized web app on AWS ECS Fargate behind an Application Load Balancer, in an existing VPC, with automatic rollback.

Updated Nov 2026

This example runs a containerized web application on AWS without managing servers. ECS Fargate runs two copies of your container in private subnets, and an Application Load Balancer sends traffic to them. It uses a VPC you already have.

What it creates

ResourcePurpose
aws_security_group (two)The load balancer accepts HTTP from anywhere; tasks accept traffic only from the load balancer
aws_lb, target group, listenerPublic load balancer with health checks on /health
aws_ecs_clusterCluster with Container Insights enabled
aws_iam_roleExecution role so ECS can pull the image and write logs
aws_cloudwatch_log_groupContainer logs, kept for 30 days
aws_ecs_task_definition0.5 vCPU and 1 GB of memory per task
aws_ecs_serviceTwo tasks, with a deployment circuit breaker that rolls back failed deploys

The Terraform

Save as main.tf. It works unchanged with OpenTofu.

terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

variable "region" {
  type    = string
  default = "us-east-1"
}

variable "name" {
  type    = string
  default = "web"
}

variable "vpc_id" {
  type        = string
  description = "Existing VPC."
}

variable "public_subnet_ids" {
  type        = list(string)
  description = "At least two public subnets for the load balancer."
}

variable "private_subnet_ids" {
  type        = list(string)
  description = "Private subnets with a NAT route, for the tasks."
}

variable "image" {
  type        = string
  description = "Container image, for example an ECR URI with a tag."
}

variable "container_port" {
  type    = number
  default = 8080
}

# Load balancer accepts HTTP from anywhere.
resource "aws_security_group" "alb" {
  name   = "${var.name}-alb"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    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"]
  }
}

# Tasks accept traffic only from the load balancer.
resource "aws_security_group" "tasks" {
  name   = "${var.name}-tasks"
  vpc_id = var.vpc_id

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

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

resource "aws_lb" "web" {
  name               = var.name
  load_balancer_type = "application"
  subnets            = var.public_subnet_ids
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_lb_target_group" "web" {
  name        = var.name
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path    = "/health"
    matcher = "200"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

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

resource "aws_ecs_cluster" "main" {
  name = var.name

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

resource "aws_cloudwatch_log_group" "web" {
  name              = "/ecs/${var.name}"
  retention_in_days = 30
}

data "aws_iam_policy_document" "ecs_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

# Lets ECS pull the image and write logs.
resource "aws_iam_role" "execution" {
  name               = "${var.name}-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

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

resource "aws_ecs_task_definition" "web" {
  family                   = var.name
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 512
  memory                   = 1024
  execution_role_arn       = aws_iam_role.execution.arn

  container_definitions = jsonencode([{
    name         = var.name
    image        = var.image
    essential    = true
    portMappings = [{ containerPort = var.container_port }]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.web.name
        awslogs-region        = var.region
        awslogs-stream-prefix = "web"
      }
    }
  }])
}

resource "aws_ecs_service" "web" {
  name            = var.name
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.web.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.tasks.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.web.arn
    container_name   = var.name
    container_port   = var.container_port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  depends_on = [aws_lb_listener.http]
}

output "url" {
  value = "http://${aws_lb.web.dns_name}"
}

Deploy it

Create a terraform.tfvars file with your network and image:

vpc_id             = "vpc-0123456789abcdef0"
public_subnet_ids  = ["subnet-aaa", "subnet-bbb"]
private_subnet_ids = ["subnet-ccc", "subnet-ddd"]
image              = "123456789012.dkr.ecr.us-east-1.amazonaws.com/web:1.0.0"

Then:

terraform init
terraform plan -out=tfplan
terraform apply tfplan
curl "$(terraform output -raw url)/health"

Your container must listen on port 8080 (or set container_port) and return 200 on /health.

Choices this example makes

  • Existing VPC. Most teams already have one. If you do not, the community terraform-aws-modules/vpc/aws module creates public and private subnets with NAT.
  • Two tasks. Enough to survive one failing task or a single Availability Zone outage, if your private subnets span two zones.
  • Circuit breaker with rollback. A deploy whose tasks keep failing health checks rolls back to the last working version on its own.
  • No task role. The application has no AWS permissions. Add a task_role_arn with only what the app needs, for example reading one S3 bucket.
  • Pinned image tags. Deploy web:1.0.0, not web:latest, so every plan shows exactly which version is changing.

Cost

The main costs are Fargate compute for two tasks running all month, the load balancer's hourly charge and usage, NAT gateway hours and data processing, and logs. For a small app this is typically tens of US dollars a month, and NAT is often a large part of it. Check current pricing for your region.

Before production

  • Add HTTPS (see the FAQ) and, if needed, AWS WAF on the load balancer.
  • Add a database such as RDS PostgreSQL in the private subnets, with its password in Secrets Manager passed to the container through secrets.
  • Add auto scaling on CPU or request count with aws_appautoscaling_target and aws_appautoscaling_policy.
  • Store state remotely with locking, and require an approval for plans that replace the service or load balancer.

FAQ

Why do the tasks run in private subnets?

So the containers have no public IP addresses and can only be reached through the load balancer. They reach the internet, for example to pull images, through a NAT gateway. Without NAT, add VPC endpoints for ECR, S3, and CloudWatch Logs.

How do I deploy a new version of the container?

Push the new image with a new tag and run terraform apply with the new image variable. ECS starts new tasks, waits for them to pass health checks, then stops the old ones. The circuit breaker rolls back if the new tasks keep failing.

How do I add HTTPS?

Request an ACM certificate for your domain, add a listener on port 443 with that certificate, change the port 80 listener to redirect to HTTPS, and allow port 443 in the load balancer security group.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.