Get started with F5 WAF for NGINX (PLM)
This tutorial walks through the complete flow of protecting traffic with F5 WAF for NGINX using Policy Lifecycle Management (PLM). By the end, you will have:
- Deployed the PLM infrastructure (Policy Controller and SeaweedFS storage)
- Connected NGINX Gateway Fabric to PLM storage
- Defined a WAF policy using
APPolicyandAPLogConfcustom resources - Attached a
WAFPolicyto a Gateway and configured HTTPRoutes - Validated policy compilation and verified that attacks are blocked
PLM is one of four WAF policy source types. With PLM, you define your security posture as APPolicy and APLogConf custom resources instead of compiling and hosting bundles yourself. For a comparison with the other source types, see PLM (Policy Lifecycle Management).
- Have
kubectlaccess to a Kubernetes cluster. - Have a valid F5 WAF for NGINX subscription. F5 WAF for NGINX is a separate add-on to NGINX Plus and isn’t included with the NGINX Plus license.
- Have your private registry credentials Secret for
private-registry.nginx.comavailable. You’ll reference this Secret when you install NGINX Gateway Fabric.
This tutorial uses the following example values. You can use different values — if you do, replace them consistently throughout.
| Example value | What it represents |
|---|---|
plm-system |
Namespace for the PLM backend components |
plm |
Helm release name for the PLM installation |
5.14.0 |
F5 WAF for NGINX Policy Controller chart and image version |
security |
Namespace for APPolicy and APLogConf resources |
default |
Namespace for the Gateway and WAFPolicy |
cafe.example.com |
Example hostname for HTTPRoutes |
The Policy Lifecycle Manager (PLM) backend runs as a Kubernetes operator. It watches WAF custom resources and compiles WAF policies into bundles. The Policy Controller delegates compilation to a separate compiler service over gRPC. The resulting bundles are stored in an embedded SeaweedFS S3-compatible object store.
F5 WAF for NGINX is installed using a separate Helm chart from your NGINX data plane. The steps in this section install only the F5 WAF for NGINX PLM components and do not affect your existing NGINX installation.
Install the four custom resource definitions (CRDs) that the Policy Controller manages:
kubectl apply -f https://raw.githubusercontent.com/nginx/waf-policy-controller/main/manifests/1-deploy-crds.yamlConfirm all four CRDs are present:
kubectl get crd | grep appprotect.f5.comExpected output:
appolicies.appprotect.f5.com
aplogconfs.appprotect.f5.com
apsignatures.appprotect.f5.com
apusersigs.appprotect.f5.comCreate a namespace for the PLM components, then create the registry pull secret using the credentials from the previous section. Replace <JWT> with your F5 WAF for NGINX JWT.
kubectl create namespace plm-system
kubectl create secret docker-registry regcred \
--namespace plm-system \
--docker-server=private-registry.nginx.com \
--docker-username=<JWT> \
--docker-password=none \
--dry-run=client --output yaml | kubectl apply -f -Create a values file for the Helm installation. Replace <BASE64_NGINX_REPO_CRT> and <BASE64_NGINX_REPO_KEY> with the base64-encoded contents of your nginx-repo.crt and nginx-repo.key files. To encode them, run:
base64 --wrap=0 < nginx-repo.crt
base64 --wrap=0 < nginx-repo.keyCreate /tmp/plm-values.yaml:
imagePullSecrets:
- name: regcred
securityUpdatesRepo:
cert: "<BASE64_NGINX_REPO_CRT>"
key: "<BASE64_NGINX_REPO_KEY>"
policyController:
image:
tag: "5.14.0"
compiler:
image:
tag: "5.14.0"
seaweedfsOperatorConfig:
seaweedfs:
image:
tag: "5.14.0"
seaweedfs-operator:
image:
tag: "5.14.0"
pullSecrets: regcredAdd the NGINX Helm repository and install the chart:
helm repo add nginx-stable https://helm.nginx.com/stable
helm repo update nginx-stable
helm upgrade --install plm nginx-stable/f5-waf-policy-controller \
--version 5.14.0 \
--namespace plm-system \
--values /tmp/plm-values.yamlWait for all PLM components to become ready. The Policy Controller’s init container waits for both the compiler service and the SeaweedFS S3 endpoint to be available before it starts, so the controller pod will show Init:0/1 until SeaweedFS is ready.
Wait for the SeaweedFS storage backend:
kubectl rollout status deployment/plm-seaweedfs-operator \
--namespace plm-system --timeout=120s
kubectl wait pods \
--selector app.kubernetes.io/name=seaweedfs \
--for=condition=Ready \
--namespace plm-system \
--timeout=180sWait for the Policy Controller:
kubectl rollout status deployment/plm-f5-waf-policy-controller \
--namespace plm-system --timeout=180sConfirm all eight pods are running:
kubectl get pods --namespace plm-systemExpected output:
NAME READY STATUS RESTARTS
plm-f5-waf-compiler-service-xxxxx 1/1 Running 0
plm-f5-waf-policy-controller-xxxxx 1/1 Running 0
plm-seaweedfs-operator-xxxxx 1/1 Running 0
plm-f5-waf-seaweed-master-0 1/1 Running 0
plm-f5-waf-seaweed-filer-0 1/1 Running 0
plm-f5-waf-seaweed-volume-0 1/1 Running 0
plm-f5-waf-seaweed-volume-1 1/1 Running 0
plm-f5-waf-seaweed-volume-2 1/1 Running 0Confirm the four CRDs are present:
kubectl get crd | grep appprotect.f5.comAll eight pods running and all four CRDs present confirms the PLM backend is ready.
NGINX Gateway Fabric fetches compiled bundles from in-cluster PLM storage. You set up storage access once, cluster-wide, at install time. It applies to every WAFPolicy that uses type: PLM.
Create a values.yaml file that enables WAF and sets the PLM storage connection details under nginxGateway.plmStorage:
nginxGateway:
plmStorage:
url: "https://plm-f5-waf-seaweed-filer.plm-system.svc.cluster.local"
credentialsSecretName: "plm-system/plm-f5-waf-seaweedfs-auth" # contains the seaweedfs_admin_secret field
tls:
caSecretName: "plm-ca-secret" # Secret with ca.crt for verifying the storage service
clientSSLSecretName: "plm-client-secret" # Secret with tls.crt/tls.key for mutual TLS
insecureSkipVerify: false # use only for testingCautionAlways use HTTPS with TLS verification (caSecretName) in production. AddclientSSLSecretNamefor mutual TLS in high-security environments, and never setinsecureSkipVerify: true.
credentialsSecretNameandcaSecretNamemust reference Secrets in the NGINX Gateway Fabric control plane namespace, unless you prefix them with<NAMESPACE>/.
Install NGINX Gateway Fabric by following the installation guide and using the NGINX Plus with WAF tab, and apply this values.yaml file in your install or upgrade command, specifying --values values.yaml.
The PLM installation creates the credentials Secret automatically, containing the S3 secret access key in the seaweedfs_admin_secret field (access key ID admin by default):
apiVersion: v1
kind: Secret
metadata:
name: plm-storage-credentials
namespace: nginx-gateway
type: Opaque
data:
seaweedfs_admin_secret: <BASE64_ENCODED_SECRET_ACCESS_KEY>NGINX Gateway Fabric watches the PLM credentials and TLS Secrets and rebuilds its storage client when they change, so you can rotate credentials without restarting the pod.
Deploy the customers and orders sample applications. The customers app returns a response containing fake sensitive data (credit card number and SSN), which you’ll use later to demonstrate data guard masking:
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: customers
spec:
replicas: 1
selector:
matchLabels:
app: customers
template:
metadata:
labels:
app: customers
spec:
containers:
- name: customers
image: hashicorp/http-echo:latest
args:
- "-listen=:8080"
- "-text=Customer List:\n\nName: John Doe\nCredit Card: 4111-1111-1111-1111\nSSN: 123-45-6789\n"
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: customers
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: customers
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 1
selector:
matchLabels:
app: orders
template:
metadata:
labels:
app: orders
spec:
containers:
- name: orders
image: nginxdemos/nginx-hello:plain-text
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: orders
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
selector:
app: orders
EOFThis section is typically owned by your security team. If that’s not you, share it with them before continuing.
PLM security logging profiles are defined as APLogConf custom resources. Create a namespace to hold your security resources, then define a log profile that logs illegal requests:
kubectl create namespace securitykubectl apply -f - <<EOF
apiVersion: appprotect.f5.com/v1
kind: APLogConf
metadata:
name: log-illegal
namespace: security
spec:
filter:
request_type: illegal
content:
format: default
max_request_size: any
max_message_size: 15k
EOFPLM compiles the log profile automatically. Wait for status.bundle.state to report ready before referencing it:
kubectl wait --for=jsonpath='{.status.bundle.state}'=ready aplogconf/log-illegal -n security --timeout=60sIf you skip this section, omit the securityLogs field in the WAFPolicy resource in the next steps.
This section is typically owned by your security team. They define the policy in the security namespace, separate from the Gateway namespace, so that security resources are managed independently from routing configuration. If that’s not you, share this section with them — you’ll need the APPolicy name and namespace before continuing to the next section.
The APPolicy resource defines the security policy. The PLM controller watches it, compiles it, and writes status.bundle with state: ready when the bundle is available.
Use the Inline tab for this guide’s primary workflow. The other tabs provide alternate policy-source methods.
Create an APPolicy resource with an inline policy that blocks all attack signatures:
kubectl apply -f - <<EOF
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: attack-signatures
namespace: security
spec:
policy:
name: attack-signatures-blocking
template:
name: POLICY_TEMPLATE_NGINX_BASE
applicationLanguage: utf-8
enforcementMode: blocking
signature-sets:
- name: All Signatures
block: true
alarm: true
cookies:
- name: "*"
attackSignaturesCheck: true
enforcementType: enforce
maskValueInLogs: false
EOFWait for the bundle to become ready:
kubectl wait --for=jsonpath='{.status.bundle.state}'=ready appolicy/attack-signatures -n security --timeout=60sBecause the APPolicy and APLogConf live in the security namespace but the WAFPolicy you create next targets a Gateway in the default namespace, create a ReferenceGrant in the security namespace to permit the cross-namespace reference:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-wafpolicy-refs
namespace: security
spec:
from:
- group: gateway.nginx.org
kind: WAFPolicy
namespace: default
to:
- group: appprotect.f5.com
kind: APPolicy
- group: appprotect.f5.com
kind: APLogConf
EOFTheReferenceGrantlives in thesecuritynamespace and must be created by whoever manages that namespace — typically your security team, not the platform engineer deploying the Gateway. Coordinate with them if you don’t have access. Without a matchingReferenceGrant, theWAFPolicyis rejected withResolvedRefs=Falseand reasonRefNotPermitted. If you put theAPPolicyandAPLogConfin the same namespace as theWAFPolicy, you can skip theReferenceGrant. See Troubleshoot WAFPolicy status for details.
Store your policy JSON in a Git repository and reference it from APPolicy.
Create an APPolicy resource that references the policy file by path:
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: <POLICY_NAME>
namespace: <NAMESPACE>
spec:
policy:
$ref: <PATH/TO/POLICY.JSON>
externalReferenceDetails:
repositoryDetails:
repository: https://github.com/<ORG>/<REPO>.git
ref: "<TAG_OR_COMMIT>"Replace <POLICY_NAME>, <NAMESPACE>, <PATH/TO/POLICY.JSON>, <ORG>, <REPO>, and <TAG_OR_COMMIT> with your values.
Pinrefto a tag or commit SHA rather than a branch name in production environments.
Apply the resource:
kubectl apply -f <POLICY_MANIFEST>.yamlFor private repositories, create a Kubernetes secret with your personal access token (PAT):
kubectl create secret generic git-token-secret \
--namespace <NAMESPACE> \
--from-literal=token=<GIT_PERSONAL_ACCESS_TOKEN>Then reference the secret in the APPolicy resource:
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: <POLICY_NAME>
namespace: <NAMESPACE>
spec:
policy:
$ref: <PATH/TO/POLICY.JSON>
externalReferenceDetails:
repositoryDetails:
repository: https://github.com/<ORG>/<REPO>.git
ref: "<TAG_OR_COMMIT>"
authentication:
token: git-token-secretCheck bundle.state:
kubectl get appolicy <POLICY_NAME> \
--namespace <NAMESPACE> \
--output jsonpath='State: {.status.bundle.state}{"\n"}Bundle: {.status.bundle.location}{"\n"}Compiler: {.status.bundle.compilerVersion}{"\n"}'The Policy Controller does not poll the Git repository for changes. To pick up changes to the referenced policy file, re-apply the APPolicy resource (or update its revision annotation) after you push changes to the repository.
The precompiled-bundle method lets you reference a .tgz policy bundle stored in an artifact registry (for example, Artifactory or Nexus). The Policy Controller imports the bundle and stores it in the SeaweedFS object store without recompiling it.
Use this method when:
- Your security team compiles and publishes bundles through an external pipeline.
- You want to decouple policy compilation from cluster operations.
Create an APPolicy resource that references your bundle:
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: <POLICY_NAME>
namespace: plm-system
spec:
policy:
$ref: "https://<ARTIFACT_REGISTRY_HOST>/<PATH/TO/POLICY_BUNDLE>.tgz"Replace <POLICY_NAME>, <ARTIFACT_REGISTRY_HOST>, and <PATH/TO/POLICY_BUNDLE> with your values.
Apply the resource:
kubectl apply -f <POLICY_MANIFEST_FILE>.yamlThe Policy Controller must be able to reach the artifact registry host over HTTPS. If the registry uses a private certificate authority, configure the Policy Controller to trust that CA.
The Policy Controller processes the bundle and updates the APPolicy status. Check the bundle.state field:
kubectl get appolicy <POLICY_NAME> \
--namespace plm-system \
--output jsonpath='State: {.status.bundle.state}{"\n"}Bundle: {.status.bundle.location}{"\n"}isCompiled: {.status.processing.isCompiled}{"\n"}'When the bundle is ready, the output looks like this:
State: ready
Bundle: s3://plm-system/bundles/<POLICY_NAME>_imported_<HASH>.tgz
isCompiled: falseisCompiled: false confirms the bundle was imported as-is and not recompiled.
bundle.state can be one of:
| State | Meaning |
|---|---|
pending |
The Policy Controller has not yet processed the resource. |
processing |
The Policy Controller is importing or storing the bundle. |
ready |
The bundle is stored and ready to use. bundle.location is populated. |
invalid |
The bundle could not be imported. Check the status for error detail. |
The Policy Controller does not poll the artifact registry for changes. To pick up a new version of a bundle, update the APPolicy resource to reference the new bundle URL (or bump its revision annotation) and re-apply it:
kubectl apply -f <UPDATED_POLICY_MANIFEST_FILE>.yamlCreate a Gateway. WAF is already enabled globally, so NGINX Gateway Fabric automatically deploys the WAF sidecar containers alongside the NGINX Pod:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: gateway
spec:
gatewayClassName: nginx
listeners:
- name: http
port: 80
protocol: HTTP
hostname: "*.example.com"
EOFCreate a WAFPolicy with type: PLM that references the APPolicy and APLogConf by name and namespace, and targets the Gateway:
kubectl apply -f - <<EOF
apiVersion: gateway.nginx.org/v1alpha1
kind: WAFPolicy
metadata:
name: gateway-base-protection
spec:
type: PLM
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: gateway
policyRef:
apPolicyRef:
name: attack-signatures
namespace: security
securityLogs:
- destination:
type: syslog
syslog:
server: syslog-svc.default.svc.cluster.local:514
logRef:
apLogConfRef:
name: log-illegal
namespace: security
EOFThis WAFPolicy protects every route attached to the Gateway. Later changes to the APPolicy or APLogConf spec trigger recompilation and an automatic re-fetch — no change to the WAFPolicy is required.
Create two HTTPRoutes — customers and orders — attached to the Gateway. Because the WAFPolicy targets the Gateway, both routes inherit WAF protection automatically:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: customers
spec:
parentRefs:
- name: gateway
sectionName: http
hostnames:
- "cafe.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /customers
backendRefs:
- name: customers
port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: orders
spec:
parentRefs:
- name: gateway
sectionName: http
hostnames:
- "cafe.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /orders
backendRefs:
- name: orders
port: 80
EOFConfirm the APPolicy and APLogConf bundles compiled successfully:
kubectl get appolicy attack-signatures -n security -o jsonpath='{.status.bundle.state}{"\n"}'
kubectl get aplogconf log-illegal -n security -o jsonpath='{.status.bundle.state}{"\n"}'Both commands should print ready.
Verify the WAFPolicy has been accepted and programmed:
kubectl describe wafpolicy gateway-base-protectionLook for three conditions in the output:
Status:
Conditions:
Message: The Policy is accepted
Observed Generation: 1
Reason: Accepted
Status: True
Type: Accepted
Message: All references are resolved
Observed Generation: 1
Reason: ResolvedRefs
Status: True
Type: ResolvedRefs
Message: Policy is programmed in the data plane
Observed Generation: 1
Reason: Programmed
Status: True
Type: ProgrammedIf any condition is False, the message field describes the problem. See Troubleshoot WAFPolicy status for guidance.
Verify that the NGINX Pod has all three containers running:
kubectl get pods -l app.kubernetes.io/name=gateway-nginxEach NGINX Pod should show 3/3 in the READY column, indicating the main NGINX container, waf-enforcer, and waf-config-mgr are all running:
NAME READY STATUS RESTARTS AGE
gateway-nginx-7f9b8d6c4d-xxxxx 3/3 Running 0 2mConfirm the Gateway was assigned an IP address and reports Programmed=True:
kubectl describe gateways.gateway.networking.k8s.io gatewayAddresses:
Type: IPAddress
Value: 192.0.2.1Save the public IP address and port of the Gateway into shell variables:
GW_IP=XXX.YYY.ZZZ.III
GW_PORT=<port number>Verify normal traffic flows. Send a request to the customers route — the response contains the fake sensitive data from the customers backend:
If you have a DNS record allocated forcafe.example.com, you can send the request directly to that hostname, without needing to resolve.
curl --resolve cafe.example.com:$GW_PORT:$GW_IP http://cafe.example.com:$GW_PORT/customersCustomer List:
Name: John Doe
Credit Card: 4111-1111-1111-1111
SSN: 123-45-6789The sensitive data passes through because the gateway-level attack-signatures policy only inspects inbound requests for attack patterns — it doesn’t mask outbound response data.
Verify attacks are blocked. Send a request with a cross-site scripting (XSS) payload:
curl --resolve cafe.example.com:$GW_PORT:$GW_IP "http://cafe.example.com:$GW_PORT/customers?x=</script>"The WAF detects the attack signature and rejects the request:
<html>
<head><title>Request Rejected</title></head>
...Verify the orders route is also protected. Since the policy targets the Gateway, all attached routes inherit protection:
curl --resolve cafe.example.com:$GW_PORT:$GW_IP "http://cafe.example.com:$GW_PORT/orders?x=</script>"<html>
<head><title>Request Rejected</title></head>
...The exact blocking response depends on your WAF policy configuration. Check the security log for a corresponding blocked event usingkubectl logs <nginx-pod-name> -c waf-enforcer.
In the previous step, you saw that the customers route returns sensitive data (credit card numbers and SSNs) in the response body. The gateway-level policy blocks inbound attacks, but doesn’t inspect outbound responses.
This pattern is a good example of a SecOps and app team collaboration: the security team defines a stricter policy for a specific service, and the platform engineer or app developer attaches it as a route-level override. The override applies only to the customers route — other routes continue using the gateway-level policy.
To protect sensitive data in responses, define a data guard APPolicy and apply it as a route-level override on the customers route:
kubectl apply -f - <<EOF
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: dataguard-blocking
namespace: security
spec:
policy:
name: dataguard-blocking
template:
name: POLICY_TEMPLATE_NGINX_BASE
applicationLanguage: utf-8
enforcementMode: blocking
data-guard:
enabled: true
creditCardNumbers: true
usSocialSecurityNumbers: true
EOFWait for the bundle to become ready, then create the route-level WAFPolicy:
kubectl wait --for=jsonpath='{.status.bundle.state}'=ready appolicy/dataguard-blocking -n security --timeout=60skubectl apply -f - <<EOF
apiVersion: gateway.nginx.org/v1alpha1
kind: WAFPolicy
metadata:
name: customers-strict-protection
spec:
type: PLM
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: customers
policyRef:
apPolicyRef:
name: dataguard-blocking
namespace: security
EOFThis policy overrides the gateway-level policy for the customers route only. Other routes attached to the Gateway continue to use the gateway-level policy.
Wait for the policy to be Programmed, then send the same request to the customers route:
kubectl wait --for=jsonpath='{.status.ancestors[0].conditions[?(@.type=="Programmed")].status}'=True wafpolicy/customers-strict-protection --timeout=60scurl --resolve cafe.example.com:$GW_PORT:$GW_IP http://cafe.example.com:$GW_PORT/customersThe credit card number and SSN are now masked in the response:
Customer List:
Name: John Doe
Credit Card: ***************1111
SSN: *******6789- F5 WAF for NGINX overview for architecture and policy lifecycle concepts.
- Configure policy sources for the other policy source types.
- Configure WAF settings for TLS, authentication, fail-open behavior, and WAF container settings.
- Troubleshoot WAFPolicy status if a condition is
False.