Skip to content

Azure Gateway Module

This OpenTofu module provisions the reverse proxy entrypoint for a Kastoria environment into an existing Azure Container App Environment. It routes public traffic to the environment's container apps over their internal FQDNs, and can optionally be fronted by a Cloudflare Tunnel and/or be paired with an mTLS proxy for client-certificate-authenticated callers.

Features

  • Single entrypoint: A reverse proxy container app with a public or internal ingress on port 8080, scaling 1–5 replicas at 0.25 CPU / 0.5Gi.
  • Declarative routing: Each upstream_services entry becomes an {NAME}_ADDRESS env var resolving to the service's internal FQDN, so no proxy config file is managed here.
  • Cloudflare Tunnel (optional): Provisions the tunnel, its ingress rules, proxied CNAME records, and a cloudflared connector container app — removing the need for a public proxy ingress.
  • mTLS proxy (optional): A second proxy app with a platform-enforced client certificate mode and an optional IPv4 allowlist on its ingress.
  • Passwordless everything: Images are pulled with a managed identity, and Key Vault secrets reach the containers as identity-backed secret blocks rather than as configuration values.
  • Nothing sensitive in the repo: The mTLS caller CA and its password, and the tunnel token, are all read from or written to Key Vault.
  • Independent toggles: proxy_enabled, cloudflared_enabled, and mtls_ca/mtls_proxy_image each gate their own resources, so a tunnel-less or proxy-less deployment leaves nothing behind in state.
  • FinOps Ready: app- prefixed naming and a submodule = "Gateway" tag on every Azure resource (Cloudflare resources carry no tags).

Requirements

Name Version
azuread ~> 3.0
azurerm ~> 4.0
cloudflare ~> 5.0

Providers

Name Version
azurerm 4.76.0
cloudflare 5.19.1

Modules

No modules.

Resources

Name Type
azurerm_container_app.cloudflared resource
azurerm_container_app.kastoria_mtls_proxy resource
azurerm_container_app.kastoria_proxy resource
azurerm_container_app_environment_certificate.mtls_ca resource
azurerm_key_vault_secret.cf_tunnel_token resource
cloudflare_dns_record.cf_cstore_tunnel resource
cloudflare_dns_record.cf_tunnel resource
cloudflare_zero_trust_tunnel_cloudflared.cf resource
cloudflare_zero_trust_tunnel_cloudflared_config.cf resource
azurerm_key_vault_secret.mtls_ca_certificate data source
azurerm_key_vault_secret.mtls_ca_password data source
cloudflare_zero_trust_tunnel_cloudflared_token.cf data source
cloudflare_zone.this data source

Inputs

Name Description Type Default Required
acr_login_server The ACR login server for container image pulls string n/a yes
base_name [d] Project name prefix for resource naming string n/a yes
cloudflare_account_id [d] Cloudflare Account ID string null no
cloudflare_zone_id [d] Cloudflare Zone ID for the tunnel DNS record string null no
cloudflared_enabled Whether to deploy the cloudflared tunnel sidecar container bool false no
cloudflared_image Container image for cloudflared (e.g. cloudflare/cloudflared:2026.5.2) string n/a yes
cloudflared_public_hostname The public hostname that Cloudflare will route to this tunnel string null no
container_app_environment_id [d] The container app environment ID to deploy the proxy into string n/a yes
cstore_internal_ingress Do we need ingress for cstore-scu bool false no
domain The published container app domain string n/a yes
kastoria_reportview_enabled Whether the perftest reporter is enabled bool false no
kastoria_smartehr_enabled Whether the smartehr upstream is enabled bool true no
key_vault_id [d] Resource ID of the Azure Key Vault (passed from parent module instead of data source lookup) string null no
key_vault_name Name of the Azure Key Vault string null no
key_vault_resource_group_name Resource group of the Key Vault (defaults to var.resource_group_name) string null no
key_vault_secrets Map of env var names to Key Vault secret names to inject into the proxy container map(string) {} no
mtls_ca [d] Optional mTLS CA certificate to register with the Container App Environment.
Both the PFX certificate blob (base64, CA chain only, no private key) and
its password are read from the Key Vault referenced by var.key_vault_id, so
nothing certificate-related is kept in the repo. Requires var.key_vault_id.
object({
name = optional(string, "gateway-mtls-ca")
certificate_name = optional(string, "callerCACert")
password_secret = optional(string, "callerCACertPassword")
})
null no
mtls_proxy_allowed_ip_ranges IPv4 CIDR ranges allowed to reach the mTLS proxy ingress. Presence of any
entry puts the ingress into deny-by-default: every address outside these
ranges is rejected with "RBAC: Access Denied" before the container sees the
request. An empty list means no IP filtering at all. Single addresses must
be written as an explicit /32; IPv6 is not supported by Azure.
list(string) [] no
mtls_proxy_client_certificate_mode ACA platform-enforced client certificate mode for the mTLS proxy ingress: require (cert mandatory), accept (optional, forwarded in X-Forwarded-Client-Cert), ignore (dropped) string "require" no
mtls_proxy_external_enabled Whether the mTLS proxy publishes a public (external) ingress. When false the
app is only reachable through the environment-internal FQDN, which is what
same-environment callers use, so flipping this does not affect them.

