Quick guide to add custom domains to AWS API Gateways using Terraform, Route53, ACM, and Cloudfront

Hossein Heydari
HeyJobs Tech
Published in
8 min readJun 21, 2022

Introduction

API Gateways can be used to make a connection between your business logic and your client’s requests. It can be added on top of an EC2 instance, Lambda functions, AWS Kinesis, Dynamodb, and many other AWS services. If you’re heavily using AWS serverless services, I bet there is a case where you need to add a custom domain on top of an API Gateway.

Usually, when you deploy an API Gateway, it looks like this:

https://RANDOM_REGION.execute-api.AWS_REGIONS.amazonaws.com

Well, it sounds good if you’re trying to use the API Gateway for internal service calls, but if it’s something customer-facing, it better be a proper domain name instead.

To serve this purpose, we’re going to set up a custom domain on an API Gateway following IaC concepts. We’ll be using Terraform to provision Route53 records, ACM Certificate, and Cloudfront distribution to create the API Gateway Custom Domain and later on, we’re going to do an API Mapping using Serverless Framework with a plugin called Serverless Domain Manager to connect an API to the custom domain.

Route53, Hosted Zone, Subdomain, and Certificate with Terraform

Terraform is an infrastructure as code tool which helps you to provision and manage all your infrastructure resources with human-readable configuration files that can be shared and reused later.

Creating a domain requires you to have a hosted zone in route53, you can either create one in Terraform and then use reference attributes, or, you can use Terraform data resources to use an existing one.

We’re going to create a Terraform module and then we’re going to use the module to provision the infrastructure resources in different development environments (e.g: staging, production, QA).

Using modules is going to help us reduce redundancy by preventing us from copying/pasting the same block of code over and over again.

Step 1: Create a file called variables.tf that contains the following variables:

variable "zone_id" { type = string }variable "root_domain" { type = string }variable "subdomain" { type = string }

Step 2: create a main.tf , we’re going to keep all the resources here.

Step 3: Add Terraform and AWS Provider specification block at the top of main.tf :

terraform {
required_version = "~> 1.1.7"
required_providers {aws = {source = "hashicorp/aws"version = "~> 4.13"configuration_aliases = [aws.eu_central_1, aws.us_east_1]
}
}
}

We need that configuration_aliases later, because there are cases where you need to create a specific resource in a specific region so you need different provider configurations for different AWS regions.

We keep all our resources under the EU-Central-1 region, but, since we’re going to attach an ACM certificate to a CloudFront distribution which is a global entity, we have created the certificate only in US-East-1, so we added configuration aliases to be able to provide a resource in US-East-1 Region. Check the link below:

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html#https-requirements-aws-region

Step 4: By the assumption that you have already created a Route53 Hosted Zone via AWS console, you can make use of the Data Resources by providing the hosted zone ID and then the data resource will provide you with the attribute references.

data "aws_route53_zone" "hosted_zone" {zone_id = var.zone_idprivate_zone = false}

In the code above, zone_id is a variable, you should fill it with a value later when calling the module.

You can get the Hosted Zone Id by going to AWS Console -> Route53 -> Hosted Zones -> Choose your hosted zone and then click on Hosted Zone Details:

Step 5: Request an ACM Certificate for all subdomains under the hosted zones, we’ll be using DNS wildcards for that.

resource "aws_acm_certificate" "cert" {provider = aws.us_east_1domain_name = var.root_domainvalidation_method = "DNS"subject_alternative_names = ["*.${var.root_domain}",]}

Tip: provider = aws.us_east_1 needs to be there, because the resource should not be created in the Europe region. Take a look at the link below for more information:

Requirements for using SSL/TLS certificates with CloudFront

Step 6: We now need to create a Route53 record resource for certificate validation.

For more information, check the link below:

DNS Validation

resource "aws_route53_record" "record_cert_validation" {for_each = {for dvo in aws_acm_certificate.cert.domain_validation_options : dvo.domain_name => {name   = dvo.resource_record_namerecord = dvo.resource_record_valuetype   = dvo.resource_record_type}
}
allow_overwrite = truename = each.value.namerecords = [each.value.record]ttl = 60type = each.value.typezone_id = data.aws_route53_zone.hosted_zone.zone_id}resource "aws_acm_certificate_validation" "cert_validation" {provider = aws.us_east_1certificate_arn = aws_acm_certificate.cert.arnvalidation_record_fqdns = [for record in aws_route53_record.record_cert_validation : record.fqdn]timeouts {create = "45m"}
}

Cloudfront and API Gateway Custom Domain

Step 7: The next step for us would be creating aws_api_gateway_domain_name resource.

This resource creates a Cloudfront distribution underneath and also provides Cloudfront Zone id and Cloudfront Domain name as attribute references. You may ask what exactly Cloudfront is doing under the hood? Well, we are creating a distribution that points to our API Gateway Url as Origin Domain.

When creating the Route53 record, we will provide the Cloudfront distribution endpoint as an alias. It would be like this:

Route53 → Cloudfront → API Gateway

