Cloud
Publishing a business application with Azure Application Gateway, mTLS, and a dedicated WAF policy
An operational walkthrough for publishing a controlled business application behind Azure Application Gateway with dedicated HTTPS listeners, mutual TLS, a scoped WAF policy, HTTPS backends, and network validation points.
Publishing a business application to an external system should never be reduced to adding a DNS name on top of a public IP address. As soon as an application flow comes from a SaaS platform, a partner, or a B2B system, several topics must be separated from the beginning: who is allowed to call the application, how the client is authenticated, which TLS path is actually used, which WAF policy applies, how the backend is validated, and how operations teams can diagnose a failure without mixing every layer together.
Azure Application Gateway fits this pattern well when the expected architecture is a controlled HTTPS publication toward existing virtual machines or private backends. API Management is not always required if the requirement is not full API lifecycle management, subscriptions, products, advanced application throttling, or payload transformation. In a more direct scenario, Application Gateway can act as the single entry point, terminate frontend TLS, enforce mutual authentication with a client certificate, apply a dedicated WAF policy, then reach the backend over HTTPS.
The target design remains intentionally simple.
External SaaS client or partner
->
Azure Application Gateway public IP
->
Dedicated HTTPS listener per application name
->
Strict mTLS authentication
->
Dedicated WAF policy for the application
->
Backend pool to application VM
->
HTTPS backend setting with explicit hostname
->
Nginx or local reverse proxy
->
Business application That simplicity is precisely what makes the model operable. Each Application Gateway object has a clear purpose. The listener represents a public name. The routing rule links that name to a backend. The backend setting describes the HTTP or HTTPS behavior toward the application server. The probe validates the real backend health. The WAF policy is isolated so that exceptions tuned for another application are not reused blindly. The SSL profile materializes the mTLS control.
Scope used in the example
The example below exposes three environments of the same application through an existing Application Gateway. Names and addresses are anonymized.
DEV
FQDN : app-dev.example.com
Backend VM : vm-app-dev-01
Backend IP : 10.10.54.137
QA
FQDN : app-qa.example.com
Backend VM : vm-app-qa-01
Backend IP : 10.10.54.69
PRD
FQDN : app.example.com
Backend VM : vm-app-prd-01
Backend IP : 10.10.54.8 The application gateway and its public frontend already exist.
export RG="rg-network-hub-prod"
export AGW="agw-internet-prod-001"
export PUBLIC_IP="203.0.113.10"
export FRONTEND_IP="appGwPublicFrontendIpIPv4"
export FRONTEND_PORT_443="port_443" Public DNS must point the three names to the same Application Gateway public IP. In an enterprise environment, that zone may be managed by a separate DNS team. This step should therefore be treated as a change prerequisite, not as a detail to validate at the end.
app-dev.example.com -> 203.0.113.10
app-qa.example.com -> 203.0.113.10
app.example.com -> 203.0.113.10 Validation must be performed from a resolver that sees public DNS, not only from an internal machine whose behavior may be affected by forwarders or private zones.
nslookup app-dev.example.com
nslookup app-qa.example.com
nslookup app.example.com Clarify both TLS chains before creating objects
This type of design contains two separate TLS connections.
The first connection goes from the external client to Application Gateway. The client validates the server certificate presented by the listener. In this example, a wildcard certificate covers the published names.
Server certificate on listener side: *.example.com
Covered names:
app-dev.example.com
app-qa.example.com
app.example.com The second connection goes from Application Gateway to the application VM. If the backend setting uses HTTPS, the gateway must be able to establish a valid TLS connection to the backend server. The hostname used in the backend setting must therefore match the certificate served by Nginx or by the local reverse proxy. This point matters when the backend pool contains IP addresses. Do not rely on pickHostNameFromBackendAddress with private IPs. The name must be explicitly defined in the backend setting.
Mutual TLS adds a third topic, but only on the frontend side. The external client presents a client certificate. Application Gateway validates that certificate against an approved client CA chain attached to the SSL profile. In strict mode, if the client certificate is missing or invalid, negotiation fails before the request is routed to the backend.
The client certificate trust chain must be prepared cleanly. The client certificate itself remains on the client side. The private key must not be sent to Azure. What is uploaded to Application Gateway is the trusted certificate authority chain that allows the gateway to validate the certificate presented by the client.
External client side
- CSR generated by the client or SaaS platform
- Client certificate signed by a trusted CA
- Private key kept on the client side
- Client certificate configured in the calling platform
Application Gateway side
- Public or private CA chain that signed the client certificate
- SSL profile with client authentication enabled
- SSL profile attached only to this application's listeners Prepare deployment variables
The following variable set keeps the commands readable. The names are intentionally generic, but they remain close enough to an operations convention to be reusable.
export RG="rg-network-hub-prod"
export AGW="agw-internet-prod-001"
export FRONTEND_IP="appGwPublicFrontendIpIPv4"
export FRONTEND_PORT_443="port_443"
export SERVER_CERT_NAME="wildcard-example-com"
export WAF_POLICY="waf-agw-app-publication"
export SSL_PROFILE="sslprof-app-mtls-001"
export TRUSTED_CLIENT_CA_NAME="ca-client-app-chain"
export DEV_HOST="app-dev.example.com"
export QA_HOST="app-qa.example.com"
export PRD_HOST="app.example.com"
export DEV_IP="10.10.54.137"
export QA_IP="10.10.54.69"
export PRD_IP="10.10.54.8" Before creating the listeners, identify the exact name of the server certificate already present on the Application Gateway.
az network application-gateway ssl-cert list -g "$RG" --gateway-name "$AGW" -o table This check prevents listeners from being created with the wrong certificate object, and avoids reimporting a certificate that is already managed by the network team.
Create backend pools
Each environment receives its own backend pool. Even if the three backends serve the same application, isolating them simplifies troubleshooting and reduces the risk of a QA change accidentally touching production.
az network application-gateway address-pool create -g "$RG" --gateway-name "$AGW" -n "bp-app-dev" --servers "$DEV_IP"
az network application-gateway address-pool create -g "$RG" --gateway-name "$AGW" -n "bp-app-qa" --servers "$QA_IP"
az network application-gateway address-pool create -g "$RG" --gateway-name "$AGW" -n "bp-app-prd" --servers "$PRD_IP" Using private IPs in backend pools is valid for virtual machines, but it requires the backend TLS hostname to be explicit. Otherwise, certificate errors quickly become misleading.
Create HTTPS probes
Probes must test the same behavior expected by the gateway. If the backend answers over HTTPS and presents a certificate for app-qa.example.com, the QA probe must use that hostname.
az network application-gateway probe create -g "$RG" --gateway-name "$AGW" -n "probe-app-dev" --protocol Https --host "$DEV_HOST" --path "/" --interval 60 --timeout 60 --threshold 3 --match-status-codes "200-399"
az network application-gateway probe create -g "$RG" --gateway-name "$AGW" -n "probe-app-qa" --protocol Https --host "$QA_HOST" --path "/" --interval 60 --timeout 60 --threshold 3 --match-status-codes "200-399"
az network application-gateway probe create -g "$RG" --gateway-name "$AGW" -n "probe-app-prd" --protocol Https --host "$PRD_HOST" --path "/" --interval 60 --timeout 60 --threshold 3 --match-status-codes "200-399" The / path is only an example. In production, an explicit application health endpoint is preferable if the application provides one. It must remain stable, fast, and representative. A probe that depends on a third-party service or heavy business logic creates false incidents.
Create HTTPS backend settings
The backend setting is where many issues hide. The port and protocol are not enough. The hostname is critical for backend TLS.
az network application-gateway http-settings create -g "$RG" --gateway-name "$AGW" -n "bhs-app-dev" --protocol Https --port 443 --host-name "$DEV_HOST" --probe "probe-app-dev" --timeout 60
az network application-gateway http-settings create -g "$RG" --gateway-name "$AGW" -n "bhs-app-qa" --protocol Https --port 443 --host-name "$QA_HOST" --probe "probe-app-qa" --timeout 60
az network application-gateway http-settings create -g "$RG" --gateway-name "$AGW" -n "bhs-app-prd" --protocol Https --port 443 --host-name "$PRD_HOST" --probe "probe-app-prd" --timeout 60 A targeted check catches an incorrect setting immediately.
az network application-gateway http-settings show -g "$RG" --gateway-name "$AGW" -n "bhs-app-qa" --query "{hostName:hostName,pickHostNameFromBackendAddress:pickHostNameFromBackendAddress,protocol:protocol,port:port,probe:probe.id}" -o json The expected result should look like this.
{
"hostName": "app-qa.example.com",
"pickHostNameFromBackendAddress": false,
"port": 443,
"protocol": "Https"
} If hostName is empty or if the gateway tries to infer the name from a private IP address, fix it before moving forward. The rest of the deployment will not repair a misnamed backend TLS configuration.
Create a dedicated WAF policy
Reusing an existing WAF policy because it already works for another application is a bad shortcut. A WAF policy often accumulates exclusions, custom rules, and tuning decisions tied to the behavior of one specific application. Reusing it for a partner integration means carrying security decisions that may have nothing to do with the new flow.
A dedicated policy keeps the scope clear.
az network application-gateway waf-policy create -g "$RG" -n "$WAF_POLICY" --type OWASP --version 3.2
az network application-gateway waf-policy policy-setting update -g "$RG" --policy-name "$WAF_POLICY" --mode Prevention --state Enabled --request-body-check true Prevention mode is coherent for a restricted and expected flow. However, it must be validated with real application calls. Some older applications or specific business payloads may trigger OWASP rules unexpectedly. The practical compromise is often to start with the dedicated policy, observe logs during QA testing, then add only justified and documented exclusions.
Restrict access to the external client IP ranges
mTLS validates the cryptographic identity of the client, but it does not replace network filtering. If the egress ranges of the partner or SaaS platform are known, a custom WAF rule can block everything that does not come from those ranges.
CLIENT_IP_RANGES=(
"198.51.100.0/24"
"198.51.101.0/24"
"203.0.113.0/25"
"203.0.113.128/25"
)
az network application-gateway waf-policy custom-rule create -g "$RG" --policy-name "$WAF_POLICY" -n "deny-non-approved-client-ranges" --priority 10 --rule-type MatchRule --action Block
az network application-gateway waf-policy custom-rule match-condition add -g "$RG" --policy-name "$WAF_POLICY" --name "deny-non-approved-client-ranges" --match-variables RemoteAddr --operator IPMatch --values "${CLIENT_IP_RANGES[@]}" --negation-condition true The logic is intentionally inverted.
If the source address is not part of the approved ranges, block the request. This kind of rule must be maintained. SaaS provider IP ranges may change. If this control becomes critical for production, plan a regular review of the ranges and an update process that does not depend on a single person.
Upload the trusted client CA chain
Application Gateway must not receive the client certificate with its private key. It must receive the certificate authority chain required to validate the certificate presented by the client.
az network application-gateway client-cert add -g "$RG" --gateway-name "$AGW" -n "$TRUSTED_CLIENT_CA_NAME" --data "./client-mtls-ca-chain.cer" The file must contain the root CA and, when needed, the intermediate certificates. If the chain is rejected, check the format, size, chain order, and whether the root is present. In some contexts, the security team may prefer uploading only a dedicated root or a controlled intermediate chain to avoid trusting too broadly.
The uploaded object can be validated directly.
az network application-gateway client-cert list -g "$RG" --gateway-name "$AGW" -o table Create the mTLS SSL profile
The SSL profile links the TLS configuration and client authentication. For a strict mode, client authentication must be enabled and the trusted CA chain must be referenced.
az network application-gateway ssl-profile add -g "$RG" --gateway-name "$AGW" --name "$SSL_PROFILE" --client-auth-configuration true --trusted-client-certificates "$TRUSTED_CLIENT_CA_NAME" --min-protocol-version TLSv1_2 Depending on the Azure CLI version, some parameter names may vary slightly or be exposed differently. Always validate the local help of the environment that will execute the change.
az network application-gateway ssl-profile --help
az network application-gateway http-listener create --help
az network application-gateway http-listener update --help If the CLI available on the administration workstation or in the pipeline does not expose all mTLS parameters cleanly, create this part through the portal, PowerShell, or Infrastructure as Code rather than improvising a partial command. The important result is a dedicated SSL profile with client authentication enabled, attached only to this application’s listeners.
Create HTTPS listeners
Each environment has its own listener. The three listeners use the same public frontend and the same port 443, but they differ by hostname.
az network application-gateway http-listener create -g "$RG" --gateway-name "$AGW" -n "ln-https-app-dev" --frontend-ip "$FRONTEND_IP" --frontend-port "$FRONTEND_PORT_443" --host-name "$DEV_HOST" --ssl-cert "$SERVER_CERT_NAME"
az network application-gateway http-listener create -g "$RG" --gateway-name "$AGW" -n "ln-https-app-qa" --frontend-ip "$FRONTEND_IP" --frontend-port "$FRONTEND_PORT_443" --host-name "$QA_HOST" --ssl-cert "$SERVER_CERT_NAME"
az network application-gateway http-listener create -g "$RG" --gateway-name "$AGW" -n "ln-https-app-prd" --frontend-ip "$FRONTEND_IP" --frontend-port "$FRONTEND_PORT_443" --host-name "$PRD_HOST" --ssl-cert "$SERVER_CERT_NAME" The WAF policy and SSL profile can be attached at creation time if the parameters are available in the Azure CLI version being used, or they can be applied afterward.
WAF_POLICY_ID=$(az network application-gateway waf-policy show -g "$RG" -n "$WAF_POLICY" --query id -o tsv)
SSL_PROFILE_ID=$(az network application-gateway ssl-profile show -g "$RG" --gateway-name "$AGW" --name "$SSL_PROFILE" --query id -o tsv)
az network application-gateway http-listener update -g "$RG" --gateway-name "$AGW" -n "ln-https-app-dev" --waf-policy "$WAF_POLICY_ID" --ssl-profile-id "$SSL_PROFILE_ID"
az network application-gateway http-listener update -g "$RG" --gateway-name "$AGW" -n "ln-https-app-qa" --waf-policy "$WAF_POLICY_ID" --ssl-profile-id "$SSL_PROFILE_ID"
az network application-gateway http-listener update -g "$RG" --gateway-name "$AGW" -n "ln-https-app-prd" --waf-policy "$WAF_POLICY_ID" --ssl-profile-id "$SSL_PROFILE_ID" Do not attach this WAF policy globally to the Application Gateway if it applies only to this application. The control must remain at the relevant listener level. That is what prevents impact on existing publications hosted on the same gateway.
Create routing rules
Routing rules bind the listener, backend pool, and backend setting. Priorities must be chosen from a range not already used by existing rules.
az network application-gateway rule create -g "$RG" --gateway-name "$AGW" -n "rule-app-dev" --http-listener "ln-https-app-dev" --address-pool "bp-app-dev" --http-settings "bhs-app-dev" --priority 400
az network application-gateway rule create -g "$RG" --gateway-name "$AGW" -n "rule-app-qa" --http-listener "ln-https-app-qa" --address-pool "bp-app-qa" --http-settings "bhs-app-qa" --priority 410
az network application-gateway rule create -g "$RG" --gateway-name "$AGW" -n "rule-app-prd" --http-listener "ln-https-app-prd" --address-pool "bp-app-prd" --http-settings "bhs-app-prd" --priority 420 Before creating these rules in a loaded environment, list existing priorities.
az network application-gateway rule list -g "$RG" --gateway-name "$AGW" --query "[].{name:name,priority:priority,listener:httpListener.id}" -o table Do not forget the return network path
A healthy backend does not depend only on Application Gateway. Flows must be allowed up to the VMs, and return traffic must follow the expected path. In architectures with a central firewall, network appliance, or forced routing, asymmetry can create symptoms that look like TLS or application issues.
The minimal flow to allow looks like this.
Source : Application Gateway subnet
Destination : 10.10.54.137
Service : TCP 443
Action : Allow
Source : Application Gateway subnet
Destination : 10.10.54.69
Service : TCP 443
Action : Allow
Source : Application Gateway subnet
Destination : 10.10.54.8
Service : TCP 443
Action : Allow From each backend VM, checking the route toward the private IPs of the Application Gateway instances or toward the relevant subnet prevents many false diagnostics.
ip route get 10.20.141.36
ip route get 10.20.141.37 If doubt remains, a packet capture is the most direct way to distinguish an IP path issue from a TLS or application issue.
sudo tcpdump -ni eth0 "host 10.20.141.36 or host 10.20.141.37" The expected TCP handshake is simple.
Application Gateway -> VM : SYN
VM -> Application Gateway : SYN,ACK
Application Gateway -> VM : ACK If the SYN reaches the backend but the SYN ACK never returns to the right place, this is not a certificate issue. It is a routing, firewall, or asymmetric return path issue.
Configure the Nginx backend with a full certificate chain
The backend must present a certificate consistent with the hostname sent by Application Gateway. In this QA example, the backend setting uses app-qa.example.com. Nginx must therefore serve a certificate whose CN or SAN covers that name.
server {
listen 443 ssl;
server_name app-qa.example.com;
ssl_certificate /etc/nginx/ssl/wildcard.example.com.fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/wildcard.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/html;
index index.html index.php;
location / {
try_files $uri $uri/ /index.html =404;
}
location /api/external/status/ {
fastcgi_param APP_ENV qa;
fastcgi_param INSTANCE_TYPE api;
fastcgi_param ENV_NAME qa;
alias /opt/app/backend/www;
try_files $uri $uri/ /index.php$is_args$args;
fastcgi_split_path_info ^(.+.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $request_filename;
}
} After modification, test and reload Nginx.
sudo nginx -t
sudo systemctl reload nginx The certificate chain served by the backend must be complete. A server certificate without its intermediate may work from a browser or a machine that already has some chains cached, but fail from Application Gateway.
openssl s_client -connect 10.10.54.69:443 -servername app-qa.example.com -showcerts </dev/null The expected result must show the server certificate and the required intermediates.
0 CN=*.example.com
1 CN=Intermediate TLS CA
2 CN=Root CA If the backend presents only the server certificate, fix the fullchain.pem file before looking elsewhere.
Validate backend health in Application Gateway
The main validation after object creation is backend health.
az network application-gateway show-backend-health -g "$RG" -n "$AGW" -o table
az network application-gateway show-backend-health -g "$RG" -n "$AGW" -o jsonc The expected result is explicit.
bp-app-dev / 10.10.54.137 : Healthy
bp-app-qa / 10.10.54.69 : Healthy
bp-app-prd / 10.10.54.8 : Healthy If the backend is unhealthy, read the exact message. The most common causes are an incorrect probe hostname, an incomplete TLS chain, a missing route, an intermediate firewall, an application timeout, or a health endpoint that does not answer as expected.
Validate access logs and WAF logs
Application Gateway logs must be available as soon as QA tests start. This is also why listeners, rules, and policies need explicit names.
AGWAccessLogs
| where TimeGenerated > ago(2h)
| where Host has "app"
| project TimeGenerated, Host, RequestUri, ClientIp, HttpStatus, RuleName, TransactionId
| order by TimeGenerated desc WAF logs distinguish a request blocked by the IP allowlist, an OWASP rule, or another condition.
AGWFirewallLogs
| where TimeGenerated > ago(2h)
| where Hostname has "app"
| project TimeGenerated, Hostname, RequestUri, ClientIp, Action, RuleId, Message, Details, TransactionId
| order by TimeGenerated desc In some environments, logs are still exposed through AzureDiagnostics.
AzureDiagnostics
| where TimeGenerated > ago(2h)
| where Category in ("ApplicationGatewayAccessLog", "ApplicationGatewayFirewallLog")
| where host_s has "app" or hostname_s has "app"
| order by TimeGenerated desc A good application test must produce a readable triplet: an access log entry, a WAF log entry if a rule is triggered, and an application trace on the backend side. Without correlation by time, hostname, URI, and transaction ID, troubleshooting becomes too slow.
Test the expected flow from the external client
The expected test scenario looks like this.
Called URL: https://app-qa.example.com/api/external/status/506
Expected result:
- the source address belongs to the allowed ranges
- the client presents its mTLS certificate
- Application Gateway validates the client CA chain
- the WAF policy does not block the expected payload
- the QA rule routes to the QA backend pool
- Application Gateway opens an HTTPS connection to 10.10.54.69
- Nginx presents a complete certificate chain
- the application returns a valid response A manual test can be performed with curl if the client certificate and private key are available in a test environment. In production, the private key often belongs to the external platform and must not circulate.
curl -v --cert ./client-test.crt --key ./client-test.key https://app-qa.example.com/api/external/status/506 This test does not replace a real call from the external platform, but it quickly isolates the TLS and mTLS part.
Common errors
A 403 Forbidden often indicates a WAF action. In this design, the first hypothesis to check is the IP allowlist. If the source address seen by Application Gateway does not match the authorized ranges, the custom rule blocks the request. Check WAF logs before looking at the application.
A message such as No required SSL certificate was sent means that the mTLS listener expects a client certificate, but the client did not present one. The issue may come from the external platform configuration, the wrong client certificate, or a manual test launched without a certificate.
A 502 Bad Gateway is broader. It can come from an unhealthy backend, no route, a firewall, an invalid backend certificate, an incorrect hostname in the backend setting, a wrong probe, or an application timeout. Start with show-backend-health, not with the application code.
A missing intermediate certificate error on the backend generally means that Nginx serves only the server certificate. Use a fullchain file that contains the server certificate and the required intermediates.
A CN or SAN mismatch appears when the hostname used by Application Gateway does not match the certificate presented by the backend. With backend pools based on private IPs, this is common if the backend setting has no explicit host-name.
Final object inventory
At the end of the change, the Application Gateway inventory must remain readable. For this example, the expected objects are the following.
WAF policy
waf-agw-app-publication
SSL profile
sslprof-app-mtls-001
Trusted client CA
ca-client-app-chain
Listeners
ln-https-app-dev
ln-https-app-qa
ln-https-app-prd
Backend pools
bp-app-dev
bp-app-qa
bp-app-prd
Backend settings
bhs-app-dev
bhs-app-qa
bhs-app-prd
Probes
probe-app-dev
probe-app-qa
probe-app-prd
Rules
rule-app-dev
rule-app-qa
rule-app-prd This list is not cosmetic. It verifies that the change remained bounded. If a global policy was modified, if an existing listener was reused without a clear reason, or if a shared rule was altered, the change has exceeded its scope.
What must be decided before generalizing the model
This pattern is clean when the exposed application has few consumers, known IP ranges, a stable HTTPS contract, and a clear need for client authentication. It becomes less suitable if the organization wants to manage dozens of consumers, publish API versions, enforce quotas per client, transform requests, or provide a developer portal. In that case, API Management becomes a natural component again.
It is also necessary to decide who owns each element over time. Public DNS, the wildcard server certificate, the trusted client CA chain, partner IP ranges, the WAF policy, internal firewall rules, and the Nginx configuration are not always administered by the same team. If that responsibility is unclear, the incident will happen during a certificate renewal, a SaaS range change, or the first urgent WAF exception.
The real benefit of this approach is therefore not only technical. The benefit comes from segmentation. One listener per name, one backend pool per environment, one probe per backend, one dedicated WAF policy, one SSL profile limited to the application scope, and observable validations at every layer. That segmentation is what makes the publication operable rather than merely functional.