Get started with F5 WAF for NGINX (PLM)
Use this tutorial to set up end-to-end traffic protection with F5 WAF for NGINX using Policy Lifecycle Management (PLM). By the end, you’ll 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).
Before you start, make sure you have:
-
kubectlaccess to a Kubernetes cluster. -
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.
-
Your F5 WAF for NGINX JWT from MyF5. To get it:
- Log in to MyF5.
- Go to My Products & Plans > Subscriptions to see your active subscriptions.
- Find your NGINX products or services subscription and select the Subscription ID for details.
- Download the JSON Web Token (JWT) from the subscription page.
The Connectivity Stack for Kubernetes JWT does not work with NGINX Plus reporting. Use a regular NGINX Plus instance JWT. -
Your private registry credentials Secret for
private-registry.nginx.com. You’ll reference this Secret when you install NGINX Gateway Fabric.
The following requirements apply to the PLM backend you’ll install in this tutorial:
- A Kubernetes cluster with a default StorageClass that supports dynamic provisioning. The PLM object store relies on PersistentVolumeClaims. Without a default StorageClass, the SeaweedFS pods stay
Pending. - Helm 3.x installed. The PLM backend installs as a Helm chart.
- An F5 WAF for NGINX JWT from MyF5, used to pull images from
private-registry.nginx.com. - Optionally,
nginx-repo.crtandnginx-repo.keyfrom MyF5, needed only for authenticated signature updates frompkgs.nginx.com.
PLM ships with an embedded SeaweedFS S3-compatible object store. The bundled SeaweedFS operator deploys and manages it. You don’t need to provide an external S3 bucket. The PLM controller writes compiled policy bundles to this store; the data plane reads from it.
By default, the deployment creates one master pod, one filer pod, and three volume pods, each backed by its own PVC. The chart generates credentials for the store and saves them in the <RELEASE>-f5-waf-seaweedfs-auth Secret.
By default, communication between PLM and the object store uses unencrypted HTTP. To enable TLS, see the PLM chart values (helm show values nginx-stable/f5-waf-policy-controller).
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.
Create a namespace for the PLM components, store your JWT in a Kubernetes Secret, then create the registry pull secret for the private F5 container registry.
-
Create the namespace and store your JWT. The following commands assume your JWT file is named
license.jwt:shell kubectl create namespace plm-system kubectl create secret generic jwt-reg-secret \ --namespace plm-system \ --from-file=license.jwt -
Retrieve the JWT from the Secret and create the registry pull secret:
shell JWT=$(kubectl get secret jwt-reg-secret \ --namespace plm-system \ -o jsonpath='{.data.license\.jwt}' | base64 -d) 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.
The securityUpdatesRepo.cert and securityUpdatesRepo.key fields are optional. They are only required if your signature repository needs certificate-based authentication. The Policy Controller starts successfully with these fields left empty.
If your signature repository requires them, 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>" # optional: only needed for authenticated signature repository access
key: "<BASE64_NGINX_REPO_KEY>" # optional: only needed for authenticated signature repository access
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: regcredBy default, communication between PLM components and the SeaweedFS object store uses unencrypted HTTP. To enable TLS, add a certificates block to /tmp/plm-values.yaml:
seaweedfsOperatorConfig:
seaweedfs:
certificates:
enabled: trueCreate Secrets before installingThe PLM chart does not generate certificates. You must create the five Secrets listed in the commands below before runninghelm upgrade --install. If any Secret is missing, the SeaweedFS pods will fail to mount their certificates and will not start.
Enabling TLS on an existing installIf you’re enabling TLS on an existing installation, the storage backend restarts and objects written before the switch can become orphaned. See the APPolicy showsinvalidwithunexpected EOFafter enabling TLS entry in the troubleshooting section. A fresh installation with TLS enabled from the start doesn’t have this issue.
Create the Secrets from your CA and certificate files before installing. The chart expects Secret names in the form <release>-f5-waf-seaweedfs-<component> — for the plm release name used in this tutorial, those are:
kubectl create secret generic plm-f5-waf-seaweedfs-ca-cert \
--namespace plm-system \
--from-file=tls.crt=<PATH/TO/CA_CERT> \
--from-file=ca.crt=<PATH/TO/CA_CERT>
kubectl create secret tls plm-f5-waf-seaweedfs-master-cert \
--namespace plm-system \
--cert=<PATH/TO/MASTER_CERT> \
--key=<PATH/TO/MASTER_KEY>
kubectl create secret tls plm-f5-waf-seaweedfs-volume-cert \
--namespace plm-system \
--cert=<PATH/TO/VOLUME_CERT> \
--key=<PATH/TO/VOLUME_KEY>
kubectl create secret tls plm-f5-waf-seaweedfs-filer-cert \
--namespace plm-system \
--cert=<PATH/TO/FILER_CERT> \
--key=<PATH/TO/FILER_KEY>
kubectl create secret tls plm-f5-waf-seaweedfs-client-cert \
--namespace plm-system \
--cert=<PATH/TO/CLIENT_CERT> \
--key=<PATH/TO/CLIENT_KEY>The CA Secret requires both tls.crt and ca.crt keys, both pointing to the same CA certificate file. The PLM chart mounts the CA using tls.crt into the Policy Controller, compiler, and SeaweedFS pods. The data plane’s S3 client reads ca.crt from the same Secret when verifying the storage endpoint. The four component Secrets use kubectl create secret tls, which produces tls.crt and tls.key — no ca.crt key is needed for them.
Replace each <PATH/TO/*> placeholder with the path to the corresponding certificate and key file from your PKI. The CA must sign all component certificates. If you don’t have an existing PKI, generate a CA and sign the five component certificates before proceeding.
Add 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.yamlTo see all available configuration options for the PLM chart, run:
helm show values nginx-stable/f5-waf-policy-controller --version 5.14.0Wait 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=120sThe SeaweedFS operator creates the SeaweedFS pods after it reconciles the SeaweedFS custom resource, so there is a window where the operator deployment is ready but no SeaweedFS pods exist yet. Poll until the pods appear and are ready:
end=$((SECONDS + 300))
until kubectl wait pods \
--selector app.kubernetes.io/name=seaweedfs \
--for=condition=Ready \
--namespace plm-system \
--timeout=10s 2>/dev/null; do
if [ $SECONDS -ge $end ]; then
echo "Timed out waiting for SeaweedFS pods"
exit 1
fi
sleep 5
doneWait for the Policy Controller:
kubectl rollout status deployment/plm-f5-waf-policy-controller \
--namespace plm-system --timeout=180sConfirm all pods are running:
kubectl get pods --namespace plm-systemExample 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 CRDs are present:
kubectl get crd | grep appprotect.f5.comExpected output:
aplogconfs.appprotect.f5.com
appolicies.appprotect.f5.com
apsignatures.appprotect.f5.com
apusersigs.appprotect.f5.comAll eight pods running and all four CRDs present confirms the PLM backend is ready.
Skip this step on a fresh install — Helm installs the CRDs automatically. Only follow these steps when upgrading an existing PLM installation.
When upgrading PLM, apply the CRDs manually before running helm upgrade:
kubectl apply -f https://raw.githubusercontent.com/nginx/waf-policy-controller/5.14.0/manifests/1-deploy-crds.yamlThese are the most common failures during PLM installation, roughly in order of likelihood.
Pods stuck in ImagePullBackOff
The JWT is wrong, expired, or contains a line break. Check the events log:
kubectl get events --namespace plm-system --field-selector reason=FailedUse the full JWT string as the registry username. Use the literal string none as the password.
Policy Controller stuck in Init:0/1
The Init:0/1 state is expected during startup. The init container waits for the compiler service and the S3 endpoint before it starts. If the pod stays in Init:0/1 for more than a few minutes, check that the SeaweedFS pods are Running:
kubectl get pods --namespace plm-system --selector app.kubernetes.io/name=seaweedfsThe most common cause is PVCs stuck in Pending because the cluster has no default StorageClass.
SeaweedFS pods Pending
SeaweedFS pods stay Pending when the cluster has no default StorageClass or insufficient capacity. Check the PVCs and available storage classes:
kubectl get pvc --namespace plm-system
kubectl get storageclassAPPolicy shows invalid with unexpected EOF after enabling TLS
Enabling TLS on an existing installation restarts the storage backend. Objects written before TLS was enabled can become orphaned. Check the filer log:
kubectl logs --namespace plm-system plm-f5-waf-seaweed-filer-0 | grep "not found"If the output contains volume N not found, orphaned objects exist. Delete the affected APPolicy resource and reapply it. The Policy Controller regenerates the bundle.
Helm install fails on a ClusterRole
If the error references seaweed-editor-role or seaweed-viewer-role, another PLM installation already exists in the cluster. Only one PLM installation is supported per cluster. Remove the existing release before installing.
Use the Policy Controller logs to diagnose any policy-related failure:
kubectl logs --namespace plm-system deploy/plm-f5-waf-policy-controller -c policy-controllerThe-c policy-controllerflag is required because the pod has more than one container. The containers are distroless, sokubectl execisn’t available for interactive debugging.
NGINX Gateway Fabric fetches compiled bundles from in-cluster PLM storage. You set up storage access once, cluster-wide, at install time. This configuration 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 testingUse HTTPS in productionAlways 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>/.
nginxGateway:
plmStorage:
url: "http://plm-f5-waf-seaweed-filer.plm-system.svc.cluster.local:8333"
credentialsSecretName: "plm-system/plm-f5-waf-seaweedfs-auth" # contains the seaweedfs_admin_secret fieldInstall NGINX Gateway Fabric by following the installation guide and using the NGINX Plus with WAF tab. Apply this values.yaml file in your install or upgrade command by 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 reloads the PLM credentials and TLS Secrets when they change, so you can rotate credentials without restarting the pod.
If you install NGINX Gateway Fabric using Kubernetes manifests, use the equivalent plm-storage-* flags documented in the command-line reference.
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 the security team. If you’re not on the security team, share this section with them before continuing.
Create the security namespace. The security team’s APPolicy and APLogConf resources live here. In NGINX Gateway Fabric, the ReferenceGrant that permits cross-namespace WAFPolicy references also lives in this namespace.
kubectl create namespace securityIf you skip this sectionOmit thesecurityLogsfield when you create theWAFPolicyin Deploy the Gateway and attach WAFPolicy.
This section is typically owned by the security team. If you’re not on the security team, share this section with them before continuing.
PLM security logging profiles are defined as APLogConf custom resources. Define a log profile that logs illegal requests:
kubectl 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=60sThe security team usually owns this section. They define the policy in the security namespace, separate from the Gateway namespace, so they can manage security resources independently from routing configuration. If you’re not on the security team, share this section with them. You’ll need the APPolicy name and namespace before continuing.
The APPolicy resource defines the security policy. The PLM controller watches the resource, compiles the policy, and writes status.bundle with state: ready when the bundle is available.
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=60sStore your policy JSON in a Git repository and reference the file from an APPolicy resource.
Create an APPolicy resource that references the policy file by path. Replace <POLICY_NAME>, <NAMESPACE>, <PATH/TO/POLICY.JSON>, <ORG>, <REPO>, and <TAG_OR_COMMIT> with your values:
kubectl apply -f - <<'EOF'
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>"
EOFPin ref to a tag in productionPinrefto a tag or commit SHA rather than a branch name in production environments.
Check that the bundle compiled successfully:
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 output shows State: ready when compilation succeeds.
For 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:
kubectl apply -f - <<'EOF'
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-secret
EOFThe Policy Controller doesn’t poll the Git repository for changes. It fetches the policy file when the APPolicy spec changes.
To pick up a new version of the policy, push your changes to the repository. Then update ref in the APPolicy resource to the new tag or commit SHA and reapply the resource. Reapplying an unchanged APPolicy doesn’t trigger a fetch. Changing an annotation doesn’t trigger a fetch either.
If you need to re-fetch the same ref (for example, after force-updating a tag), delete the APPolicy resource and recreate it.
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 the bundle.
Use this method when:
- Your security team compiles and publishes bundles through an external pipeline.
- You want to separate policy compilation from cluster operations.
Create an APPolicy resource that references your bundle. Replace <POLICY_NAME>, <ARTIFACT_REGISTRY_HOST>, and <PATH/TO/POLICY_BUNDLE> with your values:
kubectl apply -f - <<'EOF'
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: <POLICY_NAME>
namespace: security
spec:
policy:
$ref: "https://<ARTIFACT_REGISTRY_HOST>/<PATH/TO/POLICY_BUNDLE>.tgz"
EOFConfigure CA trust for private registriesThe Policy Controller must reach the artifact registry over HTTPS. If the registry uses a private certificate authority (CA), mount the CA certificate into the Policy Controller pod and set theSSL_CERT_FILEenvironment variable to its path.SSL_CERT_FILEreplaces the system trust store entirely. It doesn’t append to the system trust store. If SeaweedFS TLS is also turned on, combine both CAs into a single file and reference that file.
If the APPolicy status shows x509: certificate signed by unknown authority, the Policy Controller doesn’t trust the artifact registry CA. Check the status for the full error:
kubectl describe appolicy <POLICY_NAME> --namespace securityThe Policy Controller processes the bundle and updates the APPolicy status. Check the bundle.state field:
kubectl get appolicy <POLICY_NAME> \
--namespace security \
--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 without recompilation.
bundle.state can be one of:
| State | Meaning |
|---|---|
pending |
The Policy Controller hasn’t yet processed the resource. |
processing |
The Policy Controller is importing or storing the bundle. |
ready |
The bundle is stored and ready to use. The Policy Controller has populated bundle.location. |
invalid |
The Policy Controller couldn’t import the bundle. Check the status for error detail. |
The Policy Controller doesn’t poll the artifact registry for changes. To pick up a new version of a bundle, update the $ref URL in your APPolicy resource and reapply the resource. Changing an annotation doesn’t trigger a new download. If you need to re-fetch the same URL, delete the APPolicy resource and recreate it. Replace <POLICY_NAME>, <ARTIFACT_REGISTRY_HOST>, and <PATH/TO/UPDATED_POLICY_BUNDLE> with your values:
kubectl apply -f - <<'EOF'
apiVersion: appprotect.f5.com/v1
kind: APPolicy
metadata:
name: <POLICY_NAME>
namespace: security
spec:
policy:
$ref: "https://<ARTIFACT_REGISTRY_HOST>/<PATH/TO/UPDATED_POLICY_BUNDLE>.tgz"
EOFThe APPolicy and APLogConf are in the security namespace, but the WAFPolicy you create next targets a Gateway in the default namespace. To permit the cross-namespace reference, create a ReferenceGrant in the security namespace:
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
EOFSecurity team action requiredTheReferenceGrantlives 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.
Create 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: stderr
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. You don’t need to update the WAFPolicy.
This guide enables WAF globally on the GatewayClass-levelNginxProxy. To enable WAF on a specific Gateway only, create a per-GatewayNginxProxyand reference it from the Gateway’sinfrastructure.parametersRef. See Enable WAF per Gateway.
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
EOFGRPCRoutes inherit WAF protection the same way HTTPRoutes do.
Confirm 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. The status.bundle.location field on each resource confirms where the compiled bundle is stored in PLM storage.
If a bundle doesn’t reach ready, check the bundle.state value:
| State | Meaning |
|---|---|
pending |
The Policy Controller hasn’t processed the resource yet. |
processing |
The Policy Controller is compiling the policy. |
ready |
The bundle compiled successfully. bundle.location is populated. |
invalid |
Compilation failed. Check the status message and Policy Controller logs. |
Check the Policy Controller logs for compilation errors:
kubectl logs -n plm-system deploy/plm-f5-waf-policy-controller -c policy-controllerVerify 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 has an IP address assigned 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 to shell variables:
GW_IP=192.0.2.1
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 forcafe.example.com, you can send the request directly to that hostname without--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. Because 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.
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 is a common pattern for 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.
One WAFPolicy per resourceOnly oneWAFPolicycan target a given resource at a given level. If a secondWAFPolicytargets the same Gateway or route, it is rejected withAccepted=Falseand reasonConflicted. See Policy attachment.
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/customersWAF now masks the credit card number and SSN 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.