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
| Resource | Purpose |
|---|---|
aws_security_group (two) | The load balancer accepts HTTP from anywhere; tasks accept traffic only from the load balancer |
aws_lb, target group, listener | Public load balancer with health checks on /health |
aws_ecs_cluster | Cluster with Container Insights enabled |
aws_iam_role | Execution role so ECS can pull the image and write logs |
aws_cloudwatch_log_group | Container logs, kept for 30 days |
aws_ecs_task_definition | 0.5 vCPU and 1 GB of memory per task |
aws_ecs_service | Two 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/awsmodule 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_arnwith only what the app needs, for example reading one S3 bucket. - Pinned image tags. Deploy
web:1.0.0, notweb: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_targetandaws_appautoscaling_policy. - Store state remotely with locking, and require an approval for plans that replace the service or load balancer.
