Terraform ALB and ACM: Adding HTTPS Endpoints to Multi-Tenant AWS Infrastructure (Part 4)
Terraform ALB and ACM
ByOlaniyi Oladimeji
Terraform ALB and ACM: Adding HTTPS Endpoints to Multi-Tenant AWS Infrastructure (Part 4)

Part 3 closed out the deployment automation loop: Terraform provisions infrastructure and maintains Ansible’s inventory automatically, Ansible configures and deploys the application, and scaling from one state to three took a single-line variable change. Functionally, the project works end to end.

There’s just one detail that’s been quietly wrong since Part 1: every state is reachable only by its raw EC2 public DNS name, over plain HTTP. That’s fine for a lab. It’s not suitable for production, and it’s worth fixing properly before this project goes further.

This post adds three pieces to the KloudGov infrastructure: an Application Load Balancer (ALB) for a single, stable entry point across all tenants, an ACM certificate for TLS termination, and Route 53 DNS so each state gets a real, memorable, secure address michigan.kloudgov.online instead of a public DNS string that changes every time an instance is recreated.

Why This Matters Beyond “It Looks More Professional”

Two separate problems are bundled into “raw IP over HTTP,” and it’s worth naming both clearly:

No stable address. Every time Terraform recreates an instance, which happens constantly in a project like this, its public DNS name changes. Any bookmark, integration, or documentation pointing at the old address silently breaks.
No encryption. Every request, and every response, travels between the browser and the EC2 instance completely unencrypted. For an application handling employee records, that’s not a detail to defer.

Architecture: One Shared ALB, Host-Based Routing

Given the project’s existing for_each-based module one EC2 instance, one S3 bucket, one DynamoDB table per state the architecture that scales cleanly with that pattern is:

One ALB, shared across every tenant, rather than provisioning a load balancer per state. ALBs aren’t free to run, and there’s no reason to pay for fifty of them when host-based routing on one does the job.
Host-based routing: the ALB inspects the Host header on each request and forwards to the correct state’s target group. michigan.kloudgov.online and nevada.kloudgov.online hit the same ALB, but land on different EC2 instances.
A single wildcard ACM certificate (*.kloudgov.online) terminates TLS at the ALB. Traffic is encrypted from the browser all the way to the ALB; from the ALB to each instance, it stays plain HTTP over the private network, which is standard practice, since that hop never leaves AWS’s internal network.
Route 53 alias records, one per state, all pointing at the same ALB.

This adds one load balancer and one certificate to the whole project, regardless of whether you’re running 3 states or 50.

Prerequisite: A Domain with a Route 53 Hosted Zone

This is the one piece that can’t be automated away; you need a domain you actually own, with its DNS delegated to a Route 53 hosted zone. For this project, that’s kloudgov.online, registered through Hostinger (Get 20% off) and dedicated entirely to this project.

Since the domain is registered elsewhere but not shared with any other live site, the cleanest setup is a full nameserver delegation: create the hosted zone in Route 53, then point the domain’s nameservers at AWS directly in Hostinger’s dashboard, rather than juggling individual DNS records across two providers. Concretely:

# terraform/main.tf
resource "aws_route53_zone" "kloudgov" {
  name = "kloudgov.online"
}

output "kloudgov_nameservers" {
  value = aws_route53_zone.kloudgov.name_servers
}

Apply just this piece first, grab the four AWS nameservers from the output, and set them as custom nameservers for kloudgov.online under Hostinger’s domain settings. Confirm delegation has actually propagated before moving on. dig NS kloudgov.online should echo back the same four AWS nameservers since ACM’s DNS validation in Step 1 will fail silently until this resolves correctly.

If your domain is registered through a provider you don’t want to fully hand over to Route 53, for instance, a subdomain of a site you’re already running elsewhere, a hosted zone scoped to just that subdomain, with only an NS record delegated at the registrar, keeps the rest of the domain completely untouched. Either way, the rest of this post assumes the hosted zone already exists as a resource, not a data lookup replace kloudgov.online throughout with whatever domain you’re actually using.

Step 1: Requesting the ACM Certificate

At the root level of the Terraform configuration (terraform/main.tf), not inside the per-state module, this certificate is shared infrastructure, not something that belongs to any individual tenant.

