Skip to content

Instantly share code, notes, and snippets.

@LynnAU
Last active July 24, 2024 14:45
Show Gist options
  • Save LynnAU/131426847d2793c76e36548f9937f966 to your computer and use it in GitHub Desktop.
Save LynnAU/131426847d2793c76e36548f9937f966 to your computer and use it in GitHub Desktop.
Azure Container App - Provisioning Managed certificates for custom domains

Azure Container App - Custom Domain Managed Certficate scripts

This gist contains a create and destroy script to provision and cleanup custom domains and managed certficates assigned to Azure Container Apps.

This gist supports the following workflow:

  • Bind custom domain to existing Container App
  • Provision a managed certificate for the custom domain
  • Bind the managed certificate to the custom domain

Please note, the scripts were created with AZCLI version 2.53.0 in mind. If the scripts are failing it may be because of your version of AZCLI, please check and modify the script or your version if you encounter problems

Extra bits

The create script has the following checks throughout its execution to prevent failing on edge cases:

  • Makes sure an asuid TXT record exists before binding the custom domain
  • Checks if there's an already provisioned, managed certificate for the domain
  • Lastly checks if the custom domain already has a managed certificate bound

I've also included a terraform module to execute the scripts in the gist and handle some sense of state for the resource.

Environment variables required

These variables are used across both create and destroy scripts

  • CUSTOM_DOMAIN - The custom domain that is being/has been assigned to the container app
  • CONTAINER_APP_NAME - The name of the container app the custom domain is being/has been assigned to
  • RESOURCE_GROUP - The resource group name the container app has been created in
  • CONTAINER_APP_ENV_NAME - The name of the container app environment name the container app has been assigned to

Changes/Revisions

V2

The create script has been updated to include further checks to increase confidence of successful script execution. These checks include using dig to check for a valid asuid TXT record, and instead of waiting for 5 minutes and hoping Azure has provisioned the cert, query the provisioning state of the cert every 15 seconds in a loop to check for success status.

A destroy script has been added to cleanup provisioned managed certificates assigned to the container app and unbind the hostname in the process. This script could use some fleshing out, usually this gets executed when destroying the container app (which might not be necessary) or changing custom domains.

Included are terraform files using the null_resource and local-exec provisioners to execute the script and provide some sort of runtime state to terraform managing the certificate resources in azure. The triggers on the resource help control the state to determine when to delete/create the resource based on the container app.

V1

Initial creation of the gist with a create script to provision a managed certificate for a custom domain assigned to an Azure Container App.