You can also add an ACM certificate to your Cloudfront distribution.

Check the link below, it explains what we’re doing here, the only difference is that here we’re following infrastructure-as-code concepts using Terraform and SLS.

https://aws.amazon.com/premiumsupport/knowledge-center/api-gateway-cloudfront-distribution

resource "aws_api_gateway_domain_name" "api_gateway_domain" {provider = aws.eu_central_1domain_name = var.subdomaincertificate_arn = aws_acm_certificate.cert.arn}

Final Step: create the subdomain Route53 resource:

resource “aws_route53_record” “sub_domain” {provider = aws.eu_central_1name     = var.subdomaintype     = “A”zone_id  = data.aws_route53_zone.hosted_zone.zone_idalias {name = aws_api_gateway_domain_name.api_gateway_domain.cloudfront_domain_namezone_id = aws_api_gateway_domain_name.api_gateway_domain.cloudfront_zone_idevaluate_target_health = true}
}

Note: seems Medium ruins the Terraform linting here, make sure to run terraform fmt

Now that the module is ready, we can go on and import the module, fill the variables and run it.

You can now create a file with .tf an extension wherever you like and import the module.

terraform {required_version = "~> 1.1.7"}locals {env = "qa"zone_id = "somezoneid"subdomain = "subdomain"root_domain = "root_domain.com"}module api_gateway_domain {source = "../../modules/api_gateway_custom_domain" # Just an examplezone_id     = local.zone_idsubdomain   = “${local.subdomain}.${local.root_domain}”root_domain = local.root_domainproviders = {aws.eu_central_1 = awsaws.us_east_1 = aws.us_east_1}
}

Since we need to provision different resources in different regions, create a file named providers.tf that contains the following piece of code:

provider "aws" {region = "eu-central-1"default_tags {tags = {env = "qa"}
}
}
provider "aws" {alias = "us_east_1"region = "us-east-1"default_tags {tags = {env = "qa"}
}
}

The last step is to execute plan and apply , and check the AWS account to make sure that the resources are successfully created on our AWS account.

The command below performs several different initialization steps to prepare the current working directory:

terraform init

You can now plan and see the resources that are gonna be added to your AWS account.

terraform plan

Lastly, execute apply the command:

terraform apply

After applying is successfully finished, you can go on and check if the resources were created via the AWS console.

If you move to the Route53 records, there should be a new type A record that points at a CloudFront distribution:

Move to API Gateway → Custom Domains, you should see the subdomain you specified in your terraform locals before,

Bonus: Serverless Domain Manager

When you have the custom domain ready, you can do the API mappings on the AWS console. You can also use Terraform to do the mappings:

API Mappings with Terraform

When we started to create the custom domain, the API Gateway itself was already created with Cloudformation so we had to do the mappings with Serverless Framework.

Serverless Domain Manager is a serverless plugin that helps you manage stuff related to API Gateway domains, for more information click on the links below:

https://github.com/amplify-education/serverless-domain-manager

Step 1: Install the plugin using npm:

npm install serverless-domain-manager

Step 2: Add the plugin to serverless.yml file:

plugins:
- serverless-domain-manager

Step 3: By the assumption that you already have an API Gateway on top of a lambda function like this in a file called functions.yml:

myLambda:
handler: src.handlers.handler
module: functions/my_lambda
role: ${self:custom.iam.myLambdaRole}
reservedConcurrency: 10
events:
- http:
method: GET

Final Step: Let’s import that functions.yml into our serverless.yml and do the API mappings for custom domains.

functions:
- ${file(functions.yml)}
custom:
customDomainEnabledStageMap:
qa: true
staging: true
production: true
customDomain:
domainName: mysubdomain-${self:provider.stage}.rootdomain.com
stage: ${self:provider.stage}
createRoute53Record: false
enabled: ${self:custom.customDomainEnabledStageMap.${self:provider.stage}, false}

In the code above, domainName is obligatory to provide within customDomain scope. The value should be the same as the Route53 record you created earlier using Terraform.

createRoute53Record is false in our case, since we already created the record with Terraform earlier; however, it doesn’t do anything if the record already exists, but we added that just in case ;-)

We have different stages when deploying resources. If you’re following some patterns like pull request deployments, it sounds insane to map all the API Gateways resources created by each pull request, so legitimately, you’ll only need to map the APIs if they’re on the production, QA, or staging environment.

enabled helps you to specify whether you want the mapping to happen or not. What we’re doing here is checking if the stage is either one of QA, staging, or productions, if not, the enabled value will be false, therefore nothing would be mapped.

Wrapping up

That would be it for today! Hopefully, that helped you to get some ideas how to set a custom domain on an API Gateway using infra-as-code services. Personally, the fact that some resources were already created before, with different tools or with AWS console manually, made it a bit tough for me to find a solution, but the moment you have an overall idea of what each Terraform resource is doing underneath, it will be much easier.

Thanks for your time reading this ❤!

Interested in joining HeyJobs? Check out our open positions here.

--

--