# terraform/variables.tf
variable "domain_name" {
  description = "Root domain for KloudGov state endpoints"
  default     = "kloudgov.online"
}
# terraform/main.tf
resource "aws_acm_certificate" "kloudgov_cert" {
  domain_name       = "*.${var.domain_name}"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.kloudgov_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = aws_route53_zone.kloudgov.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "kloudgov_cert_validate" {
  certificate_arn         = aws_acm_certificate.kloudgov_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

A few things worth understanding here, not just applying:

Wildcard domain (*.kloudgov.online) covers every state subdomain michigan.kloudgov.online, texas.kloudgov.online, and any future state with a single certificate. No need to request or renew a separate cert per tenant.
validation_method = “DNS” is the right choice here specifically because the domain’s hosted zone is already in Route 53; Terraform can create the validation record automatically, rather than requiring a manual email click-through.
The for_each on domain_validation_options dynamically creates whatever validation records ACM requires; you don’t hardcode them, since ACM generates the specific record name and value at request time.
aws_acm_certificate_validation is a blocking resource Terraform genuinely waits for here until AWS confirms the certificate is validated, which typically takes a few minutes on first apply. This is expected; don’t interrupt it.

Step 2: Updating the Module to Expose the Instance ID

The ALB needs to attach EC2 instances to target groups, which requires each instance’s ID as an output the module doesn’t currently expose. Add it:

# modules/aws_kloudgov_infrastructure/outputs.tf

output "state_ec2_id" {
  value = aws_instance.state_ec2.id
}

Step 3: Creating the ALB’s Security Group

Also at the root level, this security group governs the ALB itself, which now becomes the sole public entry point into the whole system:

# terraform/main.tf
data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

resource "aws_security_group" "alb_sg" {
  name        = "kloudgov-alb-sg"
  description = "Allow HTTP and HTTPS to the ALB"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  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"]
  }

  tags = {
    Name = "kloudgov-alb-sg"
  }
}

Port 80 stays open here specifically to support an HTTP-to-HTTPS redirect in Step 5; the ALB needs to accept the initial plaintext request before it can redirect the browser to the secure version.

Step 4: Creating the Shared Application Load Balancer

# terraform/main.tf
resource "aws_lb" "kloudgov_alb" {
  name               = "kloudgov-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = data.aws_subnets.default.ids

  tags = {
    Name = "kloudgov-alb"
  }
}

Step 5: Configuring HTTPS and HTTP-Redirect Listeners

Two listeners: one handling real traffic on 443, and one on 80 whose only job is to redirect to 443.

# terraform/main.tf
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.kloudgov_alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.kloudgov_cert_validate.certificate_arn

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "No matching state route"
      status_code  = "404"
    }
  }
}

resource "aws_lb_listener" "http_redirect" {
  load_balancer_arn = aws_lb.kloudgov_alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

The https listener’s default action matters if a request arrives with a Host header that doesn’t match any state’s routing rule (added in Step 7); it returns a clean 404 instead of silently falling through to some arbitrary state’s instance. The http_redirect listener ensures anyone typing http://michigan.kloudgov.online gets bounced to the encrypted version automatically, rather than the connection just failing.

Step 6: Target Groups Per State

Each state gets its own target group, and its instance attached to it:

# terraform/main.tf
resource "aws_lb_target_group" "state_tg" {
  for_each = toset(var.states)
  name     = "kloudgov-${each.value}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id

  health_check {
    path                = "/"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
  }

  tags = {
    Name = "kloudgov-${each.value}"
  }
}

resource "aws_lb_target_group_attachment" "state_tg_attach" {
  for_each         = toset(var.states)
  target_group_arn = aws_lb_target_group.state_tg[each.value].arn
  target_id        = module.aws_kloudgov_infrastructure[each.value].state_ec2_id
  port             = 80
}

Note the target group port is 80, not 5000; it’s worth being explicit about this, since it’s easy to assume otherwise given the security group rule opened for port 5000 back in Part 1. Looking at the actual Ansible role from Part 3, Gunicorn only binds to a Unix socket; Nginx is what actually listens on port 80 and proxies to it. The ALB needs to forward to wherever the application is genuinely reachable, which is port 80 via Nginx.

Step 7: Host-Based Listener Rules

This is the actual routing logic inspecting each request’s Host header and forwarding to the matching state’s target group:

# terraform/main.tf
locals {
  state_priority = zipmap(var.states, range(100, 100 + length(var.states)))
}

resource "aws_lb_listener_rule" "state_routing" {
  for_each     = toset(var.states)
  listener_arn = aws_lb_listener.https.arn
  priority     = local.state_priority[each.value]

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.state_tg[each.value].arn
  }

  condition {
    host_header {
      values = ["${each.value}.${var.domain_name}"]
    }
  }
}

Listener rules require a unique numeric priority each, and for_each alone doesn’t hand you a clean incrementing integer the local.state_priority map solves that by pairing each state name with a sequential priority number, starting at 100. Add a new state to var.states, and this map (along with its listener rule) generates automatically; no manual priority bookkeeping required.

Step 8: Route 53 Alias Records

One DNS record per state, all pointing at the same ALB:

# terraform/main.tf
resource "aws_route53_record" "state_dns" {
  for_each = toset(var.states)
  zone_id  = aws_route53_zone.kloudgov.zone_id
  name     = "${each.value}.${var.domain_name}"
  type     = "A"

  alias {
    name                   = aws_lb.kloudgov_alb.dns_name
    zone_id                = aws_lb.kloudgov_alb.zone_id
    evaluate_target_health = true
  }
}

An alias record is the right choice over a plain CNAME here: alias records work at the zone apex if needed, don’t incur an extra DNS lookup the way a CNAME does, and integrate with Route 53 health checks via evaluate_target_health, so DNS can route around an unhealthy target automatically if the ALB reports it.

Step 9: Tightening the EC2 Security Group

With the ALB now sitting in front of every instance, the security group from Part 1 can and should be tightened. Port 80 no longer needs to be open to the entire internet; it only needs to accept traffic from the ALB.

First, pass the ALB’s security group ID into the module:

# modules/aws_kloudgov_infrastructure/variables.tf

variable "alb_security_group_id" {
  description = "Security group ID of the shared ALB"
}
# terraform/main.tf — inside the existing "aws_kloudgov_infrastructure" module block
module "aws_kloudgov_infrastructure" {
  source                 = "./modules/aws_kloudgov_infrastructure"
  for_each                = toset(var.states)
  state_name              = each.value
  alb_security_group_id   = aws_security_group.alb_sg.id
}

Then update the module’s security group to scope port 80 to the ALB specifically, and drop the now-unused port 5000 rule entirely:

# modules/aws_kloudgov_infrastructure/main.tf

resource "aws_security_group" "state_ec2_sg" {
  name        = "kloudgov-${var.state_name}-ec2-sg"
  description = "Allow SSH and ALB-forwarded HTTP traffic"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [var.alb_security_group_id]
  }

  ingress {
    from_port       = 0
    to_port         = 0
    protocol        = "-1"
    security_groups = ["<INSERT_YOUR_EC2/IDE_SECURITY_GROUP_ID>"]
  }

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

  tags = {
    Name = "kloudgov-${var.state_name}"
  }
}

Two real security improvements here: port 80 is no longer reachable from the public internet directly, only from the ALB’s security group, meaning the ALB genuinely becomes the sole path into any instance. And port 5000 is gone entirely, since nothing in the actual deployed application ever listens on it; it was carried over from an early assumption in Part 1 that didn’t match how the Ansible role actually configured Gunicorn and Nginx. Worth cleaning up rather than leaving an open, unused port sitting in a security group.

Step 10: Applying the Changes

cd kloud-gov-infrastructure/terraform
terraform plan
terraform apply -auto-approve

This apply takes noticeably longer than previous ones in this series, except for a few minutes specifically for ACM’s DNS validation to complete before the certificate becomes usable and the HTTPS listener can attach to it.

Before moving on, check the plan output carefully. Because the security group definition itself changed in Step 9 (new ingress rules, a new source referencing var.alb_security_group_id), Terraform may decide the existing EC2 instances need to be destroyed and recreated rather than updated in place; you’ll see -/+ next to aws_instance in the plan if that’s the case, rather than a simple in-place ~ update. This is a normal, if easy-to-miss, side effect of modifying security group definitions on resources that reference them directly. If it happens, don’t be alarmed; it just means Step 11 below isn’t optional this time.

Step 11: Re-Running the Ansible Playbook

If Terraform replaced any instances in Step 10, they come back up completely bare: no Nginx, no application, nothing configured even though they’re correctly attached to their target groups and sitting behind the right security group. The Part 3 provisioners will have already appended fresh entries for these new instances to /etc/ansible/hosts automatically, so confirm that first:

cat /etc/ansible/hosts

Each state should show its current instance ID and private IP, matching what’s in the AWS Console. Then re-run the deployment playbook, exactly as in Part 3:

cd ~/kloud-gov-infrastructure/ansible

ansible-playbook deploy-kloudgov.yml -e "ansible_ssh_private_key_file=/home/ec2-user/kloudgov-ec2-key.pem"

This reconfigures every replaced instance from scratch, installing Nginx, deploying the application, and starting the systemd service, and is exactly why Part 3 built this pipeline to be idempotent in the first place. States that weren’t replaced report “ok” with no changes, and states that were come back fully configured within one playbook run. Give the target groups a minute or two after this completes; health checks need a couple of successful passes before AWS marks a target healthy again.

If you skip this step after an instance replacement, every target group will report “unhealthy” indefinitely, not because of a networking or DNS problem, but simply because there’s no application running yet to answer the health check at all. Worth remembering any time you make a change to a security group Terraform associates directly with your instances.

Step 12: Validating the Setup

Confirm each piece independently:

