Skip to content

Instantly share code, notes, and snippets.

@fdelu
Created November 13, 2023 00:59
Show Gist options
  • Save fdelu/25f4eee056633abc03dc87b4a7e7704b to your computer and use it in GitHub Desktop.
Save fdelu/25f4eee056633abc03dc87b4a7e7704b to your computer and use it in GitHub Desktop.
Managed certificate creation with terraform - Azure Container Apps

variables.tf

variable "env" {
  description = "Environment"
  default     = "dev"
}

variable "app_name" {
  description = "Application name"
}

variable "subdomain" {
  description = "Subdomain for the DNS record"
}

variable "dns_zone" {
  description = "DNS Zone name"
}

variable "dns_resource_group" {
  description = "DNS resource group"
}

variable "location" {
  description = "Location"
  default     = "Brazil South"
}

main.tf

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3.80.0"
    }
    azapi = {
      source = "azure/azapi"
    }
  }
  backend "remote" {}
}

provider "azurerm" {
  features {}
}

provider "azapi" {}

resource "azurerm_resource_group" "rg" {
  name     = "${upper(var.env)}-${upper(var.app_name)}-RG"
  location = var.location
}

resource "azurerm_log_analytics_workspace" "logs" {
  name                = "${upper(var.env)}-${upper(var.app_name)}-logs"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_container_app_environment" "app_env" {
  name                       = "${upper(var.env)}-${upper(var.app_name)}-CAE"
  location                   = azurerm_resource_group.rg.location
  resource_group_name        = azurerm_resource_group.rg.name
  log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id
}

resource "azurerm_container_app" "app" {
  name                         = "${lower(var.env)}-${lower(var.app_name)}-ca"
  container_app_environment_id = azurerm_container_app_environment.app_env.id
  resource_group_name          = azurerm_resource_group.rg.name
  revision_mode                = "Single"

  template {
    container {
      name   = "${var.env}-${var.app_name}-container"
      image  = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest"
      cpu    = 0.25
      memory = "0.5Gi"
    }
  }

  ingress {
    transport                  = "http"
    target_port                = 80
    external_enabled           = true
    allow_insecure_connections = false
    traffic_weight {
      percentage      = 100
      latest_revision = true
    }
  }

  lifecycle {
    ignore_changes = [ingress.0.custom_domain] // Required to not delete the custom domain created in dns.tf
  }
}

output "default_app_url" {
  value = azurerm_container_app.app.ingress[0].fqdn
}

output "app_url" {
  value = time_sleep.dns_propagation.triggers["url"]
}

dns.tf

data "azurerm_dns_zone" "dns" {
  name                = var.dns_zone
  resource_group_name = var.dns_resource_group
}

resource "azurerm_dns_cname_record" "cname_record" {
  name                = var.subdomain
  zone_name           = data.azurerm_dns_zone.dns.name
  resource_group_name = data.azurerm_dns_zone.dns.resource_group_name
  ttl                 = 3600
  record              = azurerm_container_app.app.ingress[0].fqdn
}

data "azapi_resource" "app_verification_id" {
  resource_id = azurerm_container_app_environment.app_env.id
  type        = "Microsoft.App/managedEnvironments@2023-05-01"

  response_export_values = ["properties.customDomainConfiguration.customDomainVerificationId"]
}

locals {
  verificationId = jsondecode(data.azapi_resource.app_verification_id.output).properties.customDomainConfiguration.customDomainVerificationId
}

resource "azurerm_dns_txt_record" "txt_record" {
  name                = "asuid.${var.subdomain}"
  zone_name           = data.azurerm_dns_zone.dns.name
  resource_group_name = data.azurerm_dns_zone.dns.resource_group_name
  ttl                 = 3600
  record {
    value = local.verificationId
  }
}


resource "time_sleep" "dns_propagation" {
  create_duration = "60s"

  depends_on = [azurerm_dns_txt_record.txt_record, azurerm_dns_cname_record.cname_record]

  triggers = {
    url            = "${azurerm_dns_cname_record.cname_record.name}.${data.azurerm_dns_zone.dns.name}",
    verificationId = local.verificationId,
    record         = azurerm_dns_cname_record.cname_record.record,
  }
}

