How to deploy web application at AWS Fargate using terraform – Part 1

H

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.


About the author

sergii-demianchuk

Software engineer with over 18 year’s experience. Everyday stack: PHP, Python, Java, Javascript, Symfony, Flask, Spring, Vue, Docker, AWS Cloud, Machine Learning, Ansible, Terraform, Jenkins, MariaDB, MySQL, Mongo, Redis, ElasticSeach

architecture AWS cluster cyber-security devops devops-basics docker elasticsearch flask geo high availability java machine learning opensearch php programming languages python recommendation systems search systems spring boot symfony