Custom Domains for API Gateway and CloudFront with Terraform

Exposing services through custom domains for API Gateway and CloudFront with Terraform is an architectural decision that defines how users, clients, and systems discover and access your application.

Without a structured approach, domain configuration often ends up fragmented — certificates managed separately, DNS records manually created, API Gateway domains inconsistently configured, and CloudFront distributions loosely connected to Route53.

In this post, we will build a reusable Terraform module that standardizes custom domain configuration for both API Gateway and CloudFront.

Understanding the Domain Architecture

At a high level, exposing an application through a custom domain involves three layers:

  • DNS (Route53) – translates domain names into AWS-managed endpoints
  • TLS (ACM) – secures communication via HTTPS
  • Service Endpoint (API Gateway or CloudFront) – serves the actual traffic

Each of these layers is independent, but tightly connected:

  • Route53 does not know anything about your API and it only maps a domain name to a target
  • API Gateway and CloudFront do not own your domain instead they only expose endpoints that can be attached to it
  • ACM certificates do not route traffic – they only establish trust
Two Different Target Types

In this setup, we intentionally support two distinct use cases:

  • API Gateway custom domain
  • CloudFront distribution domain

They may look similar from the outside (both resolve to HTTPS endpoints), but internally they behave differently.

API Gateway

When using API Gateway, a custom domain acts as a layer in front of your API stages and it allows you to expose endpoints like:

https://api.example.com/orders

instead of:

https://xyz.execute-api.eu-central-1.amazonaws.com/dev/orders

CloudFront

CloudFront sits at the edge and is typically used for:

  • Frontend applications (S3 + CDN)
  • Static assets
  • Global caching

The custom domain represents your public entry point:

https://example.com

Unlike API Gateway, CloudFront already sits in front of your application across multiple locations worldwide. When you point your domain to it, users are automatically routed to the nearest edge location, without needing any additional domain layer or abstraction.

DNS Resolution with Route53

Everything starts with Route53. We first resolve the hosted zone:

resource "aws_route53_zone" "public_zone" {
  name = var.hosted_zone_name
}

resource "aws_route53_zone" "private_zone" {
  count = var.create_private_record ? 1 : 0

  name = var.hosted_zone_name

  vpc {
    vpc_id = var.vpc_id
  }
}

This dual setup allows the same domain to exist in both:

  • Public DNS (internet-facing)
  • Private DNS (VPC-internal resolution)

This is particularly useful in hybrid architectures where internal services resolve the same domain differently.

API Gateway Custom Domain

To expose an API via a custom domain, we first define the domain itself:

resource "aws_api_gateway_domain_name" "api_domain" {
  count = var.target_type == "apigw" ? 1 : 0

  domain_name     = var.domain_name
  certificate_arn = var.certificate_arn

  endpoint_configuration {
    types = ["EDGE"]
  }

  endpoint_access_mode = "STRICT"
  security_policy      = "SecurityPolicy_TLS12_PFS_2025_EDGE"
}

This resource does several things:

  • Attaches an ACM certificate to enable HTTPS
  • Provisions a CloudFront distribution behind the scenes (for EDGE endpoints)
  • Provides a target domain that can be mapped via Route53

One important detail: API Gateway EDGE domains always use CloudFront internally.

Base Path Mapping

Defining a domain is not enough. We must connect it to an actual API stage:

resource "aws_api_gateway_base_path_mapping" "mapping" {
  count       = var.target_type == "apigw" ? 1 : 0
  domain_name = aws_api_gateway_domain_name.api_domain[0].domain_name
  api_id      = var.api_gateway_id
  stage_name  = var.api_stage
}

This mapping defines:

  • Which API is exposed
  • Which stage is used
  • How paths are resolved

Without this, the domain exists but serves nothing.

Route53 Alias for API Gateway

Now we connect DNS to the API Gateway domain:

resource "aws_route53_record" "apigw_record" {
  count = var.target_type == "apigw" ? 1 : 0

  zone_id = data.aws_route53_zone.zone.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = aws_api_gateway_domain_name.api_domain[0].cloudfront_domain_name
    zone_id                = aws_api_gateway_domain_name.api_domain[0].cloudfront_zone_id
    evaluate_target_health = var.evaluate_target_health
  }
}