# env variables used throughout this script:
# CUSTOM_DOMAIN
# CONTAINER_APP_NAME
# RESOURCE_GROUP
# CONTAINER_APP_ENV_NAME
# functions below taken from: https://stackoverflow.com/a/25515370
yell() { echo "$0: $*" >&2; }
die() {
yell "$*"
exit 111
}
# use dig to verify the asuid txt record exists on the DNS host
# azure requires this to exist prior to adding the domain
# azure's dns can also be slow, so best to check propagation
tries=0
until [ "$tries" -ge 12 ]; do
[[ ! -z $(dig @8.8.8.8 txt asuid.$CUSTOM_DOMAIN +short) ]] && break
tries=$((tries + 1))
sleep 10
done
if [ "$tries" -ge 12 ]; then
die "'asuid.${CUSTOM_DOMAIN}' txt record does not exist"
fi
echo "took $tries trie(s) for the dns record to exist publically"
# check if the hostname already exists on the container app
# if not, add it since it's required to provision a managed cert
DOES_CUSTOM_DOMAIN_EXIST=$(
az containerapp hostname list \
-n $CONTAINER_APP_NAME \
-g $RESOURCE_GROUP \
--query "[?name=='$CUSTOM_DOMAIN'].name" \
--output tsv
)
if [ -z "${DOES_CUSTOM_DOMAIN_EXIST}" ]; then
echo "adding custom hostname to container app first since it does not exist yet"
az containerapp hostname add \
-n $CONTAINER_APP_NAME \
-g $RESOURCE_GROUP \
--hostname $CUSTOM_DOMAIN \
--output none
fi
# check if a managed cert for the domain already exists
# if it does not exist, provision one
# if it does, save its name to use for binding it later
MANAGED_CERTIFICATE_NAME=$(
az containerapp env certificate list \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_ENV_NAME \
--managed-certificates-only \
--query "[?properties.subjectName=='$CUSTOM_DOMAIN'].name" \
--output tsv
)
if [ -z "${MANAGED_CERTIFICATE_NAME}" ]; then
MANAGED_CERTIFICATE_NAME=$(
az containerapp env certificate create \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_ENV_NAME \
--hostname $CUSTOM_DOMAIN \
--validation-method CNAME \
--query "name" \
--output tsv
)
echo "created cert for '$CUSTOM_DOMAIN'. waiting for it to provision now..."
# poll azcli to check for the certificate status
# this is better than waiting 5 minutes, because it could be
# faster and we get to exit the script faster
# ---
# the default 20 tries means it'll check for 5 mins
# at 15 second intervals
tries=0
until [ "$tries" -ge 20 ]; do
STATE=$(
az containerapp env certificate list \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_ENV_NAME \
--managed-certificates-only \
--query "[?properties.subjectName=='$CUSTOM_DOMAIN'].properties.provisioningState" \
--output tsv
)
[[ $STATE == "Succeeded" ]] && break
tries=$((tries + 1))
sleep 15
done
if [ "$tries" -ge 20 ]; then
die "waited for 5 minutes, checked the certificate status 20 times and its not done. check azure portal..."
fi
else
echo "found existing cert in the env. proceeding to use that"
fi
# check if the cert has already been bound
# if not, bind it then
IS_CERT_ALREADY_BOUND=$(
az containerapp hostname list \
-n $CONTAINER_APP_NAME \
-g $RESOURCE_GROUP \
--query "[?name=='$CUSTOM_DOMAIN'].bindingType" \
--output tsv
)
if [ $IS_CERT_ALREADY_BOUND = "SniEnabled" ]; then
echo "cert is already bound, exiting..."
else
# try bind the cert to the container app
echo "cert successfully provisioned. binding the cert id to the hostname"
az containerapp hostname bind \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_NAME \
--hostname $CUSTOM_DOMAIN \
--environment $CONTAINER_APP_ENV_NAME \
--certificate $MANAGED_CERTIFICATE_NAME \
--output none
echo "finished binding. the domain is now secured and ready to use"
fi
# functions below taken from: https://stackoverflow.com/a/25515370
yell() { echo "$0: $*" >&2; }
die() {
yell "$*"
exit 111
}
# get the managed cert using the custom domain
CERTIFICATE_ID=$(
az containerapp env certificate list \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_ENV_NAME \
--managed-certificates-only \
--query "[?properties.subjectName=='$CUSTOM_DOMAIN'].id" \
--output tsv
)
# destroy the cert
az containerapp env certificate delete \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_ENV_NAME \
--certificate $CERTIFICATE_ID --yes
echo "destroyed the managed certificate"
# remove the custom domain from the container app
az containerapp hostname delete --hostname $CUSTOM_DOMAIN \
-g $RESOURCE_GROUP \
-n $CONTAINER_APP_NAME
echo "removed the custom domain from the container app"
terraform {}
resource "null_resource" "null" {
for_each = { for svc in var.services : svc.key => svc }
lifecycle {
create_before_destroy = false
}
triggers = {
rg_name = var.resource_group_name
ca_env_name = var.container_app_env_name
custom_domain = each.value.custom_domain
ca_name = each.value.container_app_name
}
# provision a managed cert and apply it to the container app
provisioner "local-exec" {
when = create
command = "sh ${path.module}/scripts/create.sh"
environment = {
RESOURCE_GROUP = self.triggers.rg_name
CONTAINER_APP_ENV_NAME = self.triggers.ca_env_name
CUSTOM_DOMAIN = self.triggers.custom_domain
CONTAINER_APP_NAME = self.triggers.ca_name
}
}
provisioner "local-exec" {
when = destroy
command = "sh ${path.module}/scripts/destroy.sh"
environment = {
RESOURCE_GROUP = self.triggers.rg_name
CONTAINER_APP_ENV_NAME = self.triggers.ca_env_name
CUSTOM_DOMAIN = self.triggers.custom_domain
CONTAINER_APP_NAME = self.triggers.ca_name
}
}
}
variable "resource_group_name" {
description = "name of the resource group"
type = string
}
variable "container_app_env_name" {
description = "name of the container app environment name"
type = string
}
variable "services" {
type = list(object({
key = string
custom_domain = string
container_app_name = string
}))
}
@1TT-Chris
Copy link

Great script, thanks @LynnAU

I found line 36 fails because the Azure CLI only looks in the certificates namespace. See Azure/azure-cli#29119

I had to change lines 17 and 19 to capture the resource ID rather than the name, and that resolved it for me.

@RiccardoBarbieri
Copy link

I am using this script for my own projects, thank you very much, a real life saver!
If I can contribute I would suggest using the resource ID to identify the certificate in the create.sh script instead of its name, in my application it gave issues, particularly the bind command reported the certificate as non existent in the environment (checked the status on azure portal and with CLI and it was provisioned successfully).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment