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_servicesentry becomes an{NAME}_ADDRESSenv 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
cloudflaredconnector 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
secretblocks 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, andmtls_ca/mtls_proxy_imageeach gate their own resources, so a tunnel-less or proxy-less deployment leaves nothing behind in state. - FinOps Ready:
app-prefixed naming and asubmodule = "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({ |
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({ |
{} |
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 thecloudflareprovider, so that provider needsCLOUDFLARE_API_TOKENin the environment running the plan (CI/CD secret or local shell).cloudflare_account_idandcloudflare_zone_idare required in this mode, andcloudflared_public_hostnamemust 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-tokenlands in thekey_vault_idKey Vault with a key name oftunnel-tokenand the connector app consumes it as a managed-identity-backedsecret. That meanskey_vault_name(for the secret URI) anduser_assigned_identity_id(holdingKey Vault Secrets User) are both required whenever the tunnel is active.3. Set
proxy_external_enabled = falseto 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_proxyis deployed only when bothmtls_caandmtls_proxy_imageare set. The ACA platform's (Envoy) ingress terminates TLS and enforcesmtls_proxy_client_certificate_mode; certificate chain validation against the caller CA happens inside the proxy image using theX-Forwarded-Client-Certheader, not in Envoy.2. The proxy always reads
kastoriaMTLSClientRolesfrom the Key Vault intoKASTORIA_MTLS_CLIENT_ROLES, so that secret must exist before the first apply. Building its secret URI needskey_vault_name, and reading it needsuser_assigned_identity_idwithKey Vault Secrets Useron the vault — the same pairing the Cloudflare tunnel connector relies on.3.
mtls_proxy_allowed_ip_rangesflips the ingress to deny-by-default as soon as it contains anything — an empty list means no IP filtering at all. Combined withmtls_proxy_external_enabled = trueand 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 (/32for a single address);0.0.0.0/0is 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