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