Hi, and welcome to detailed tutorial at how to deploy web application at AWS Fargate using terraform. Current tutorial assume that you already know and have several basic things/conceptions:
- You have active AWS account
- You have basic knowledge about next AWS components: VPC, network, route table, security group, Route 53, Target Group (TG), Application Load Balancer (ALB)
- You have installed terraform, aws-cli and configured terrafrom state and locks (similar as described here)
- You have built docker image with your web app and pushed it to AWS ECR (similar as described here)
- You know what is AWS Fargate and how it works (here is the article from my blog about it)
- You have bought domain and delegated it to AWS Route 53, and you also generated certificate to it, using AWS certificate manager (the video how to do it is available for free at my Udemy course, look at Section 3, Lecture 8: Applying terraform – Part 3: ALB terrafrom module and AWS Certificate Manager)
Yep, a lot of stuff is required, and it is definitely not the tutorial for beginners. But if you already ready – let’s go at further trip. We will start from general architecture view, to understand what we are going to create. Here is how it looks like:
Here is short description how all would be working, starting from request at browser:
- Request hits the internet. Via DNS network it is forwarded to AWS Route 53 service
- From R53, request is forwarded to ALB.
- Load balancer is using host header information to forward traffic further at our application, which is deployed at AWS Fargate container serverless service.
- To connect AWS Fargate application with ALB – one more block is required – it is TG that performs the role of bridge between 2 different worlds.
At current, 1st tutorial parts, we will concentrate at building backbone infrastructure – network and ALB. Then we will concentrate at AWS Fargate by itself. So, network. Our scheme has next view:
Here is module files view:
Now terraform realization, step by step, with some comments:
# vpc.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_ip_block
enable_dns_hostnames = true
enable_dns_support = true
instance_tenancy = "default"
assign_generated_ipv6_cidr_block = true
tags = merge(local.common_tags, {
Name = local.name_prefix
})
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = local.name_prefix
})
}
# subnets.tf
esource "aws_subnet" "public" {
count = var.az_num
vpc_id = aws_vpc.main.id
availability_zone = element(data.aws_availability_zones.az.names, count.index)
cidr_block = cidrsubnet(var.subnet_cidr_public, var.new_bits_public, count.index)
ipv6_cidr_block = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index)
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = format("%s-public-%s",
local.name_prefix,
substr(strrev(element(data.aws_availability_zones.az.names, count.index)), 0, 1)
)
})
}
resource "aws_subnet" "private" {
count = var.az_num
vpc_id = aws_vpc.main.id
availability_zone = element(data.aws_availability_zones.az.names, count.index)
cidr_block = cidrsubnet(var.subnet_cidr_private, var.new_bits_private, count.index)
map_public_ip_on_launch = false
tags = merge(local.common_tags, {
Name = format("%s-private-%s",
local.name_prefix,
substr(strrev(element(data.aws_availability_zones.az.names, count.index)), 0, 1)
)
})
}
# routes.tf
# PRIVATE
resource "aws_route_table" "private" {
count = length(aws_subnet.private)
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = format("%s-private-%s",
local.name_prefix,
substr(strrev(element(data.aws_availability_zones.az.names, count.index)), 0, 1)
)
})
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = element(aws_subnet.private[*].id, count.index)
route_table_id = element(aws_route_table.private[*].id, count.index)
}
resource "aws_route" "private_natgw" {
count = local.natgw_count == 0 ? 0 : length(aws_route_table.private)
route_table_id = element(aws_route_table.private[*].id, count.index)
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = local.natgw_count == 0 ? 0 : (
local.natgw_count == 1 ? aws_nat_gateway.ngw[0].id : element(aws_nat_gateway.ngw[*].id, count.index)
)
}
# PUBLIC
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-public"
})
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = element(aws_subnet.public[*].id, count.index)
route_table_id = aws_route_table.public.id
}
resource "aws_route" "route_public" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
That was backbone network components, now security groups:
# sg-default.tf
### DEFAULT - keep empty!
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
}
# sg-alb.tf
resource "aws_security_group" "alb" {
description = "Alb Dev"
name = "Alb Dev"
vpc_id = aws_vpc.main.id
lifecycle {
create_before_destroy = true
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-alb"
})
}
### EGRESS
resource "aws_security_group_rule" "alb_egress" {
description = "Alb Dev Egress"
type = "egress"
from_port = 0
to_port = 65535
protocol = "all"
cidr_blocks = ["0.0.0.0/0"] # tfsec:ignore:AWS007
security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "alb_egress_v6" {
description = "Alb Dev Egress"
type = "egress"
from_port = 0
to_port = 65535
protocol = "all"
ipv6_cidr_blocks = ["::/0"] # tfsec:ignore:AWS007
security_group_id = aws_security_group.alb.id
}
### ICMP
resource "aws_security_group_rule" "alb_icmp" {
description = "Alb Dev ICMP"
type = "ingress"
from_port = -1
to_port = -1
protocol = "icmp"
cidr_blocks = ["0.0.0.0/0"] # tfsec:ignore:AWS006
security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "alb_icmp_v6" {
description = "Alb Dev ICMP"
type = "ingress"
from_port = -1
to_port = -1
protocol = "icmpv6"
ipv6_cidr_blocks = ["::/0"] # tfsec:ignore:AWS006
security_group_id = aws_security_group.alb.id
}
### FROM PUBLIC IPS
resource "aws_security_group_rule" "alb_80" {
count = length(var.public_ips)
description = values(var.public_ips)[count.index]
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [keys(var.public_ips)[count.index]]
security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "alb_443" {
count = length(var.public_ips)
description = values(var.public_ips)[count.index]
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [keys(var.public_ips)[count.index]]
security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "alb_80_v6" {
count = length(var.public_ips_v6)
description = values(var.public_ips)[count.index]
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
ipv6_cidr_blocks = [keys(var.public_ips_v6)[count.index]]
security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "alb_443_v6" {
count = length(var.public_ips_v6)
description = values(var.public_ips)[count.index]
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
ipv6_cidr_blocks = [keys(var.public_ips_v6)[count.index]]
security_group_id = aws_security_group.alb.id
}
# sg-ecs-fargate-task.tf
resource "aws_security_group" "ecs_fargate_task" {
description = "ECS Fargate task security group"
name = "ecs-fargate-task-security-group"
vpc_id = aws_vpc.main.id
lifecycle {
create_before_destroy = true
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-alb"
})
}
### EGRESS
resource "aws_security_group_rule" "ecs_fargate_task_egress" {
description = "ECS Fargate task Egress"
type = "egress"
from_port = 0
to_port = 65535
protocol = "all"
cidr_blocks = ["0.0.0.0/0"] # tfsec:ignore:AWS007
security_group_id = aws_security_group.ecs_fargate_task.id
}
resource "aws_security_group_rule" "ecs_fargate_task_egress_v6" {
description = "ECS Fargate task Egress"
type = "egress"
from_port = 0
to_port = 65535
protocol = "all"
ipv6_cidr_blocks = ["::/0"] # tfsec:ignore:AWS007
security_group_id = aws_security_group.ecs_fargate_task.id
}
### INGRESS FROM ALB
resource "aws_security_group_rule" "ecs_fargate_task_alb_microservices" {
description = "From ALB Microservices"
type = "ingress"
from_port = 0
to_port = 65535
protocol = "tcp"
security_group_id = aws_security_group.ecs_fargate_task.id
source_security_group_id = aws_security_group.alb.id
}
Now some additional terraform stuff:
# data.tf
data "aws_availability_zones" "az" {
state = "available"
}
# variables-env.tf
variable "account_id" {
type = string
description = "AWS Account ID"
}
variable "env" {
type = string
description = "Environment name"
}
variable "project" {
type = string
description = "Project name"
}
variable "region" {
type = string
description = "AWS Region"
}
# variables.tf
variable "vpc_ip_block" {
type = string
}
variable "subnet_cidr_public" {
type = string
}
variable "subnet_cidr_private" {
type = string
}
variable "new_bits_private" {
type = number
}
variable "new_bits_public" {
type = number
}
variable "natgw_count" {
type = string
description = "all | none | one"
}
variable "az_num" {
type = number
description = "Number of used AZ"
}
variable "public_ips" {
type = map(string)
}
variable "public_ips_v6" {
type = map(string)
}
variable "app_ports" {
type = list(number)
description = "Ports on app servers to open for ALB"
}
# outputs.tf
output "vpc" {
value = aws_vpc.main
}
output "subnets_private" {
value = aws_subnet.private
}
output "subnets_public" {
value = aws_subnet.public
}
output "sg_alb" {
value = aws_security_group.alb
}
output "sg_ecs_fargate_task" {
value = aws_security_group.ecs_fargate_task
}
# main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.21"
}
}
required_version = "~> 1.6"
}
That is all you will need to deploy web application at AWS Fargate at public network, which I recommend to start with, though if you want to do it at private network (preferable solution at prod for security reasons), you will also probably need SSM VPC endpoint, security group, and quite possible that NAT also would be required in addition. Below you may find according terraform files with realization for above mentioned things:
# natgw.tf
resource "aws_eip" "ngw" {
count = local.natgw_count
depends_on = [aws_internet_gateway.igw]
tags = merge(local.common_tags, {
Name = format("%s-natgw-%d", local.name_prefix, count.index + 1)
})
}
resource "aws_nat_gateway" "ngw" {
count = local.natgw_count
allocation_id = element(aws_eip.ngw[*].id, count.index)
subnet_id = element(aws_subnet.public[*].id, count.index)
depends_on = [aws_internet_gateway.igw]
tags = merge(local.common_tags, {
Name = format("%s-%d", local.name_prefix, count.index + 1)
})
}
# endpoints.tf
### SSM
resource "aws_vpc_endpoint" "ssm" {
service_name = "com.amazonaws.${var.region}.ssm"
vpc_endpoint_type = "Interface"
vpc_id = aws_vpc.main.id
security_group_ids = [aws_security_group.ssm-vpc.id]
private_dns_enabled = true
tags = merge(local.common_tags, {
Name = local.name_prefix
})
}
resource "aws_vpc_endpoint_subnet_association" "ssm_public" {
count = length(aws_subnet.public)
vpc_endpoint_id = aws_vpc_endpoint.ssm.id
subnet_id = element(aws_subnet.public[*].id, count.index)
}
# sg-ssm.tf
resource "aws_security_group" "ssm-vpc" {
vpc_id = aws_vpc.main.id
name = "${local.name_prefix}-ssm-vpc"
description = "Allows HTTPS access to SSM endpoint in VPC"
lifecycle {
create_before_destroy = true
}
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-ssm-vpc"
})
}
### EGRESS
resource "aws_security_group_rule" "ssm_egress" {
security_group_id = aws_security_group.ssm-vpc.id
type = "egress"
from_port = 0
to_port = 65535
protocol = "all"
cidr_blocks = ["0.0.0.0/0"]
}
### INGRESS
resource "aws_security_group_rule" "ssm_ingress" {
security_group_id = aws_security_group.ssm-vpc.id
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
source_security_group_id = aws_security_group.ecs_fargate_task
}
Finally – here is network implementation module:
# module implementation example
terraform {
backend "s3" {
bucket = "terraform-state-fargate"
dynamodb_table = "terraform-state-fargate"
encrypt = true
key = "dev-network.tfstate"
region = "eu-central-1"
}
}
provider "aws" {
allowed_account_ids = [var.account_id]
region = var.region
}
module "network" {
source = "../../modules/network"
account_id = var.account_id
env = var.env
project = var.project
region = var.region
az_num = 3
vpc_ip_block = "172.27.72.0/22"
subnet_cidr_private = "172.27.72.0/24"
subnet_cidr_public = "172.27.73.0/24"
new_bits_private = 2
new_bits_public = 2
natgw_count = "none"
public_ips = {
"0.0.0.0/0" = "Open",
}
public_ips_v6 = {
"::/0" = "Open",
}
app_ports = [
80,
443,
]
}
OK, suppose it is enough for the introduction, See you at next part, were ALB terraform module would be represented. If you do not want to miss next part, subscribe to the newsletter. If you want to pass all material at once in fast and convenient way, with detailed explanations, then welcome to my course: “AWS Fargate DevOps: Autoscaling with Terraform at practice”, here you may find coupon with discount.