This guide walks through deploying NGINX Plus Ingress Controller 5.4.1 on Azure Kubernetes Service (AKS), exposing a workload over HTTPS using a Let’s Encrypt certificate via cert-manager, and protecting it with F5 NGINX App Protect WAF. Every step is in sequence so you can reproduce the full stack from scratch.
Architecture Overview
The final stack consists of:
- AKS cluster — Kubernetes worker nodes on Azure
- NGINX Plus Ingress Controller 5.4.1 — with NAP (App Protect) module enabled
- cert-manager — automates Let’s Encrypt certificate issuance and renewal
- NGINX App Protect WAF — OWASP signature-based blocking policy
- WordPress + MariaDB — sample backend workload
Prerequisites
- AKS cluster up and running (
kubectlconfigured) - NGINX Plus license JWT token
- Access to
private-registry.nginx.com(F5/NGINX entitlement) - A DNS A record pointing your domain to the LoadBalancer public IP
- Helm 3 installed locally
Step 1 — Create the Namespace
kubectl create namespace nic-perf
Step 2 — Create the Image Pull Secret
NGINX Plus images are hosted on a private registry. Create a Kubernetes secret with your registry credentials:
kubectl create secret docker-registry regcred \
--docker-server=private-registry.nginx.com \
--docker-username=<YOUR_NGINX_REGISTRY_USER> \
--docker-password=<YOUR_NGINX_REGISTRY_PASSWORD> \
-n nic-perf
Step 3 — Create the NGINX Plus License Secret
Store your NGINX Plus JWT license as a Kubernetes secret:
kubectl create secret generic nplus-license \
--from-literal=license.jwt="<YOUR_JWT_TOKEN>" \
-n nic-perf
Step 4 — Install NGINX Plus Ingress Controller via Helm
Add the NGINX Helm repository and install NIC 5.4.1 with the NAP-enabled image:
helm repo add nginx-stable https://helm.nginx.com/stable
helm repo update
helm install nginx-plus-nic nginx-stable/nginx-ingress \
--namespace nic-perf \
--version 2.5.1 \
--set controller.image.repository=private-registry.nginx.com/nginx-ic-nap/nginx-plus-ingress \
--set controller.image.tag=5.4.1 \
--set controller.nginxplus=true \
--set controller.appprotect.enable=true \
--set controller.ingressClass.name=nginx-perf \
--set controller.ingressClass.create=true \
--set controller.ingressClass.setAsDefaultIngress=false \
--set controller.service.type=LoadBalancer \
--set controller.serviceAccount.imagePullSecretName=regcred \
--set controller.mgmt.licenseTokenSecretName=nplus-license
Wait for the controller to be ready:
kubectl rollout status deployment/nginx-plus-nic-nginx-ingress-controller -n nic-perf
Get the public IP assigned by Azure:
kubectl get svc -n nic-perf nginx-plus-nic-nginx-ingress-controller
Map this IP to your domain in your DNS provider before proceeding.
Step 5 — Deploy WordPress and MariaDB
Create persistent storage and deploy the backend workload.
MariaDB PVC and Deployment
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-pvc
namespace: nic-perf
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-csi
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
namespace: nic-perf
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:11.4
env:
- name: MYSQL_ROOT_PASSWORD
value: "<YOUR_ROOT_PASSWORD>"
- name: MYSQL_DATABASE
value: wordpress
- name: MYSQL_USER
value: wordpress
- name: MYSQL_PASSWORD
value: "<YOUR_DB_PASSWORD>"
ports:
- containerPort: 3306
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumes:
- name: data
persistentVolumeClaim:
claimName: mariadb-pvc
---
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: nic-perf
spec:
selector:
app: mariadb
ports:
- port: 3306
WordPress PVC and Deployment
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wordpress-pvc
namespace: nic-perf
spec:
accessModes: [ReadWriteOnce]
storageClassName: managed-csi
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress
namespace: nic-perf
spec:
replicas: 1
selector:
matchLabels:
app: wordpress
template:
metadata:
labels:
app: wordpress
spec:
containers:
- name: wordpress
image: wordpress:6.7-apache
env:
- name: WORDPRESS_DB_HOST
value: mariadb
- name: WORDPRESS_DB_USER
value: wordpress
- name: WORDPRESS_DB_PASSWORD
value: "<YOUR_DB_PASSWORD>"
- name: WORDPRESS_DB_NAME
value: wordpress
ports:
- containerPort: 80
volumeMounts:
- name: data
mountPath: /var/www/html
volumes:
- name: data
persistentVolumeClaim:
claimName: wordpress-pvc
---
apiVersion: v1
kind: Service
metadata:
name: wordpress
namespace: nic-perf
spec:
selector:
app: wordpress
ports:
- port: 80
Apply both files:
kubectl apply -f mariadb.yaml -n nic-perf
kubectl apply -f wordpress.yaml -n nic-perf
Step 6 — Create the Ingress (HTTP only first)
Start with HTTP only. TLS will be added after the certificate is issued.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress-ingress
namespace: nic-perf
annotations:
nginx.org/proxy-connect-timeout: "60s"
nginx.org/proxy-read-timeout: "60s"
nginx.org/client-max-body-size: "64m"
nginx.org/proxy-buffering: "false"
spec:
ingressClassName: nginx-perf
rules:
- host: your-domain.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
Verify the site is reachable over HTTP before proceeding.
Step 7 — Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.3/cert-manager.yaml
kubectl rollout status deployment/cert-manager -n cert-manager
kubectl rollout status deployment/cert-manager-webhook -n cert-manager
kubectl rollout status deployment/cert-manager-cainjector -n cert-manager
Step 8 — Create Let’s Encrypt ClusterIssuer
The key setting here is ingress.name: wordpress-ingress. NGINX Plus NIC enforces exclusive host ownership per ingress — if cert-manager creates a separate ingress for the ACME challenge it will be rejected with “All hosts are taken by other resources”. Referencing the existing ingress by name tells cert-manager to inject the challenge path into it instead.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
ingressClassName: nginx-perf
name: wordpress-ingress
Step 9 — Add TLS to the Ingress
Update the ingress to request a certificate and enable HTTPS. Do not add an HTTP-to-HTTPS redirect at this stage — Let’s Encrypt must be able to reach the HTTP-01 challenge path over plain HTTP during initial issuance.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress-ingress
namespace: nic-perf
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.org/proxy-connect-timeout: "60s"
nginx.org/proxy-read-timeout: "60s"
nginx.org/client-max-body-size: "64m"
nginx.org/proxy-buffering: "false"
spec:
ingressClassName: nginx-perf
tls:
- hosts:
- your-domain.example.com
secretName: your-domain-tls
rules:
- host: your-domain.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
Watch the certificate until it is ready:
kubectl get certificate -n nic-perf -w
You should see READY: True within 60-90 seconds. Once the certificate is issued you can optionally re-enable the HTTP redirect by patching the NIC ConfigMap:
kubectl patch configmap nginx-plus-nic-nginx-ingress -n nic-perf \
--type=merge -p '{"data":{"ssl-redirect":"true"}}'
Step 10 — Create the WAF Log Profile
apiVersion: appprotect.f5.com/v1beta1
kind: APLogConf
metadata:
name: waf-logconf
namespace: nic-perf
spec:
content:
format: default
max_message_size: 64k
max_request_size: any
filter:
request_type: illegal
Step 11 — Create the WAF Policy
This policy uses the NGINX base template with blocking mode enabled, all attack signatures active, and bot defense turned on.
apiVersion: appprotect.f5.com/v1beta1
kind: APPolicy
metadata:
name: waf-policy
namespace: nic-perf
spec:
policy:
name: waf-policy
description: "OWASP Top 10 protection - blocking mode"
template:
name: POLICY_TEMPLATE_NGINX_BASE
applicationLanguage: utf-8
enforcementMode: blocking
blocking-settings:
violations:
- name: VIOL_BOT_CLIENT
alarm: true
block: true
- name: VIOL_EVASION
alarm: true
block: true
- name: VIOL_HTTP_PROTOCOL
alarm: true
block: true
- name: VIOL_RATING_THREAT
alarm: true
block: true
- name: VIOL_THREAT_CAMPAIGN
alarm: true
block: true
- name: VIOL_PARAMETER
alarm: true
block: false
- name: VIOL_URL
alarm: true
block: false
evasions:
- description: "Bad unescape"
enabled: true
- description: "Directory traversals"
enabled: true
- description: "Bare byte decoding"
enabled: true
- description: "Multiple decoding"
enabled: true
maxDecodingPasses: 2
signature-sets:
- name: All Signatures
block: true
signatureSet:
filter:
minRevision: "2020-01-01"
bot-defense:
settings:
isEnabled: true
mitigations:
classes:
- name: malicious-bot
action: block
- name: untrusted-bot
action: alarm
- name: trusted-bot
action: detect
response-pages:
- responsePageType: default
responseContent: |
<html><head><title>Request Rejected</title></head>
<body><h1>Request Rejected</h1>
<p>Your request has been blocked by the Web Application Firewall.</p>
<p>Support ID: <%TS.request.ID()%></p></body></html>
responseHeader: "HTTP/1.1 403 Forbidden\r\nContent-Type: text/html; charset=utf-8"
Step 12 — Enable WAF on the Ingress
Add the App Protect annotations to the ingress. The log destination is set to stderr so events appear in kubectl logs without needing a separate syslog server.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wordpress-ingress
namespace: nic-perf
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.org/proxy-connect-timeout: "60s"
nginx.org/proxy-read-timeout: "60s"
nginx.org/client-max-body-size: "64m"
nginx.org/proxy-buffering: "false"
appprotect.f5.com/app-protect-enable: "True"
appprotect.f5.com/app-protect-policy: "nic-perf/waf-policy"
appprotect.f5.com/app-protect-security-log-enable: "True"
appprotect.f5.com/app-protect-security-log: "nic-perf/waf-logconf"
appprotect.f5.com/app-protect-security-log-destination: "stderr"
spec:
ingressClassName: nginx-perf
tls:
- hosts:
- your-domain.example.com
secretName: your-domain-tls
rules:
- host: your-domain.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
Step 13 — Verify Everything Works
Test HTTPS
curl -I https://your-domain.example.com
Expected: HTTP/2 200
Test WAF blocking
# SQL injection - should return 403
curl -sk "https://your-domain.example.com/?id=1'+OR+'1'='1" -o /dev/null -w "%{http_code}"
# XSS - should return 403
curl -sk "https://your-domain.example.com/?q=<script>alert(1)</script>" -o /dev/null -w "%{http_code}"
# Directory traversal - should return 403
curl -sk "https://your-domain.example.com/../../etc/passwd" -o /dev/null -w "%{http_code}"
# Normal request - should return 200
curl -sk "https://your-domain.example.com/" -o /dev/null -w "%{http_code}"
View WAF logs
NIC_POD=$(kubectl get pods -n nic-perf -l app=nginx-plus-nic-nginx-ingress \
-o jsonpath='{.items[0].metadata.name}')
# Stream all security events
kubectl logs -n nic-perf $NIC_POD -f | grep 'request_status'
# Stream only blocked requests
kubectl logs -n nic-perf $NIC_POD -f | grep 'request_status="blocked"'
# Pretty-print key fields
kubectl logs -n nic-perf $NIC_POD \
| grep 'request_status="blocked"' \
| grep -oP '(date_time|ip_client|attack_type|violations|support_id|severity)="[^"]*"'
Key Gotchas
- NGINX Plus NIC host ownership — NIC rejects any ingress that tries to claim a hostname already owned by another ingress. This breaks the default cert-manager HTTP-01 solver which creates a new ingress. Fix: set
ingress.namein the ClusterIssuer solver to reuse the existing ingress. - SSL redirect blocks ACME challenge — If HTTP-to-HTTPS redirect is enabled when the certificate is first requested, the Let’s Encrypt HTTP-01 challenge will be redirected to HTTPS and fail. Disable the redirect until the certificate is issued.
- WAF violation names — The APPolicy CRD validates violation names strictly.
VIOL_ATTACK_SIGNATUREis not a valid blocking-settings violation name in current versions. UseVIOL_RATING_THREATandVIOL_THREAT_CAMPAIGNfor signature-based blocking control. - Bot defense actions — The
allowaction is not valid for bot-defense mitigations. Usedetectfor trusted bots instead. - cert-manager auto-renewal — cert-manager renews the certificate automatically 30 days before expiry. No manual intervention needed.
Component Versions Used
- NGINX Plus Ingress Controller: 5.4.1 (Helm chart nginx-ingress-2.5.1)
- NGINX Plus: R36-P3
- NGINX App Protect: 5.12.0
- cert-manager: v1.16.3
- AKS: Azure Kubernetes Service (any recent version)
- WordPress: 6.7-apache
- MariaDB: 11.4