Custom Domain on Azure App Service using Terraform and Cloudflare

The other day, I was building some infrastructure on Azure that contained an Azure App Service. I wanted to use a custom domain so that users can use the application over a nice domain name instead of the *.azurewebsites.net. The infrastructure is built using Terraform; luckily, there is a provider for Cloudflare. Cloudflare is where the domain’s DNS is managed. This blog post will walk you through the steps to do all the configuration.

Setup the Azure App Service

Let’s start with creating the Azure App Service and the plan it runs on. That is shown in below example:

locals {
 project_name       = "tfsessionadvanced"
 location           = "West Europe"
}

resource "azurerm_resource_group" "rg" {
 name     = "rg-${local.project_name}-${var.environment}"
 location = local.location
}

resource "azurerm_service_plan" "app_service_plan" {
 name                = "asp-${local.project_name}-${var.environment}"
 resource_group_name = azurerm_resource_group.rg.name
 location            = azurerm_resource_group.rg.location
 os_type             = "Linux"
 sku_name            = "B1"
}

resource "azurerm_linux_web_app" "app_service" {
 name                = "app-${local.project_name}-${var.environment}"
 resource_group_name = azurerm_resource_group.rg.name
 location            = azurerm_resource_group.rg.location
 service_plan_id     = azurerm_service_plan.app_service_plan.id

 site_config {}
}

The Terraform and provider block looks like this:

terraform {
 required_version = ">= 1.1.7"

 required_providers {
   azurerm = {
     source  = "hashicorp/azurerm"
     version = "3.27.0"
   }
 }
}

provider "azurerm" {
 features {}
}

Add Cloudflare to the mix

Now that we have the basics for the App Service in place, it is time to create the DNS entries in Cloudflare so we can use that on our Azure App Service. I will be using a CNAME, but you can, of course, also use an A-record.

The first thing we need to do is add the Cloudflare provider to Terraform. After you’ve done that, the config in Terraform looks like this:

terraform {
  required_version = ">= 1.1.7"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "3.27.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "3.31.0"
    }
  }
}

provider "azurerm" {
  features {}
}

provider "cloudflare" {
}

For Terraform to be able to talk to Cloudflare, you need to create an API Token, here’s how, and give that to the Cloudflare provider in Terraform. There are multiple ways to do that. An easy but unsafe way is to add it to the provider config like so:

provider "cloudflare" {
  api_token = "<your token>"
}

That could be fine for development but should not be pushed to your source control system. We will look at better ways later on in this post.

Now that we have the provider in place, let’s create the two domain records: one for the CNAME and one for the domain name validation. That last one allows the app service to validate that you own the domain. They do that by giving you a token you need to add as an additional TXT record in DNS. Here’s how to do both in Terraform:

resource "cloudflare_record" "domain-verification" {
  zone_id = "<your zone id>"
  name    = "asuid.tf-demo.staal-it.nl"
  value   = azurerm_linux_web_app.app_service.custom_domain_verification_id
  type    = "TXT"
  ttl     = 3600
}

resource "cloudflare_record" "cname-record" {
  zone_id = "<your zone id>"
  name    = "tf-demo"
  value   = azurerm_linux_web_app.app_service.default_hostname
  type    = "CNAME"
  ttl     = 3600
}

As you can see in the example above, the value for the domain validation can be retrieved from the App Service object in Terraform. The same goes for the hostname. The result in Cloudflare should resemble the following: Terraform Plan in Azure DevOps Terraform Plan in Azure DevOps

Deploy the custom domain on the App Service

With the DNS records in place, we can configure our last Terraform resource, the custom binding on the App Service. That is done as shown below:

resource "azurerm_app_service_custom_hostname_binding" "hostname-binding" {
  hostname            = "tf-demo.staal-it.nl"
  app_service_name    = azurerm_linux_web_app.app_service.name
  resource_group_name = azurerm_resource_group.rg.name

  depends_on = [
    cloudflare_record.domain-verification,
    cloudflare_record.cname-record
  ]
}

Now run a Terraform init, plan and apply and verify that you can reach the App Service using your custom domain. It might take a while to function when you’ve used an A-records. A CNAME record should work immediately. On the App Service in Azure, you should now see the binding: Terraform Plan in Azure DevOps

Secure the Cloudflare API Token

The API Token we’ve added to the provider config needs to be removed. Since that API Token is like a password, we need not store that in Git. An alternative is to set it as an environment variable named CLOUDFLARE_API_TOKEN. The Cloudflare provider in Terraform will then read it from there. Use the command native to your operating system to set the environment variable.

Azure DevOps Pipeline

To ensure we can also securely use the Cloudflare API Token in our Azure DevOps pipeline, we need to take an additional step. The task I use in my pipelines to work with Terraform, TerraformCLI, supports passing an Azure DevOps Secure File. That means that you can create a .env file with the following contents:

CLOUDFLARE_API_TOKEN=<your_token>

That file needs to be uploaded as a secure file. Then, one last modification is needed on the task in the pipeline. Where you use that to do the Terraform plan, add the following line:

secureVarsFile: cloudflare.env

The whole task then looks like this:

- task: TerraformCLI@0
    displayName: Terraform plan
    inputs:
      command: plan
      environmentServiceName: ${{ parameters.service_connection }}
      publishPlanResults: ${{ parameters.environment }}
      workingDirectory: ${{ parameters.working_directory }}
      secureVarsFile: cloudflare.env
      runAzLogin: true
      commandOptions: -input=false -var-file=./config/${{ parameters.environment }}.tfvars -out=${{parameters.environment}}-tf.plan -detailed-exitcode -lock=false

A complete, working pipeline can be found here.