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 APPolicy and APLogConf custom resources
  • Attached a WAFPolicy to 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 begin

Before you start, make sure you have:

  • kubectl access 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:

    1. Log in to MyF5.
    2. Go to My Products & Plans > Subscriptions to see your active subscriptions.
    3. Find your NGINX products or services subscription and select the Subscription ID for details.
    4. 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.

PLM prerequisites

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.crt and nginx-repo.key from MyF5, needed only for authenticated signature updates from pkgs.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).

Example values

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

Deploy PLM infrastructure

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 the registry pull secret

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.

  1. 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
  2. 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 -

Install the Policy Controller

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:

shell
base64 --wrap=0 < nginx-repo.crt
base64 --wrap=0 < nginx-repo.key

Create /tmp/plm-values.yaml:

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: regcred

Enable TLS for PLM storage (optional)

By 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:

yaml
seaweedfsOperatorConfig:
  seaweedfs:
    certificates:
      enabled: true
Create Secrets before installing
The PLM chart does not generate certificates. You must create the five Secrets listed in the commands below before running helm 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 install
If you’re enabling TLS on an existing installation, the storage backend restarts and objects written before the switch can become orphaned. See the APPolicy shows invalid with unexpected EOF after 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:

shell
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.

Install the chart

Add the NGINX Helm repository and install the chart:

shell
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.yaml

To see all available configuration options for the PLM chart, run:

helm show values nginx-stable/f5-waf-policy-controller --version 5.14.0

Verify the deployment

Wait 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:

shell
kubectl rollout status deployment/plm-seaweedfs-operator \
  --namespace plm-system --timeout=120s

The 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:

shell
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
done

Wait for the Policy Controller:

shell
kubectl rollout status deployment/plm-f5-waf-policy-controller \
  --namespace plm-system --timeout=180s

Confirm all pods are running:

kubectl get pods --namespace plm-system

Example output:

text
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   0

Confirm the CRDs are present:

kubectl get crd | grep appprotect.f5.com

Expected output:

text
aplogconfs.appprotect.f5.com
appolicies.appprotect.f5.com
apsignatures.appprotect.f5.com
apusersigs.appprotect.f5.com

All eight pods running and all four CRDs present confirms the PLM backend is ready.

Update the CRDs

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.yaml

Troubleshoot the deployment

These 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=Failed

Use 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=seaweedfs

The 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:

shell
kubectl get pvc --namespace plm-system
kubectl get storageclass
APPolicy 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.

Check the Policy Controller logs

Use the Policy Controller logs to diagnose any policy-related failure:

kubectl logs --namespace plm-system deploy/plm-f5-waf-policy-controller -c policy-controller
The -c policy-controller flag is required because the pod has more than one container. The containers are distroless, so kubectl exec isn’t available for interactive debugging.

Connect NGINX Gateway Fabric to PLM storage

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.

yaml
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 testing
Use HTTPS in production
Always use HTTPS with TLS verification (caSecretName) in production. Add clientSSLSecretName for mutual TLS in high-security environments, and never set insecureSkipVerify: true.
credentialsSecretName and caSecretName must reference Secrets in the NGINX Gateway Fabric control plane namespace, unless you prefix them with <NAMESPACE>/.
yaml
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 field

Install 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):

yaml
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 sample application

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:

yaml
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
EOF

Create the security namespace

This 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 security

Configure security logging (optional)

If you skip this section
Omit the securityLogs field when you create the WAFPolicy in 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:

yaml
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
EOF

PLM 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=60s

Define the WAF policy

The 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:

yaml
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
EOF

Wait for the bundle to become ready:

kubectl wait --for=jsonpath='{.status.bundle.state}'=ready appolicy/attack-signatures -n security --timeout=60s

Store 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:

shell
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>"
EOF
Pin ref to a tag in production
Pin ref to a tag or commit SHA rather than a branch name in production environments.

Check that the bundle compiled successfully:

shell
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):

shell
kubectl create secret generic git-token-secret \
  --namespace <NAMESPACE> \
  --from-literal=token=<GIT_PERSONAL_ACCESS_TOKEN>

Then reference the Secret in the APPolicy resource:

shell
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
EOF

The 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:

shell
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"
EOF
Configure CA trust for private registries
The 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 the SSL_CERT_FILE environment variable to its path. SSL_CERT_FILE replaces 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 security

The Policy Controller processes the bundle and updates the APPolicy status. Check the bundle.state field:

shell
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:

text
State:      ready
Bundle:     s3://plm-system/bundles/<POLICY_NAME>_imported_<HASH>.tgz
isCompiled: false

isCompiled: 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:

shell
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"
EOF

The 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:

yaml
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
EOF
Security team action required
The ReferenceGrant lives in the security namespace 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 matching ReferenceGrant, the WAFPolicy is rejected with ResolvedRefs=False and reason RefNotPermitted. If you put the APPolicy and APLogConf in the same namespace as the WAFPolicy, you can skip the ReferenceGrant. See Troubleshoot WAFPolicy status for details.

Deploy the Gateway and attach WAFPolicy

Create a Gateway. WAF is already enabled globally, so NGINX Gateway Fabric automatically deploys the WAF sidecar containers alongside the NGINX Pod:

yaml
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"
EOF

Create a WAFPolicy with type: PLM that references the APPolicy and APLogConf by name and namespace, and targets the Gateway:

yaml
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
EOF

This 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-level NginxProxy. To enable WAF on a specific Gateway only, create a per-Gateway NginxProxy and reference it from the Gateway’s infrastructure.parametersRef. See Enable WAF per Gateway.

Configure HTTPRoutes

Create two HTTPRoutes — customers and orders — attached to the Gateway. Because the WAFPolicy targets the Gateway, both routes inherit WAF protection automatically:

yaml
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
EOF
GRPCRoutes inherit WAF protection the same way HTTPRoutes do.

Validate policy compilation and application

Confirm the APPolicy and APLogConf bundles compiled successfully:

shell
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-controller

Verify the WAFPolicy has been accepted and programmed:

kubectl describe wafpolicy gateway-base-protection

Look for three conditions in the output:

text
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:                  Programmed

If 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-nginx

Each NGINX Pod should show 3/3 in the READY column, indicating the main NGINX container, waf-enforcer, and waf-config-mgr are all running:

text
NAME                             READY   STATUS    RESTARTS   AGE
gateway-nginx-7f9b8d6c4d-xxxxx   3/3     Running   0          2m

Test deployment and policy enforcement

Confirm the Gateway has an IP address assigned and reports Programmed=True:

kubectl describe gateways.gateway.networking.k8s.io gateway
text
Addresses:
  Type:   IPAddress
  Value:  192.0.2.1

Save the public IP address and port of the Gateway to shell variables:

text
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 for cafe.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/customers
text
Customer List:

Name: John Doe
Credit Card: 4111-1111-1111-1111
SSN: 123-45-6789

The 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:

text
<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>"
text
<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 using kubectl logs <NGINX_POD_NAME> -c waf-enforcer.

Apply a route-level override (optional)

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 resource
Only one WAFPolicy can target a given resource at a given level. If a second WAFPolicy targets the same Gateway or route, it is rejected with Accepted=False and reason Conflicted. 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:

yaml
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
EOF

Wait 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=60s
yaml
kubectl 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
EOF

This 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=60s
curl --resolve cafe.example.com:$GW_PORT:$GW_IP http://cafe.example.com:$GW_PORT/customers

WAF now masks the credit card number and SSN in the response:

text
Customer List:

Name: John Doe
Credit Card: ***************1111
SSN: *******6789

Next steps