├── .gitignore ├── README.md ├── examples ├── appservice_domain.tf ├── dns_zone.tf ├── output.tf ├── providers.tf ├── rg.tf ├── tfplan └── variables.tf ├── images ├── portal.png ├── resources.png └── youtube.png ├── main.tf ├── output.tf ├── providers.tf └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | crash.*.log 11 | 12 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 13 | # password, private keys, and other secrets. These should not be part of version 14 | # control as they are data points which are potentially sensitive and subject 15 | # to change depending on the environment. 16 | *.tfvars 17 | *.tfvars.json 18 | 19 | # Ignore override files as they are usually used to override resources locally and so 20 | # are not checked in 21 | override.tf 22 | override.tf.json 23 | *_override.tf 24 | *_override.tf.json 25 | 26 | # Include override files you do wish to add to version control using negated pattern 27 | # !example_override.tf 28 | 29 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 30 | # example: *tfplan* 31 | 32 | # Ignore CLI configuration files 33 | .terraformrc 34 | terraform.rc 35 | 36 | .terraform.lock.hcl 37 | 38 | .infracost -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure App Service Domain module for Terraform 2 | 3 | ## Problem 4 | 5 | You can create a custom domain name in Azure using App Service Domain service. 6 | You can do that using Azure portal or Azure CLI. 7 | But you cannot do that using Terraform for Azure provider. 8 | Because that is not implemented yet. 9 | Creating a custom domain in infra as code tool like Terraform might not be that much appealing for enterprises. 10 | They would purchase their domain name manually, just once. Infra as code doesn't make lots of sense here. 11 | 12 | However for labs, workshops and demonstrations, this is very useful to make the lab more realistic. 13 | 14 | ## Solution 15 | 16 | We'll provide a Terraform implementation for creating a custom domain name using Azure App Service Domain. 17 | We'll use `AzApi` provider to create the resource. More info about AzApi here: https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/azapi_resource. 18 | 19 | The AzApi will call the REST API and pass the required JSON file containing the needed attributes. 20 | Take a look at the REST API for App Service Domain here: https://learn.microsoft.com/en-us/rest/api/appservice/domains/create-or-update 21 | 22 | We also create an Azure DNS Zone to manage and configure the domain name. 23 | 24 | And we create an A record "test" to make sure the configuration works. 25 | 26 | The complete Terraform implementation is in this current folder. 27 | 28 | ## Video tutorial 29 | 30 | Here is a Youtube video explaining how this works: [https://www.youtube.com/watch?v=ptdAcsG2ROI](https://www.youtube.com/watch?v=ptdAcsG2ROI) 31 | 32 | ![](https://github.com/HoussemDellai/terraform-azapi-appservice-domain/blob/main/images/youtube.png?raw=true) 33 | 34 | ## How to use it 35 | 36 | ```hcl 37 | resource "azurerm_resource_group" "rg" { 38 | name = "rg-${var.custom_domain_name}" 39 | location = "westeurope" 40 | } 41 | 42 | # DNS Zone to configure the domain name 43 | resource "azurerm_dns_zone" "dns_zone" { 44 | name = var.custom_domain_name 45 | resource_group_name = azurerm_resource_group.rg.name 46 | } 47 | 48 | # DNS Zone A record 49 | resource "azurerm_dns_a_record" "dns_a_record" { 50 | name = "test" 51 | zone_name = azurerm_dns_zone.dns_zone.name 52 | resource_group_name = azurerm_resource_group.rg.name 53 | ttl = 300 54 | records = ["1.2.3.4"] # just example IP address 55 | } 56 | 57 | module "appservice_domain" { 58 | source = "../." # if calling module from local machine 59 | # source = "HoussemDellai/appservice-domain/azapi" # if calling module from Terraform Registry 60 | 61 | providers = { 62 | azapi = azapi 63 | } 64 | 65 | custom_domain_name = var.custom_domain_name 66 | resource_group_id = azurerm_resource_group.rg.id 67 | dns_zone_id = azurerm_dns_zone.dns_zone.id 68 | 69 | agreedby_ip_v6 = "2a04:cec0:11d9:24c8:8898:3820:8631:d83" 70 | agreedat_datetime = "2024-01-01T9:00:00.000Z" 71 | 72 | contact = { 73 | nameFirst = "FirstName" 74 | nameLast = "LastName" 75 | email = "youremail@email.com" # you might get verification email 76 | phone = "+33.762954328" 77 | addressMailing = { 78 | address1 = "1 Microsoft Way" 79 | city = "Redmond" 80 | state = "WA" 81 | country = "US" 82 | postalCode = "98052" 83 | } 84 | } 85 | } 86 | 87 | variable "custom_domain_name" { 88 | type = string 89 | validation { 90 | condition = length(var.custom_domain_name) > 0 && (endswith(var.custom_domain_name, ".com") || endswith(var.custom_domain_name, ".net") || endswith(var.custom_domain_name, ".co.uk") || endswith(var.custom_domain_name, ".org") || endswith(var.custom_domain_name, ".nl") || endswith(var.custom_domain_name, ".in") || endswith(var.custom_domain_name, ".biz") || endswith(var.custom_domain_name, ".org.uk") || endswith(var.custom_domain_name, ".co.in")) 91 | error_message = "Available top level domains are: com, net, co.uk, org, nl, in, biz, org.uk, and co.in" 92 | } 93 | } 94 | ``` 95 | 96 | ## Deploy the resources using Terraform 97 | 98 | Choose the custom domain name you want to purchase in the file `terraform.tfvars`. 99 | 100 | Then run the following Terraform commands from within the current folder. 101 | 102 | ```sh 103 | terraform init 104 | terraform plan -out tfplan 105 | terraform apply tfplan 106 | ``` 107 | 108 | ## Test the deployment 109 | 110 | Verify you have two resources created within the resource group. 111 | 112 | ![](https://github.com/HoussemDellai/terraform-azapi-appservice-domain/blob/main/images/resources.png?raw=true) 113 | 114 | Verify that custom domain name works. 115 | You should see the IP address we used in A record which is `1.2.3.4`. 116 | 117 | ```sh 118 | nslookup test. # replace with domain name 119 | # Server: bbox.lan 120 | # Address: 2001:861:5e62:69c0:861e:a3ff:fea2:796c 121 | # Non-authoritative answer: 122 | # Name: test.houssem13.com 123 | # Address: 1.2.3.4 124 | ``` 125 | 126 | ## Creating a custom domain name using Azure CLI 127 | 128 | In this lab we used Terraform to create the domain name. 129 | But still you can just use Azure portal or command line. 130 | 131 | ![](https://github.com/HoussemDellai/terraform-azapi-appservice-domain/blob/main/images/portal.png?raw=true) 132 | 133 | Make sure you fill the `contact_info.json` file. It is required to create domain name. More details here: https://learn.microsoft.com/en-us/cli/azure/appservice/domain?view=azure-cli-latest#az-appservice-domain-create 134 | 135 | ```sh 136 | az group create -n rg-dns-domain -l westeurope -o table 137 | 138 | az appservice domain create ` 139 | --resource-group rg-dns-domain ` 140 | --hostname "houssem.com" ` 141 | --contact-info=@'contact_info.json' ` 142 | --accept-terms 143 | ``` 144 | 145 | ## Important notes 146 | 147 | You should use a Pay-As-You-Go azure subscription to be able to create Azure App Service Domain. 148 | MSDN/VisualStudio and Free Azure subscriptions doesn't work. 149 | 150 | Within the terraform config file, you can change the contact info for the contactAdmin, contactRegistrant, contactBilling and contactTech. 151 | It worked for me when reusing the same contact ! 152 | 153 | ## Module available in Terraform registry 154 | 155 | The module is available in Terraform registry: [https://registry.terraform.io/modules/HoussemDellai/appservice-domain/azapi/latest](https://registry.terraform.io/modules/HoussemDellai/appservice-domain/azapi/latest) -------------------------------------------------------------------------------- /examples/appservice_domain.tf: -------------------------------------------------------------------------------- 1 | module "appservice_domain" { 2 | # source = "../." # if calling module from local machine 3 | source = "HoussemDellai/appservice-domain/azapi" # if calling module from Terraform Registry 4 | version = "2.0.0" # if calling module from Terraform Registry 5 | 6 | custom_domain_name = var.custom_domain_name 7 | resource_group_id = azurerm_resource_group.rg.id 8 | dns_zone_id = azurerm_dns_zone.dns_zone.id 9 | 10 | agreedby_ip_v6 = "2a04:cec0:11d9:24c8:8898:3820:8631:d83" 11 | agreedat_datetime = "2023-08-10T11:50:59.264Z" 12 | 13 | contact = { 14 | nameFirst = "FirstName" 15 | nameLast = "LastName" 16 | email = "youremail@email.com" # you might get verification email 17 | phone = "+33.762954328" 18 | addressMailing = { 19 | address1 = "1 Microsoft Way" 20 | city = "Redmond" 21 | state = "WA" 22 | country = "US" 23 | postalCode = "98052" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/dns_zone.tf: -------------------------------------------------------------------------------- 1 | # DNS Zone to configure the domain name 2 | resource "azurerm_dns_zone" "dns_zone" { 3 | name = var.custom_domain_name 4 | resource_group_name = azurerm_resource_group.rg.name 5 | } 6 | 7 | # DNS Zone A record 8 | resource "azurerm_dns_a_record" "dns_a_record_test" { 9 | name = "@" 10 | zone_name = azurerm_dns_zone.dns_zone.name 11 | resource_group_name = azurerm_resource_group.rg.name 12 | ttl = 300 13 | records = ["1.2.3.4"] # just example IP address 14 | } 15 | -------------------------------------------------------------------------------- /examples/output.tf: -------------------------------------------------------------------------------- 1 | output "appservice_domain_name" { 2 | value = module.appservice_domain.appservice_domain_name 3 | } 4 | 5 | output "app_service_domain_id" { 6 | value = module.appservice_domain.app_service_domain_id 7 | } 8 | 9 | output "app_service_domain_name_servers" { 10 | value = module.appservice_domain.app_service_domain_name_servers 11 | } 12 | -------------------------------------------------------------------------------- /examples/providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | 3 | required_version = ">=1.6" 4 | 5 | required_providers { 6 | azurerm = { 7 | source = "hashicorp/azurerm" 8 | version = ">= 4.17.0" 9 | } 10 | azapi = { 11 | source = "Azure/azapi" 12 | version = ">= 2.2.0" 13 | } 14 | } 15 | } 16 | 17 | provider "azurerm" { 18 | features {} 19 | } 20 | 21 | provider "azapi" {} 22 | -------------------------------------------------------------------------------- /examples/rg.tf: -------------------------------------------------------------------------------- 1 | resource "azurerm_resource_group" "rg" { 2 | name = "rg-${var.custom_domain_name}" 3 | location = "westeurope" 4 | } -------------------------------------------------------------------------------- /examples/tfplan: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoussemDellai/terraform-azapi-appservice-domain/256544fbac148560ea23ab9b793c66b6a99c80c3/examples/tfplan -------------------------------------------------------------------------------- /examples/variables.tf: -------------------------------------------------------------------------------- 1 | variable "custom_domain_name" { 2 | type = string 3 | default = "sampleapp02.com" 4 | validation { 5 | condition = length(var.custom_domain_name) > 0 && (endswith(var.custom_domain_name, ".com") || endswith(var.custom_domain_name, ".net") || endswith(var.custom_domain_name, ".co.uk") || endswith(var.custom_domain_name, ".org") || endswith(var.custom_domain_name, ".nl") || endswith(var.custom_domain_name, ".in") || endswith(var.custom_domain_name, ".biz") || endswith(var.custom_domain_name, ".org.uk") || endswith(var.custom_domain_name, ".co.in")) 6 | error_message = "Available top level domains are: com, net, co.uk, org, nl, in, biz, org.uk, and co.in" 7 | } 8 | } -------------------------------------------------------------------------------- /images/portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoussemDellai/terraform-azapi-appservice-domain/256544fbac148560ea23ab9b793c66b6a99c80c3/images/portal.png -------------------------------------------------------------------------------- /images/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoussemDellai/terraform-azapi-appservice-domain/256544fbac148560ea23ab9b793c66b6a99c80c3/images/resources.png -------------------------------------------------------------------------------- /images/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HoussemDellai/terraform-azapi-appservice-domain/256544fbac148560ea23ab9b793c66b6a99c80c3/images/youtube.png -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | # App Service Domain 2 | # REST API reference: https://docs.microsoft.com/en-us/rest/api/appservice/domains/createorupdate 3 | resource "azapi_resource" "appservice_domain" { 4 | type = "Microsoft.DomainRegistration/domains@2024-04-01" 5 | name = var.custom_domain_name 6 | parent_id = var.resource_group_id 7 | location = "global" 8 | schema_validation_enabled = true 9 | response_export_values = ["*"] # ["id", "name", "properties.nameServers"] 10 | 11 | body = { 12 | 13 | properties = { 14 | dnsType = "AzureDns" 15 | dnsZoneId = var.dns_zone_id 16 | autoRenew = false 17 | privacy = false 18 | 19 | consent = { 20 | agreementKeys = ["DNRA"] 21 | agreedBy = var.agreedby_ip_v6 22 | agreedAt = var.agreedat_datetime 23 | } 24 | 25 | contactAdmin = { 26 | nameFirst = var.contact.nameFirst 27 | nameLast = var.contact.nameLast 28 | email = var.contact.email 29 | phone = var.contact.phone 30 | 31 | addressMailing = { 32 | address1 = var.contact.addressMailing.address1 33 | city = var.contact.addressMailing.city 34 | state = var.contact.addressMailing.state 35 | country = var.contact.addressMailing.country 36 | postalCode = var.contact.addressMailing.postalCode 37 | } 38 | } 39 | 40 | contactRegistrant = { 41 | nameFirst = var.contact.nameFirst 42 | nameLast = var.contact.nameLast 43 | email = var.contact.email 44 | phone = var.contact.phone 45 | 46 | addressMailing = { 47 | address1 = var.contact.addressMailing.address1 48 | city = var.contact.addressMailing.city 49 | state = var.contact.addressMailing.state 50 | country = var.contact.addressMailing.country 51 | postalCode = var.contact.addressMailing.postalCode 52 | } 53 | } 54 | 55 | contactBilling = { 56 | nameFirst = var.contact.nameFirst 57 | nameLast = var.contact.nameLast 58 | email = var.contact.email 59 | phone = var.contact.phone 60 | 61 | addressMailing = { 62 | address1 = var.contact.addressMailing.address1 63 | city = var.contact.addressMailing.city 64 | state = var.contact.addressMailing.state 65 | country = var.contact.addressMailing.country 66 | postalCode = var.contact.addressMailing.postalCode 67 | } 68 | } 69 | 70 | contactTech = { 71 | nameFirst = var.contact.nameFirst 72 | nameLast = var.contact.nameLast 73 | email = var.contact.email 74 | phone = var.contact.phone 75 | 76 | addressMailing = { 77 | address1 = var.contact.addressMailing.address1 78 | city = var.contact.addressMailing.city 79 | state = var.contact.addressMailing.state 80 | country = var.contact.addressMailing.country 81 | postalCode = var.contact.addressMailing.postalCode 82 | } 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /output.tf: -------------------------------------------------------------------------------- 1 | output "appservice_domain_name" { 2 | value = azapi_resource.appservice_domain.output.name 3 | } 4 | 5 | output "app_service_domain_id" { 6 | value = azapi_resource.appservice_domain.output.id 7 | } 8 | 9 | output "app_service_domain_name_servers" { 10 | value = azapi_resource.appservice_domain.output.properties.nameServers 11 | } 12 | -------------------------------------------------------------------------------- /providers.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | 3 | required_version = ">= 1.6" 4 | 5 | required_providers { 6 | azapi = { 7 | source = "Azure/azapi" 8 | version = ">= 2.2.0" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "custom_domain_name" { 2 | type = string 3 | validation { 4 | condition = length(var.custom_domain_name) > 0 && (endswith(var.custom_domain_name, ".com") || endswith(var.custom_domain_name, ".net") || endswith(var.custom_domain_name, ".co.uk") || endswith(var.custom_domain_name, ".org") || endswith(var.custom_domain_name, ".nl") || endswith(var.custom_domain_name, ".in") || endswith(var.custom_domain_name, ".biz") || endswith(var.custom_domain_name, ".org.uk") || endswith(var.custom_domain_name, ".co.in")) 5 | error_message = "Available top level domains are: com, net, co.uk, org, nl, in, biz, org.uk, and co.in" 6 | } 7 | } 8 | 9 | variable "dns_zone_id" { 10 | type = string 11 | } 12 | 13 | variable "resource_group_id" { 14 | type = string 15 | } 16 | 17 | variable "agreedby_ip_v6" { 18 | type = string 19 | default = "2a04:cec0:11d9:24c8:8898:3820:8631:d83" 20 | } 21 | 22 | variable "agreedat_datetime" { 23 | type = string 24 | default = "2024-01-01T9:00:00.000Z" 25 | } 26 | 27 | variable "contact" { 28 | type = object({ 29 | nameFirst = string 30 | nameLast = string 31 | email = string 32 | phone = string 33 | addressMailing = object({ 34 | address1 = string 35 | city = string 36 | state = string 37 | country = string 38 | postalCode = string 39 | }) 40 | }) 41 | } 42 | --------------------------------------------------------------------------------