How to create AWS ASG with HTTPS ALB using Terraform modules
Tags: aws, terraform, https, alb
Published: 2024-01-03 18:37:35

{%- # TOC start (generated with https://github.com/derlin/bitdowntoc) -%}
- [General settings](#general-settings)
- [VPC](#vpc)
- [AMI](#ami)
- [Additional parameters](#additional-parameters)
- [ALB with HTTPS](#alb-with-https)
- [ASG](#asg)
{%- # TOC end -%}

This is basically a slighty modified version of a [tutorial by Anton Putra](https://antonputra.com/amazon/create-alb-terraform/#secure-alb-with-tls-certificate), which I've changed to use modules (with pinned versions for future compatibility) instead of plain resources, so it is easier to read. I've also added the code for creating and assigning appropriate IAM Role and Instance Profile to ASG instances, so you can manage them with SSM.

## General settings
First of all, you'll have to set the region, which you would like the resources to be deployed in:
```
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.31.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}
```

## VPC
Then, you'll create the VPC and all of its resources (security groups are dependant on eachother, so they are created as resources first and then rules are created as modules)
```
locals {
  ...
  vpc_cidr      = "10.0.0.0/16"
  azs           = slice(data.aws_availability_zones.available.names, 0, 2)
  tags = {
    ManagedBy = "Terraform"
  }
 ...
}

data "aws_availability_zones" "available" {}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.4.0"

  name               = "alb-vpc"
  cidr               = local.vpc_cidr
  azs                = local.azs
  private_subnets    = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k)]
  public_subnets     = [for k, v in local.azs : cidrsubnet(local.vpc_cidr, 8, k + 4)]
  enable_nat_gateway = true
  single_nat_gateway = true
  tags               = local.tags
}

resource "aws_security_group" "ec2" {
  name   = "ec2-sg"
  vpc_id = module.vpc.vpc_id
  tags   = local.tags
}

resource "aws_security_group" "alb" {
  name   = "alb-sg"
  vpc_id = module.vpc.vpc_id
  tags   = local.tags
}

module "alb-sg-rules" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "5.1.0"

  create_sg           = false
  security_group_id   = aws_security_group.alb.id
  ingress_cidr_blocks = ["0.0.0.0/0"]
  ingress_rules       = ["http-80-tcp", "https-443-tcp"]
  egress_with_source_security_group_id = [
    {
      from_port                = 8080
      to_port                  = 8080
      protocol                 = "tcp"
      description              = "App port"
      source_security_group_id = aws_security_group.ec2.id
    },
    {
      from_port                = 8081
      to_port                  = 8081
      protocol                 = "tcp"
      description              = "Full healthcheck"
      source_security_group_id = aws_security_group.ec2.id
    }
  ]
}

module "ec2-sg-rules" {
  source  = "terraform-aws-modules/security-group/aws"
  version = "5.1.0"

  create_sg         = false
  security_group_id = aws_security_group.ec2.id
  ingress_with_source_security_group_id = [
    {
      from_port                = 8080
      to_port                  = 8080
      protocol                 = "tcp"
      description              = "App port"
      source_security_group_id = aws_security_group.alb.id
    },
    {
      from_port                = 8081
      to_port                  = 8081
      protocol                 = "tcp"
      description              = "Full healthcheck"
      source_security_group_id = aws_security_group.alb.id
    }
  ]
  egress_cidr_blocks = ["0.0.0.0/0"]     // these are needed for instance to be able to initiate a connection to SSM
  egress_rules       = ["https-443-tcp"]
}
```

## AMI
After this, you'll have to create launch template for your ASG. I'll use Packer to create the AMI, as provided in Anton's post. You'll have to create the following files:
`files/my-app.service`
```
[Unit]
Description=My App
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
ExecStart=/home/ubuntu/go/bin/my-app

User=ubuntu

Environment=GIN_MODE=release

Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target
```

`scripts/bootstrap.sh`
```
#!/bin/bash

set -e

sudo add-apt-repository ppa:longsleep/golang-backports
sudo apt-get update
sudo apt-get install -y golang-go
go install github.com/antonputra/tutorials/lessons/127/my-app@main
```

`my-app.pkr.hcl`
```
packer {
  required_plugins {
    amazon = {
      version = "v1.2.9"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

source "amazon-ebs" "my-app" {
  ami_name      = "my-app-{{ timestamp }}"
  instance_type = "t3a.small" // this isn't the instance type of ASG, it's the type of temp instance Packer will use to build an AMI off of
  region        = "eu-west-1" // change this to the region of your resources
  subnet_id     = "subnet-074a32b171778af28" // change this to any public subnet in the specified region, for example, you can use the one from default VPC

  source_ami_filter {
    filters = {
      name                = "ubuntu/images/*ubuntu-jammy-22.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["099720109477"]
  }

  ssh_username = "ubuntu"

  tags = {
    Name = "My-App",
    ManagedBy = "Packer"
  }
}

build {
  sources = ["source.amazon-ebs.my-app"]

  provisioner "file" {
    destination = "/tmp"
    source      = "files"
  }

  provisioner "shell" {
    script = "scripts/bootstrap.sh"
  }

  provisioner "shell" {
    inline = [
      "sudo mv /tmp/files/my-app.service /etc/systemd/system/my-app.service",
      "sudo systemctl start my-app",
      "sudo systemctl enable my-app"
    ]
  }
}
```
Then, run these commands to initialize and build your AMI using Packer:

```
packer init my-app.pkr.hcl
packer build my-app.pkr.hcl
```

## Additional parameters
After this, you'll have to create/import keypair and decide on the instance type, you'd like to use for your ASG. You'll also have to create Route53 public hosted zone and update nameservers for your domain with the ones provided in NS record, which will be automatically created in the Route53 zone. Then, you'll add these values to locals:
```
locals {
  ...
  domain_name   = "https-alb.pp.ua"
  keypair_name  = "devops"
  instance_type = "t3a.micro"
  ami_id        = "ami-06f69317847054bb5"
  ...
}
```

## ALB with HTTPS
You can now also create ALB with support for HTTPS and a target group, which will be attached to ASG:
```
module "acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "5.0.0"

  domain_name       = local.domain_name
  zone_id           = data.aws_route53_zone.public.zone_id
  validation_method = "DNS"
  tags              = local.tags
}

module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = "9.4.0"

  name                       = "alb"
  vpc_id                     = module.vpc.vpc_id
  subnets                    = module.vpc.public_subnets
  security_groups            = [aws_security_group.alb.id]
  enable_deletion_protection = false
  target_groups = {
    asg = {
      name              = "asg-tg"
      port              = 8080
      protocol          = "HTTP"
      vpc_id            = module.vpc.vpc_id
      create_attachment = false

      health_check = {
        enabled             = true
        port                = 8081
        interval            = 30
        protocol            = "HTTP"
        path                = "/health"
        matcher             = "200"
        healthy_threshold   = 3
        unhealthy_threshold = 3
      }
    }
  }
  listeners = {
    http-https-redirect = {
      port     = 80
      protocol = "HTTP"
      redirect = {
        port        = "443"
        protocol    = "HTTPS"
        status_code = "HTTP_301"
      }
    },
    https = {
      port            = 443
      protocol        = "HTTPS"
      ssl_policy      = "ELBSecurityPolicy-2016-08" // set to allow most clients, should be change to newer one
      certificate_arn = module.acm.acm_certificate_arn
      forward = {
        target_group_key = "asg"
      }
    }
  }
  tags = local.tags
}

resource "aws_route53_record" "alb" {
  name    = local.domain_name
  type    = "A"
  zone_id = data.aws_route53_zone.public.zone_id
  alias {
    name                   = module.alb.dns_name
    zone_id                = module.alb.zone_id
    evaluate_target_health = false
  }
}
```

## ASG
And, finally, you'll create the ASG itself:

```
resource "aws_iam_service_linked_role" "autoscaling" {
  aws_service_name = "autoscaling.amazonaws.com"
  description      = "A service linked role for autoscaling"
  custom_suffix    = "ssm"

  provisioner "local-exec" {
    command = "sleep 10"
  }
  tags = local.tags
}

module "asg" {
  source  = "terraform-aws-modules/autoscaling/aws"
  version = "7.3.1"

  name                             = "asg"
  use_name_prefix                  = false
  vpc_zone_identifier              = module.vpc.private_subnets
  min_size                         = 1 // automatically set as desired
  max_size                         = 3
  launch_template_name             = "my-app"
  launch_template_use_name_prefix  = false
  update_default_version           = true
  image_id                         = local.ami_id
  instance_type                    = local.instance_type
  key_name                         = local.keypair_name
  security_groups                  = [aws_security_group.ec2.id]
  create_traffic_source_attachment = true
  traffic_source_identifier        = module.alb.target_groups["asg"].arn
  service_linked_role_arn          = aws_iam_service_linked_role.autoscaling.arn
  service_linked_role_arn          = aws_iam_service_linked_role.autoscaling.arn
  create_iam_instance_profile      = true
  iam_instance_profile_name.       = "ssm-instance-profile"
  iam_role_name                    = "ssm-role"
  iam_role_path                    = "/ec2/"
  iam_role_description             = "SSM role example"
  iam_role_tags                    = local.tags
  iam_role_policies = {
    AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  }
  block_device_mappings = [
    {
      # Root volume
      device_name = "/dev/xvda"
      no_device   = 0
      ebs = {
        delete_on_termination = true
        encrypted             = true
        volume_size           = 1
        volume_type           = "gp3"
      }
    }
  ]
  scaling_policies = {
    avg-cpu-policy-greater-than-80 = {
      policy_type               = "TargetTrackingScaling"
      estimated_instance_warmup = 300
      target_tracking_configuration = {
        predefined_metric_specification = {
          predefined_metric_type = "ASGAverageCPUUtilization"
        }
        target_value = 80.0
      }
    }
  }
  tags = local.tags
}
```
You can optionally add output, to see complete healthcheck URL after all of the resources are created:

```
output "custom_domain" {
  value = "https://${module.acm.distinct_domain_names[0]}/ping" // will output only first domain supplied
} 
```