Combining this with an empty var.mtls_proxy_allowed_ip_ranges publishes the
app to the whole internet protected only by its client certificate.
bool false no
mtls_proxy_image The image name for the mtls proxy if used string null no
proxy_enabled Whether to deploy the kastoria reverse proxy container app bool true no
proxy_env_extra Additional environment variables to pass to the proxy container map(string) {} no
proxy_external_enabled Whether the reverse proxy has external-facing ingress. Set false when using Cloudflare Tunnel. bool true no
proxy_image The container image for the reverse proxy string n/a yes
registry_identity_id Resource ID of the user-assigned managed identity used for ACR authentication string n/a yes
resource_group_name [d] The resource group the container app environment is in string n/a yes
tags Tags to apply map(string) {} no
upstream_services Map of upstream services. Each entry produces a {NAME}_ADDRESS env var.
map(object({
address = string
}))
{} no
user_assigned_identity_id Resource ID of the user-assigned managed identity attached to the container app for Key Vault access string null no
workload_profile_name Workload profile name assigned to the gateway container apps string "Consumption" no

[d] Destructive: changing this input forces one or more resources to be destroyed and recreated (an OpenTofu/Terraform replacement) rather than updated in place.

Outputs

Name Description
cloudflare_tunnel_id The ID of the Cloudflare tunnel
cloudflare_tunnel_record_name The CNAME record name for the tunnel
cloudflare_tunnel_token The tunnel token stored in Key Vault (sensitive)
cloudflared_container_app_id The ID of the cloudflared tunnel container app
cloudflared_container_app_name The name of the cloudflared tunnel container app
container_app_fqdn The FQDN of the kastoria proxy container app (latest revision)
container_app_id The ID of the kastoria proxy container app
container_app_name The name of the kastoria proxy container app
mtls_proxy_app_fqdn The FQDN of the kastoria mtls proxy container app (latest revision)
mtls_proxy_app_id The ID of the kastoria mtls proxy container app
mtls_proxy_app_name The name of the kastoria mtls proxy container app

Example Usage

Reverse proxy only

module "gateway" {
  source = "oci://acrmerkalisdist0c66.azurecr.io/modules/azure/gateway?tag=<module-version>"

  base_name                    = "merk-test"
  resource_group_name          = "merk-test-rg"
  container_app_environment_id = module.compute_apps.container_app_environment_id
  domain                       = "victoriousflower-bd8a4f3a.centralus.azurecontainerapps.io"

  cloudflared_image    = "not-used"

  proxy_image          = "myacr.azurecr.io/kastoria-proxy:latest"
  acr_login_server     = "myacr.azurecr.io"
  registry_identity_id = module.compute_apps.identity_id

  # Each entry becomes an {NAME}_ADDRESS env var pointing at
  # app-{base_name}-{address}.internal.{domain}
  upstream_services = {
    kastoria_health      = { address = "kastoria" }
    kastoria_smartlaunch = { address = "smartlaunchapi" }
    kastoria_consoleapi  = { address = "consapi" }
    kastoria_consoleui   = { address = "consui" }
    ohif_viewer          = { address = "ohif" }
  }

  proxy_env_extra = {
    LOG_LEVEL = "warn"
  }

  tags = {
    owner   = "merkalis"
    env     = "test"
    envtype = "DevTest"
  }
}

With a Cloudflare Tunnel

Requires the cloudflare provider credentials (CLOUDFLARE_API_TOKEN) and a Key Vault to hold the generated tunnel token. Pair with proxy_external_enabled = false so the proxy is reachable only through the tunnel.

module "gateway" {
  # ... base_name, domain, upstream_services as above

  proxy_external_enabled = false

  cloudflared_enabled         = true
  cloudflared_image           = "cloudflare/cloudflared:2026.5.2"
  cloudflared_public_hostname = "app.merkal.io"
  cloudflare_account_id       = "abc123"
  cloudflare_zone_id          = "def456"
  cstore_internal_ingress     = true # also expose app-{base_name}-storescu

  key_vault_name            = module.secrets.vault_name
  key_vault_id              = module.secrets.vault_id
  user_assigned_identity_id = module.secrets.identity_id

  key_vault_secrets = {
    PROXY_API_TOKEN = "proxy-api-token" # env var name -> KV secret name
  }
}

With an mTLS proxy

Registers a caller CA with the Container App Environment and deploys a second proxy that demands a client certificate. Both the certificate and its password are read from Key Vault, so key_vault_id is mandatory here.

module "gateway" {
  # ... base_name, domain, upstream_services as above

  cloudflared_image    = "not-used"

  key_vault_name            = module.secrets.vault_name
  key_vault_id              = module.secrets.vault_id
  user_assigned_identity_id = module.secrets.identity_id

  mtls_ca = {
    name             = "gateway-mtls-ca"          # defaults shown
    certificate_name = "callerCACert"
    password_secret  = "callerCACertPassword"
  }

  mtls_proxy_image                   = "myacr.azurecr.io/kastoria-mtls-proxy:latest"
  mtls_proxy_client_certificate_mode = "require"
  mtls_proxy_external_enabled        = false
  mtls_proxy_allowed_ip_ranges       = ["203.0.113.0/24"]
}

Resources Created

Resource Condition Notes
azurerm_container_app.kastoria_proxy proxy_enabled app-{base_name}-kastoria-proxy, 0.25 CPU / 0.5Gi, 1–5 replicas, HTTP ingress on 8080
azurerm_container_app_environment_certificate.mtls_ca mtls_ca != null Caller CA registered with the ACA environment
azurerm_container_app.kastoria_mtls_proxy mtls_ca != null && mtls_proxy_image != null app-{base_name}-mtls-proxy, 0.25 CPU / 0.5Gi, 1–5 replicas, mTLS ingress on 8080
cloudflare_zero_trust_tunnel_cloudflared.cf cloudflared_enabled Tunnel named app-{base_name}-cloudflared
cloudflare_zero_trust_tunnel_cloudflared_config.cf cloudflared_enabled Ingress rules to the proxy (plus storescu when cstore_internal_ingress)
cloudflare_dns_record.cf_tunnel cloudflared_enabled Proxied CNAME for cloudflared_public_hostname
cloudflare_dns_record.cf_cstore_tunnel cloudflared_enabled Proxied CNAME cstore.{hostname}; the matching tunnel route only exists when cstore_internal_ingress
azurerm_key_vault_secret.cf_tunnel_token cloudflared_enabled cloudflare-tunnel-token written to key_vault_id
azurerm_container_app.cloudflared cloudflared_enabled app-{base_name}-cloudflared, 0.25 CPU / 0.5Gi, single replica

The Cloudflare tunnel token is stored in Azure Key Vault rather than kept in a Terraform variable, and the connector app reads it back through a managed-identity secret block. This keeps the token out of configuration the container while OpenTofu still owns the Key Vault secret itself.

Notes

Upstream Routing

upstream_services is the only routing input: each key becomes an upper-cased {NAME}_ADDRESS env var resolving to the service's internal FQDN, which the proxy image reads to build its routes. Toggling kastoria_reportview_enabled and kastoria_smartehr_enabled emits KASTORIA_*_ENABLED flags for feature gates inside the proxy image — they do not add or remove routes.

Cloudflare Tunnel

1. When cloudflared_enabled = true, the module creates the tunnel through the cloudflare provider, so that provider needs CLOUDFLARE_API_TOKEN in the environment running the plan (CI/CD secret or local shell). cloudflare_account_id and cloudflare_zone_id are required in this mode, and cloudflared_public_hostname must be the full FQDN inside that zone (e.g. app.example.com) — the zone name is trimmed off it to derive the CNAME record name.

2. The generated cloudflare-tunnel-token lands in the key_vault_id Key Vault with a key name of tunnel-token and the connector app consumes it as a managed-identity-backed secret. That means key_vault_name (for the secret URI) anduser_assigned_identity_id (holding Key Vault Secrets User) are both required whenever the tunnel is active.

3. Set proxy_external_enabled = false to lock the proxy to internal-only when the tunnel is active; the tunnel reaches it over the environment-internal FQDN either way.

mTLS Proxy

1. azurerm_container_app.kastoria_mtls_proxy is deployed only when both mtls_ca and mtls_proxy_image are set. The ACA platform's (Envoy) ingress terminates TLS and enforces mtls_proxy_client_certificate_mode; certificate chain validation against the caller CA happens inside the proxy image using the X-Forwarded-Client-Cert header, not in Envoy.

2. The proxy always reads kastoriaMTLSClientRoles from the Key Vault into KASTORIA_MTLS_CLIENT_ROLES, so that secret must exist before the first apply. Building its secret URI needs key_vault_name, and reading it needs user_assigned_identity_id with Key Vault Secrets User on the vault — the same pairing the Cloudflare tunnel connector relies on.

3. mtls_proxy_allowed_ip_ranges flips the ingress to deny-by-default as soon as it contains anything — an empty list means no IP filtering at all. Combined with mtls_proxy_external_enabled = true and an empty list, the app is reachable from the whole internet guarded only by its client certificate. Entries are validated at plan time as IPv4 CIDRs (/32 for a single address); 0.0.0.0/0 is rejected because it would silently disable the restriction. Rule names derive from the CIDR itself, so removing one range never renames its siblings.

Available versions

  • v0.9.1