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/ordersinstead of:
https://xyz.execute-api.eu-central-1.amazonaws.com/dev/ordersCloudFront
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.comUnlike 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.arnExample 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.







