Secure a custom domain with a managed certificate
The default *.azurewebsites.net hostname on Azure App Service is already
protected by a platform certificate. A production hostname that you own needs
its own DNS mapping and certificate. In this lab, you map a real public
subdomain, issue a free App Service managed certificate, bind it with
Server Name Indication (SNI), and require modern HTTPS.
You must own or control a public DNS domain to complete this lab. DNS ownership cannot be simulated, and a made-up hostname cannot receive a publicly trusted certificate.
Estimated time: 35 to 60 minutes, plus DNS propagation and certificate issuance time.
Objectives
By the end of this lab, you will be able to:
- Choose between a free managed certificate and bring-your-own-certificate options.
- Map a public custom hostname to an App Service app without weakening domain ownership validation.
- Create and bind an App Service managed certificate.
- Enable HTTPS-only and require TLS 1.2 or later for the app and SCM endpoint.
- Verify DNS, HTTP redirect behavior, the certificate, and the App Service configuration.
Prerequisites
Before you begin, you will need an Azure subscription with Owner permissions and a GitHub account.
In addition, you will need the following tools installed on your local machine:
- Visual Studio Code with the following extensions:
- Azure CLI
- GitHub CLI
- Git
- Azure Developer CLI (azd) (for the azd provisioning path)
- A public DNS domain that you own or administer with permission to create CNAME, A, and TXT records
- A DNS lookup tool (dig, nslookup, or Resolve-DnsName)
- POSIX-compliant shell (bash, zsh, Azure Cloud Shell)
Setup Azure CLI
Start by logging into Azure by run the following command and follow the prompts:
az login --use-device-code
You can log into a different tenant by passing in the --tenant flag to specify your tenant domain or tenant ID.
Do not continue with a production domain unless you are authorized to change
its DNS. This lab uses a temporary subdomain such as
asl-tls.example.com, not the root domain or an active production hostname.
Custom TLS bindings require Basic B1 or higher. This lab uses a B1 plan, about USD 13/month if left running. The managed certificate is free, but your domain registration and DNS provider can have separate costs. Delete the test resources and DNS records when you finish.
How DNS validation and TLS fit together
The DNS record sends clients to App Service. The asuid TXT record proves that
your subscription controls the target app and helps prevent a dangling DNS
record from being claimed by another app. After App Service accepts the
hostname, it asks a public certificate authority to validate the domain and
issue the managed certificate.
The mapping and certificate behavior are the same for Windows and Linux App Service plans. Runtime language does not affect the certificate.
Choose the certificate type first
| Requirement | Best fit |
|---|---|
| One public root domain or subdomain, basic server TLS, automatic renewal | App Service managed certificate used in this lab |
Wildcard hostname such as *.example.com | App Service certificate or another public certificate |
| Export the certificate or use the private key outside App Service | Bring your own certificate (BYOC) |
| Control certificate authority, key lifecycle, or certificate policy | BYOC, commonly stored in Azure Key Vault |
| Use the certificate as a client certificate | BYOC |
| Private DNS in multitenant App Service | A public managed certificate is not suitable |
Managed certificates are not exportable, do not support wildcards, and can change issuer and key material during renewal. Do not pin clients to their certificate or issuer. Automatic renewal depends on keeping the supported DNS mapping and hostname binding in place.
Optional concept: Key Vault and BYOC
For BYOC, store a password-protected PFX certificate in Azure Key Vault and import it into App Service. Grant only the App Service resource provider access required for the import, and let App Service synchronize later certificate versions. BYOC changes certificate procurement, rotation, and incident-response responsibilities, so it is outside this executable lab. Never commit the PFX password or certificate private key.
Provision resources
Create or reuse a web app on a B1 or higher App Service plan. Choose one path. The azd template creates and owns its resource group. The Azure CLI and portal paths use the shared lab resource group.
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
Use the repository sample to provision a Linux B1 plan and deploy a web app:
git clone https://github.com/Azure-Samples/app-service-labs.git
cd app-service-labs/samples/zava-widgets
SUFFIX=$(openssl rand -hex 3)
azd auth login
azd env new "asl-tls-${SUFFIX}" --location eastus
azd up
Read the generated app and resource group names:
export RG_NAME=$(azd env get-value RESOURCE_GROUP_NAME)
export APP_NAME=$(azd env get-value WEB_APP_NAME)
azd provisions the app, but it does not own records at an arbitrary external
DNS provider. The domain-validation and certificate operations below therefore
use the App Service management commands after azd up. Keep the azd
environment so azd down can remove the Azure resources later.
Setup Resource Group
In this workshop, we will set environment variables for the resource group name and location.
The following commands will set the environment variables for your current terminal session. If you close the current terminal session, you will need to set the environment variables again.
To keep the resource names unique, we will use a random number as a suffix for the resource names. This will also help you to avoid naming conflicts with other resources in your Azure subscription.
Run the following command to generate a random number.
RAND=$RANDOM
export RAND
echo "Random resource identifier will be: ${RAND}"
Set the location to a region of your choice. For example, eastus or westeurope.
export LOCATION=eastus
Create a resource group name using the random number.
export RG_NAME=myresourcegroup$RAND
You can list the regions that support availability zones with the following command:
az account list-locations \
--query "[?metadata.regionType=='Physical' && metadata.supportsAvailabilityZones==true].{Region:name}" \
--output table
Run the following command to create a resource group using the environment variables you just created.
az group create \
--name ${RG_NAME} \
--location ${LOCATION}
Setup Resources
This workshop uses a few supporting Azure resources alongside your App Service app. Depending on the lab, these may include:
- Azure App Service plan that defines the compute for your app
- Azure Container Registry for storing container images
- Azure Key Vault for secrets management
- Azure User-Assigned Managed Identity for accessing Azure services securely
- Azure Monitor / Application Insights for observability
Each lab will provide the exact commands needed to provision the resources it depends on.
Create a Linux B1 plan and web app:
export PLAN_NAME=plan-asl-tls-$RAND
export APP_NAME=app-asl-tls-$RAND
az appservice plan create \
--name "$PLAN_NAME" \
--resource-group "$RG_NAME" \
--sku B1 \
--is-linux
az webapp create \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--plan "$PLAN_NAME" \
--runtime "NODE:22-lts"
The platform welcome page is enough for this TLS-focused lab. If you prefer to deploy code, complete Deploy your first web app before continuing.
Setup Resource Group
In this workshop, we will set environment variables for the resource group name and location.
The following commands will set the environment variables for your current terminal session. If you close the current terminal session, you will need to set the environment variables again.
To keep the resource names unique, we will use a random number as a suffix for the resource names. This will also help you to avoid naming conflicts with other resources in your Azure subscription.
Run the following command to generate a random number.
RAND=$RANDOM
export RAND
echo "Random resource identifier will be: ${RAND}"
Set the location to a region of your choice. For example, eastus or westeurope.
export LOCATION=eastus
Create a resource group name using the random number.
export RG_NAME=myresourcegroup$RAND
You can list the regions that support availability zones with the following command:
az account list-locations \
--query "[?metadata.regionType=='Physical' && metadata.supportsAvailabilityZones==true].{Region:name}" \
--output table
Run the following command to create a resource group using the environment variables you just created.
az group create \
--name ${RG_NAME} \
--location ${LOCATION}
Setup Resources
This workshop uses a few supporting Azure resources alongside your App Service app. Depending on the lab, these may include:
- Azure App Service plan that defines the compute for your app
- Azure Container Registry for storing container images
- Azure Key Vault for secrets management
- Azure User-Assigned Managed Identity for accessing Azure services securely
- Azure Monitor / Application Insights for observability
Each lab will provide the exact commands needed to provision the resources it depends on.
-
In the Azure portal, create Web App.
-
Put it in the resource group created above.
-
Choose Code, either Linux or Windows, and a supported runtime.
-
Create or select a Basic B1 App Service plan.
-
On Monitoring, do not add extra services for this lab.
-
Select Review + create, then Create.
-
When deployment completes, open the resource and record the app name in your terminal:
export APP_NAME="<your-app-name>"
Confirm the default HTTPS endpoint responds before changing DNS:
DEFAULT_HOSTNAME=$(az webapp show \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--query defaultHostName -o tsv)
curl --fail --silent --show-error \
"https://${DEFAULT_HOSTNAME}/" \
--output /dev/null \
--write-out "HTTP %{http_code}\n"
Expected output:
HTTP 200
Step 1: Choose a temporary hostname
Prefer a subdomain because a CNAME follows the stable App Service hostname and does not depend on an inbound IP address. Set the real hostname that you control:
export CUSTOM_HOSTNAME=asl-tls.example.com
Replace example.com. Do not type the https:// scheme.
Read the target hostname and ownership verification ID:
DEFAULT_HOSTNAME=$(az webapp show \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--query defaultHostName -o tsv)
VERIFICATION_ID=$(az webapp show \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--query customDomainVerificationId -o tsv)
printf "CNAME target: %s\nTXT value: %s\n" \
"$DEFAULT_HOSTNAME" "$VERIFICATION_ID"
Step 2: Create the public DNS records
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
Create these records at the authoritative DNS provider for your domain:
| Type | Name for asl-tls.example.com | Value |
|---|---|---|
| CNAME | asl-tls | the value in $DEFAULT_HOSTNAME |
| TXT | asuid.asl-tls | the value in $VERIFICATION_ID |
The CNAME must point directly to the app's *.azurewebsites.net hostname for
this lab. An intermediate CNAME, proxy, or traffic service can prevent managed
certificate issuance.
If your authoritative zone is in Azure DNS, set its resource group and create the records:
export DNS_RESOURCE_GROUP="<dns-resource-group>"
export DNS_ZONE=example.com
export DNS_RECORD=asl-tls
az network dns record-set cname set-record \
--resource-group "$DNS_RESOURCE_GROUP" \
--zone-name "$DNS_ZONE" \
--record-set-name "$DNS_RECORD" \
--cname "$DEFAULT_HOSTNAME"
az network dns record-set txt add-record \
--resource-group "$DNS_RESOURCE_GROUP" \
--zone-name "$DNS_ZONE" \
--record-set-name "asuid.${DNS_RECORD}" \
--value "$VERIFICATION_ID"
For another DNS provider, create the equivalent CNAME and TXT records in that provider's management interface.
- In your web app, select Settings > Custom domains.
- Select Add custom domain.
- Select All other domain services and App Service Managed Certificate.
- Enter the fully qualified hostname, but do not select Validate yet.
- Copy the CNAME and TXT record names and values shown under Domain validation.
- At your authoritative DNS provider, create both records and save the change.
Wait for public DNS to return both records. Use one of these commands:
- Linux or macOS
- Windows
dig +short "$CUSTOM_HOSTNAME" CNAME
dig +short "asuid.${CUSTOM_HOSTNAME}" TXT
Resolve-DnsName $env:CUSTOM_HOSTNAME -Type CNAME
Resolve-DnsName "asuid.$env:CUSTOM_HOSTNAME" -Type TXT
The CNAME result must end in azurewebsites.net, and the TXT result must match
the app's verification ID. DNS propagation can take minutes or hours according
to your provider's time-to-live (TTL).
For a root such as example.com, use an A record that points to the app's
inbound IP address and a TXT record named asuid. Root records are more
sensitive to IP-address changes. The executable path in this lab intentionally
uses a subdomain and CNAME.
Step 3: Add the custom hostname
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
Because the hostname depends on external DNS, add it after azd up:
az webapp config hostname add \
--webapp-name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME"
az webapp config hostname add \
--webapp-name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME"
Return to the Add custom domain pane and select Validate. Both DNS checks must show success. Select Add.
Step 4: Create and bind the managed certificate
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
The App Service managed-certificate CLI command is currently in preview. Certificate issuance is asynchronous, so request the certificate, wait for Azure to return its thumbprint, and then bind it to the hostname:
az webapp config ssl create \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME" \
--output none
CERT_THUMBPRINT=""
for attempt in {1..30}; do
CERT_THUMBPRINT=$(az webapp config ssl list \
--resource-group "$RG_NAME" \
--query "[?contains(hostNames, '$CUSTOM_HOSTNAME')].thumbprint | [0]" \
--output tsv)
if [[ -n "$CERT_THUMBPRINT" ]]; then
break
fi
echo "Waiting for certificate issuance ($attempt/30)..."
sleep 10
done
if [[ -z "$CERT_THUMBPRINT" ]]; then
echo "Certificate issuance did not finish within 5 minutes." >&2
exit 1
fi
az webapp config ssl bind \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME" \
--certificate-thumbprint "$CERT_THUMBPRINT" \
--ssl-type SNI
The App Service managed-certificate CLI command is currently in preview. Certificate issuance is asynchronous, so request the certificate, wait for Azure to return its thumbprint, and then bind it:
az webapp config ssl create \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME" \
--output none
CERT_THUMBPRINT=""
for attempt in {1..30}; do
CERT_THUMBPRINT=$(az webapp config ssl list \
--resource-group "$RG_NAME" \
--query "[?contains(hostNames, '$CUSTOM_HOSTNAME')].thumbprint | [0]" \
--output tsv)
if [[ -n "$CERT_THUMBPRINT" ]]; then
break
fi
echo "Waiting for certificate issuance ($attempt/30)..."
sleep 10
done
if [[ -z "$CERT_THUMBPRINT" ]]; then
echo "Certificate issuance did not finish within 5 minutes." >&2
exit 1
fi
az webapp config ssl bind \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--hostname "$CUSTOM_HOSTNAME" \
--certificate-thumbprint "$CERT_THUMBPRINT" \
--ssl-type SNI
The combined Add custom domain flow requests and binds the managed certificate. On Custom domains, wait until the hostname changes from No binding to Secured. Certificate issuance can take several minutes.
If you added the hostname without a certificate, select Certificates > Managed certificates > Add certificate, choose the hostname, and select Validate. After issuance, return to Custom domains, select Add binding, choose the certificate, and use SNI SSL.
Step 5: Enforce HTTPS and modern TLS
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
Apply the secure settings to the app. The sample already declares HTTPS-only and app TLS 1.2 in Bicep; this command also sets SCM TLS 1.2 explicitly:
az webapp update \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--set httpsOnly=true
az webapp config set \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--min-tls-version 1.2 \
--generic-configurations '{"scmMinTlsVersion":"1.2"}'
az webapp update \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--set httpsOnly=true
az webapp config set \
--name "$APP_NAME" \
--resource-group "$RG_NAME" \
--min-tls-version 1.2 \
--generic-configurations '{"scmMinTlsVersion":"1.2"}'
- In the web app, select Settings > Configuration > General settings.
- Set HTTPS Only to On.
- Set Minimum inbound TLS version to 1.2 or a higher version supported by all clients.
- Set SCM minimum TLS version to 1.2 or higher.
- Select Save.
Verify
Verify the mapping from outside Azure, not only in the portal.
-
Confirm HTTP redirects to HTTPS:
curl --head "http://${CUSTOM_HOSTNAME}/"Expect a
301,302,307, or308response with an HTTPSLocation. -
Confirm HTTPS succeeds and the app responds:
curl --fail --silent --show-error \"https://${CUSTOM_HOSTNAME}/" \--output /dev/null \--write-out "HTTP %{http_code}\n"Expected output:
HTTP 200 -
Inspect the presented certificate:
echo | openssl s_client \-connect "${CUSTOM_HOSTNAME}:443" \-servername "$CUSTOM_HOSTNAME" 2>/dev/null \| openssl x509 -noout -subject -issuer -datesConfirm the subject covers your hostname, the issuer is publicly trusted, and the validity dates include today.
-
Confirm the App Service binding and security settings:
az webapp config hostname list \--webapp-name "$APP_NAME" \--resource-group "$RG_NAME" \--query "[?name=='$CUSTOM_HOSTNAME'].{hostname:name,sslState:sslState,thumbprint:thumbprint}" \-o tableaz webapp show \--name "$APP_NAME" \--resource-group "$RG_NAME" \--query "{httpsOnly:httpsOnly}" \-o tableaz webapp config show \--name "$APP_NAME" \--resource-group "$RG_NAME" \--query "{minTls:minTlsVersion,scmMinTls:scmMinTlsVersion}" \-o tableExpect
SniEnabled, a nonempty thumbprint,httpsOnlyset totrue, and both TLS values set to1.2or higher.
Delete the CNAME and asuid TXT records from your DNS provider first, then
wait until a public DNS lookup no longer returns them. Deleting the app while
its CNAME still targets azurewebsites.net creates a dangling DNS window.
- Azure Developer CLI (azd)
- Azure CLI (az)
- Azure portal
Cleanup
Run this from samples/zava-widgets to delete the resources owned by the azd
environment:
azd down --purge --force
Cleanup
To clean up the resources created in this lab, run the following command to delete the resource group. If you want to use the resources again, you can skip this step.
az group delete \
--name ${RG_NAME} \
--yes \
--no-wait
This will delete the resource group and all its contents.
Cleanup
To clean up the resources created in this lab, run the following command to delete the resource group. If you want to use the resources again, you can skip this step.
az group delete \
--name ${RG_NAME} \
--yes \
--no-wait
This will delete the resource group and all its contents.
Wait for deletion, then verify the resource group is gone:
while [ "$(az group exists --name "$RG_NAME")" = "true" ]; do
sleep 10
done
az group exists --name "$RG_NAME"
Expected output:
false
Summary
You mapped a real public subdomain to App Service, proved domain ownership with
an asuid TXT record, created and bound a free managed certificate, and
enforced HTTPS with TLS 1.2 or later. You also learned when a managed
certificate is not enough and a Key Vault-backed BYOC design is more suitable.
Troubleshooting
- Hostname validation fails. Query the public authoritative DNS records.
The CNAME must resolve directly to the app's default hostname, and the
asuidTXT value must matchcustomDomainVerificationId. Remove surrounding quotation marks added as literal TXT content. - Certificate stays pending or creation fails. Confirm the hostname is
already mapped, the public CNAME still points directly to App Service, and no
Certification Authority Authorization (CAA) record blocks DigiCert. Some
domains need a CAA record that permits
digicert.com. - A proxy or content delivery network hides the App Service hostname. Complete certificate issuance with the supported direct DNS mapping first, or terminate TLS at the proxy with a certificate managed there.
- HTTPS returns the wrong certificate. Make the request with the custom hostname, not the app's IP address. SNI uses the hostname to choose the certificate.
- The page returns 404 after DNS changed. Flush the local DNS cache or try a resolver that has the new record. Verify that the hostname appears in the web app's custom domains list.
- A root domain does not accept CNAME. Use the app's inbound IP for an A
record and use
asuidfor the ownership TXT record, or use a DNS provider that supports standards-compliant alias records at the zone apex.