This is where everything connects:

  • Route53 → CloudFront (managed by API Gateway) → API Gateway → Backend

The private record follows the exact same logic, but resolves inside a VPC.

CloudFront Domain Mapping

CloudFront is simpler because the distribution already exists:

resource "aws_route53_record" "cloudfront_record" {
  count = var.target_type == "cloudfront" ? 1 : 0

  zone_id = data.aws_route53_zone.zone.zone_id
  name    = var.domain_name
  type    = "A"

  alias {
    name                   = var.cloudfront_domain_name
    zone_id                = var.cloudfront_zone_id
    evaluate_target_health = var.evaluate_target_health
  }
}

There is no additional “domain resource” here — only DNS mapping. That’s because CloudFront handles domain binding directly in the distribution configuration.

Module Design

The key design decision in this setup is abstraction through a single module:

variable "target_type" {
  description = "Target type: apigw or cloudfront"
  type        = string
}

Instead of duplicating logic, we:

  • Use conditional resources (count)
  • Support both target types
  • Keep inputs minimal but expressive

This allows us to standardize domain handling across the entire infrastructure.

TLS Certificates with ACM

To enable HTTPS for our domains, we use AWS Certificate Manager (ACM). In most setups, a wildcard certificate is sufficient, as it can cover multiple subdomains under the same domain.

resource "aws_acm_certificate" "wildcard" {
  domain_name               = var.domain_name
  subject_alternative_names = ["*.${var.domain_name}"]

  validation_method = "DNS"

  tags = {
    Name = var.domain_name
  }

  lifecycle {
    create_before_destroy = true
  }
}

DNS validation is handled via Route53:

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.wildcard.domain_validation_options :
    dvo.domain_name => dvo
  }

  zone_id = data.aws_route53_zone.zone.zone_id
  name    = each.value.resource_record_name
  type    = each.value.resource_record_type
  records = [each.value.resource_record_value]
  ttl     = 60
}

The certificate ARN is then passed to the module:

certificate_arn = aws_acm_certificate.wildcard.arn
Example Usage

API Gateway

module "api_domain" {
  source                = "../modules/custom_domain"
  domain_name           = "api.${var.domain_name}"
  hosted_zone_name      = var.domain_name
  certificate_arn       = aws_acm_certificate.wildcard.arn
  target_type           = "apigw"
  api_gateway_id        = module.api.api_id
  api_stage             = var.env
  create_private_record = var.create_private_record
}

CloudFront

module "frontend_domain" {
  source                 = "../modules/custom_domain"
  target_type            = "cloudfront"
  domain_name            = var.domain_name
  hosted_zone_name       = var.domain_name
  certificate_arn        = aws_acm_certificate.wildcard.arn
  cloudfront_domain_name = aws_cloudfront_distribution.frontend_ui.domain_name
  cloudfront_zone_id     = aws_cloudfront_distribution.frontend_ui.hosted_zone_id
  create_private_record  = var.create_private_record
}

What This Setup Enables

With this structure in place:

  • Domains are consistently managed across environments
  • API Gateway and CloudFront follow the same abstraction
  • DNS records are predictable and version-controlled
  • Public and private resolution can coexist
  • Certificates are reused cleanly

Instead of manually wiring domains in multiple places, everything becomes part of your infrastructure definition.

Considerations

A few details:

  • TLS policy – enforce modern standards (TLS 1.2+)
  • EDGE vs REGIONAL – choose based on latency and architecture
  • Wildcard certificates – simplify domain management
  • Private hosted zones – useful for internal service routing
  • Consistent naming – prevents DNS conflicts

Most domain issues in production are not caused by Terraform — but by unclear ownership of these decisions.

Conclusion

Custom domains in AWS are not a final step and they define how your services are exposed. By managing Route53, API Gateway, and CloudFront integrations in Terraform, DNS becomes consistent, certificates centralized, and endpoints decoupled from AWS-generated URLs.