// azurerm can't create a managed TLS certificate - see https://github.com/hashicorp/terraform-provider-azurerm/issues/21866
// The following resources are the workaround

resource "azapi_update_resource" "custom_domain" {
  type        = "Microsoft.App/containerApps@2023-05-01"
  resource_id = azurerm_container_app.app.id

  body = jsonencode({
    properties = {
      ingress = {
        customDomains = [
          {
            bindingType = "Disabled",
            name        = time_sleep.dns_propagation.triggers["url"],
          }
        ]
      }
    }
  })
}

resource "azapi_resource" "managed_certificate" {
  depends_on = [time_sleep.dns_propagation, azapi_update_resource.custom_domain]
  type       = "Microsoft.App/ManagedEnvironments/managedCertificates@2023-05-01"
  name       = "${lower(var.env)}-${lower(var.app_name)}-cert"
  parent_id  = azurerm_container_app_environment.app_env.id
  location   = azurerm_resource_group.rg.location

  body = jsonencode({
    properties = {
      subjectName             = time_sleep.dns_propagation.triggers["url"]
      domainControlValidation = "CNAME"
    }
  })

  response_export_values = ["*"]
}

resource "azapi_update_resource" "custom_domain_binding" {
  type        = "Microsoft.App/containerApps@2023-05-01"
  resource_id = azurerm_container_app.app.id

  body = jsonencode({
    properties = {
      ingress = {
        customDomains = [
          {
            bindingType   = "SniEnabled",
            name          = time_sleep.dns_propagation.triggers["url"],
            certificateId = jsondecode(azapi_resource.managed_certificate.output).id
          }
        ]
      }
    }
  })
}
@kneumann-BB
Copy link

This was super helpful!
I did have to slightly modify to get this to work for me. It was silently failing and not updating the customDomains on either call. I figured out there was a missing "configuration" layer. https://learn.microsoft.com/en-us/rest/api/containerapps/container-apps/update?view=rest-containerapps-2023-05-01&tabs=HTTP#configuration

    properties = {
      configuration = {   # <--- need this
        ingress = {
          customDomains = [
            {
              bindingType = "Disabled",
              name        = time_sleep.dns_propagation.triggers["url"],
            }
          ]
        }
      }
    }

@naveedausaf
Copy link

naveedausaf commented Jul 1, 2025

This is a superb Gist. Thank you so much for sharing it!

@kneumann-BB you are right, that configuration block to surround ingress is needed. That is what made my configuration work. Thank you.

