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

  • Have kubectl access 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.com available. 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

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.

Install the CRDs

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

Confirm all four CRDs are present:

kubectl get crd | grep appprotect.f5.com

Expected output:

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

Create the registry pull secret

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

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

Install the Policy Controller

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:

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

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

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

kubectl wait pods \
  --selector app.kubernetes.io/name=seaweedfs \
  --for=condition=Ready \
  --namespace plm-system \
  --timeout=180s

Wait for the Policy Controller:

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

Confirm all eight pods are running:

kubectl get pods --namespace plm-system

Expected 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 four CRDs are present:

kubectl get crd | grep appprotect.f5.com

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

Configure NGF to connect 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. 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:

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
Caution
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>/.

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

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

Configure security logging (optional)

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

If you skip this section, omit the securityLogs field in the WAFPolicy resource in the next steps.

Define the WAF policy

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:

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

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

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

Store your policy JSON in a Git repository and reference it from APPolicy.

Create an APPolicy resource that references the policy file by path:

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

Pin ref to a tag or commit SHA rather than a branch name in production environments.

Apply the resource:

kubectl apply -f <POLICY_MANIFEST>.yaml

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:

yaml
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

Check bundle.state:

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

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

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

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

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

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: syslog
      syslog:
        server: syslog-svc.default.svc.cluster.local:514
    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 — no change to the WAFPolicy is required.

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

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.

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 was assigned an IP address 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 into shell variables:

text
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 for cafe.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/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. 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>"
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)

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:

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

The credit card number and SSN are now masked in the response:

text
Customer List:

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

Next steps