ACM — in the AWS Console, confirm the certificate status shows “Issued,” not “Pending validation.”
Route 53 — confirm one A (alias) record exists per state, each pointing at the ALB’s DNS name.
Target Groups — confirm each state’s target group shows its instance as “healthy.” If it shows “unhealthy” and Step 11 doesn’t apply here (no instance replacement occurred), double-check the health check path (/) actually returns a 200 from Nginx, and that the security group changes from Step 9 correctly permit the ALB to reach port 80.

Then, from a browser:

https://michigan.kloudgov.online

https://florida.kloudgov.online

https://nevada.kloudgov.online

Each should load the correct state’s KloudGov instance, over HTTPS, with a valid certificate, and typing http:// instead should redirect automatically to the secure version.

Step 13: Committing the Changes

git status
git add .
git commit -m "Added ALB, ACM certificate, and Route 53 records for HTTPS state endpoints"
git push -u origin

A Note on Cost

Worth being upfront about this, since it’s a real change from the rest of this series: an ALB has an hourly cost even when idle, unlike an EC2 instance you can stop. Route 53 hosted zones also carry a small monthly charge plus per-query costs. ACM certificates themselves are free when used with AWS resources like an ALB. None of this is expensive at lab scale, but it’s a different cost profile than everything built in Parts 1 through 3, and worth factoring in if you’re leaving this infrastructure running between sessions rather than tearing it down each time.

Step 14: Tearing Down

terraform destroy -auto-approve

Terraform’s dependency graph handles the teardown order correctly on its own: the HTTPS listener (and its certificate attachment) gets removed before the certificate itself, and DNS records before the ALB they point at. The one caveat carried over from earlier posts still applies here. If any state’s S3 bucket has leftover objects from the known employee-deletion bug, terraform destroy will still fail with “bucket not empty” until those are cleared manually.

If you rebuild this environment again later, remember Step 11: any terraform apply that replaces an instance, not just this one, leaves it unconfigured until the Ansible playbook runs again. Worth keeping that pairing in mind as a habit in the future, not just a one-time fix for this post.

What This Actually Fixed

Before this post, every state’s application was reachable at an address that changed on every instance recreation, over a connection with zero encryption. Now, every state has a permanent, memorable, encrypted endpoint michigan.kloudgov.online that stays valid regardless of how many times the underlying EC2 instance gets destroyed and recreated behind it. And because the target group attachment references the instance through Terraform’s own state, a recreated instance gets re-attached to its target group automatically on the next apply, with zero manual DNS or load balancer reconfiguration.

Key Takeaways

A shared ALB with host-based routing scales cleanly with a for_each-driven multi-tenant module: one load balancer, one certificate, any number of tenants, rather than provisioning either per state.
aws_acm_certificate_validation is a genuinely blocking resource; the first apply after adding it will take longer than previous ones, and that’s expected, not a hang.
Match your ALB’s target group port to where the application is actually listening for this project; that’s Nginx on port 80, not the Gunicorn/application port assumed back in Part 1.
Once an ALB sits in front of your instances, tighten the instance-level security group to only trust the ALB’s security group on the application port; the ALB should become the sole public path in.
zipmap() paired with range() is a clean way to generate unique, deterministic listener rule priorities from a for_each set, without manual bookkeeping as new tenants are added.
A security group change can force EC2 instance replacement; always check the terraform plan for -/+ next to aws_instance before applying, and if you see it, plan on re-running the Ansible playbook immediately afterward. A “healthy” check in the target group console won’t happen on its own if the instance behind it was silently replaced with a bare one.
An ALB and a Route 53 hosted zone introduce a genuinely different cost profile than the rest of this series; factor that in if this infrastructure stays running between work sessions.

With provisioning, configuration, deployment, and now secure public access all fully automated, the KloudGov project has moved from “working lab exercise” to something that reflects, end to end, how a real multi-tenant SaaS platform actually gets built on AWS.

{{ reviewsTotal }}{{ options.labels.singularReviewCountLabel }}
{{ reviewsTotal }}{{ options.labels.pluralReviewCountLabel }}
{{ options.labels.noReviewsLabel }}
{{ options.labels.newReviewButton }}
{{ userData.canReview.message }}

Related Posts

EKS-KloudGov-Cluster
AWS EKS Cluster Setup: Provisioning Kubernetes with eksctl and Terraform (Part 1)
This new series adds a third architecture: Kubernetes, via Amazon EKS (Elastic Kubernetes Service), fronted...
Docker-and-ECR
Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)
Everything built by hand in Part 3 the task definition, the target group, the ALB,...
Docker-and-ECR
ECS Task Definitions and Fargate Services: Deploying KloudGov (Part 3)
creating the task definition through the console, launching the service, and watching it come up...