I had to make a few changes to get past issues during apply, updates and destroy:

  1. jsonencode and jsonencode are no longer necessary (don't work in fact) with body argument in the latest version of the azapi provider at the date of this posting.

  2. Add this resource to dns.tf:

    resource "azapi_resource_action" "custom_domain_binding_destroy" {
      type = "Microsoft.App/containerApps@2023-05-01"
    
      # following line also makes this resource a dependent 
      # on the custom_domain_binding resource
      resource_id = azapi_update_resource.custom_domain_binding.resource_id
    
      # default for this resource is POST
      # unlike for the azapi_update_resource
      # resource for which it seems to be PATCH
      # from my Fiddler investigations
      method = "PATCH"
    
      response_export_values = ["*"]
    
      body = {
        properties = {
          configuration = {
            ingress = {
              customDomains = []
            }
          }
        }
      }
    
      # The all-important `when` argument, that makes this resource 
      # act like a destroy provisioner counterpart to the 
      # azapi_resource_action.custom_domain_binding resource's
      # create provisioner behaviour for the ACA app resource
      # (azapi_update_resource resource only runs on create whereas
      # the present resource can run on either create or destroy; we
      # are running it on destroy).
      when = "destroy"
    }
    
  3. Replace:

    resource "azapi_update_resource" "custom_domain" {
    

    with

    resource "azapi_resource_action" "custom_domain" {
        # default for this resource is POST, not PATCH
        # as it is for azapi_update_resource
        method      = "PATCH" 
        
        # eveything else in the resource config is the same as 
        # for azapi_update_resource.custom_domain
    
  4. Remove the whole lifecycle block from resource "azurerm_container_app" "app"

EXPLANATION OF CHANGES:

  1. I think this must be due to an update to the azapi provider

  2. As it is, terraform destroy fails because managed certificate cannot be destroyed as the custom_domain in ingress is still holding a reference to it. To get around it, I added a azapi_resource_action resource from AzAPI provider.

    Like azapi_update_resource, this is a one time API call but unlike azapi_update_resource, we can choose whether the action would run on create or destroy. I added an instance that runs on destroy, but is dependent on azapi_update_resource so that it definitely runs - and removes the custom_domain binding - before destroy of the managed certificate on terraform destroy.

    This is the bit to add:

    resource "azapi_resource_action" "custom_domain_binding_destroy" {
      type = "Microsoft.App/containerApps@2023-05-01"
    
      # following line also makes this resource a dependent 
      # on the custom_domain_binding resource
      resource_id = azapi_update_resource.custom_domain_binding.resource_id
    
      # default for this resource is POST
      # unlike for the azapi_update_resource
      # resource for which it seems to be PATCH
      # from my Fiddler investigations
      method = "PATCH"
    
      response_export_values = ["*"]
    
      body = {
        properties = {
          configuration = {
            ingress = {
              customDomains = []
            }
          }
        }
      }
    
      # The all-important `when` argument, that makes this resource 
      # act like a destroy provisioner counterpart to the 
      # azapi_resource_action.custom_domain_binding resource's
      # create provisioner behaviour for the ACA app resource
      # (azapi_update_resource resource only runs on create whereas
      # the present resource can run on either create or destroy; we
      # are running it on destroy).
      when = "destroy"
    }
    
  3. There is a problem with updates computed during the plan stage if you run terraform apply again (e.g. because there an update to some other resource in the Terraform configuration needs to be made). Terraform computes drift from the first of the two azapi_update_resource resources, the one that creates a custom domain binding with a bindingType of Disabled. Terraform want to set it back to Disabled even though it was set by the second azapi_update_resource to SniEnabled with an actual certificaateId value from the managed cert that had been created.

    The plan looks like this:

    Terraform used the selected providers to generate the following execution plan. Resource 
    actions are indicated with the following symbols:
      + create
      ~ update in-place
    
    Terraform will perform the following actions:
    
      # azapi_update_resource.custom_domain will be updated in-place
      ~ resource "azapi_update_resource" "custom_domain" {
          ~ body                    = {
              ~ properties = {
                  ~ configuration = {
                      ~ ingress = {
                          ~ customDomains = [
                              ~ {
                                  ~ bindingType = "SniEnabled" -> "Disabled"
                                    name        = "api.efast.uk"
                                },
                            ]
                        }
                    }
                }
            }
    

    To get around this issue, and allow subsequent updates to the configuration via terraform apply to happen correctly, replace:

    resource "azapi_update_resource" "custom_domain" {
    

    with

    resource "azapi_resource_action" "custom_domain" {
        # default for this resource is POST, not PATCH
        # as it is for azapi_update_resource
        method      = "PATCH" 
        
        # eveything else in the resource config is the same as 
        # for azapi_update_resource.custom_domain
    
  4. This warning is shown on terraform apply:

    Warning: Redundant ignore_changes element
    │
    │   on azure.tf line 57, in resource "azurerm_container_app" "app":
    │   57: resource "azurerm_container_app" "app" {
    │
    │ Adding an attribute name to ignore_changes tells Terraform to ignore future changes to 
    │ the argument in configuration after the object has been created, retaining the value   
    │ originally configured.
    │
    │ The attribute ingress[0].custom_domain is decided by the provider alone and therefore  
    │ there can be no configured value to compare with. Including this attribute in
    │ ignore_changes has no effect. Remove the attribute from ignore_changes to quiet this   
    │ warning.
    

    I think this is because Terraform can't control whether or not to include ingress[0].custom_domain and the provider decides this. The provider I think also does not include it in its drift calculation. If I remove lifecycle.ignore_changes from resource "azurerm_container_app" "app", subsequent tarraform applys, where I have changed other thing in the config, do not include any changes to this resource in the plan, even though there has definitely been drift on ingress[0].custom_domain because subsequent azapi resources add custom domain binding to the app then modify that binding.

    Fix: remove the whole lifecycle block from resource "azurerm_container_app" "app"

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