Session Persistence

Learn how to configure session persistence using NGINX Gateway Fabric.

Overview

In this guide, you’ll learn how to configure session persistence for your application. Session persistence ensures that multiple requests from the same client are consistently routed to the same backend Pod. This is useful when your application maintains in-memory state (for example, shopping carts or user sessions). NGINX Gateway Fabric supports configuring session persistence via UpstreamSettingsPolicy resource or directly on HTTPRoute and GRPCRoute resources. For NGINX OSS users, using the ip_hash load-balancing method provides basic session affinity by routing requests from the same client IP to the same backend Pod. For NGINX Plus users, cookie-based session persistence can be configured using the sessionPersistence field in a Route. In this guide, you will deploy three applications:

  • An application configured with ip_hash load-balancing method.
  • An application configured with cookie–based session persistence (if you have access to NGINX Plus).
  • A regular application with default load-balancing.

These applications will showcase the benefits of session persistence for stateful workloads.

The NGINX directives discussed in this guide are:

Note

Important
Cookie-based SessionPersistence is only available for NGINX Plus users, with alternatives provided for NGINX OSS users. Session Persistence is a Gateway API field from the experimental release channel and is subject to change.

Before you begin

Install NGINX Gateway Fabric with NGINX Plus and experimental features enabled if you want to use cookie-based sessionPersistence. If you plan to use the ip_hash load-balancing method for session affinity instead, installing NGINX Gateway Fabric with NGINX OSS is sufficient.

To use Gateway API experimental resources, the Gateway API resources from the experimental channel must be installed before deploying NGINX Gateway Fabric. Additionally, NGINX Gateway Fabric must have experimental features enabled.

Caution
As noted in the Gateway API documentation, future releases of the Gateway API can include breaking changes to experimental resources and fields.

To install the Gateway API resources from the experimental channel, run the following:

kubectl kustomize "https://github.com/nginx/nginx-gateway-fabric/config/crd/gateway-api/experimental?ref=v2.2.1" | kubectl apply -f -
If you plan to use the edge version of NGINX Gateway Fabric, you can replace the version in ref with main, for example ref=main.

To enable experimental features on NGINX Gateway Fabric:

Using Helm: Set nginxGateway.gwAPIExperimentalFeatures.enable to true. An example can be found in the Installation with Helm guide.

Using Kubernetes manifests: Add the --gateway-api-experimental-features command-line flag to the deployment manifest args. An example can be found in the Installation with Kubernetes manifests guide.

Setup

Create the coffee, tea and latte applications:

yaml
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coffee
spec:
  replicas: 2
  selector:
    matchLabels:
      app: coffee
  template:
    metadata:
      labels:
        app: coffee
    spec:
      containers:
      - name: coffee
        image: nginxdemos/nginx-hello:plain-text
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: coffee
spec:
  ports:
  - port: 80
    targetPort: 8080
    protocol: TCP
    name: http
  selector:
    app: coffee
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tea
spec:
  replicas: 2
  selector:
    matchLabels:
      app: tea
  template:
    metadata:
      labels:
        app: tea
    spec:
      containers:
      - name: tea
        image: nginxdemos/nginx-hello:plain-text
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: tea
spec:
  ports:
  - port: 80
    targetPort: 8080
    protocol: TCP
    name: http
  selector:
    app: tea
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: latte
spec:
  replicas: 2
  selector:
    matchLabels:
      app: latte
  template:
    metadata:
      labels:
        app: latte
    spec:
      containers:
      - name: latte
        image: nginxdemos/nginx-hello:plain-text
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: latte
spec:
  ports:
  - port: 80
    targetPort: 8080
    protocol: TCP
    name: http
  selector:
    app: latte
EOF

This creates three Service resources and multiple Pods in the default namespace. The multiple replicas are needed to demonstrate stickiness to backend Pods.

kubectl get all -o wide
text
NAME                                 READY   STATUS    RESTARTS   AGE     IP            NODE                 NOMINATED NODE   READINESS GATES
pod/coffee-5b9c74f9d9-2zlqq          1/1     Running   0          3h19m   10.244.0.95   kind-control-plane   <none>           <none>
pod/coffee-5b9c74f9d9-7gfwn          1/1     Running   0          3h19m   10.244.0.94   kind-control-plane   <none>           <none>
pod/latte-d5f64f67f-9t2j5            1/1     Running   0          3h19m   10.244.0.96   kind-control-plane   <none>           <none>
pod/latte-d5f64f67f-drwc6            1/1     Running   0          3h19m   10.244.0.98   kind-control-plane   <none>           <none>
pod/tea-859766c68c-cnb8n             1/1     Running   0          3h19m   10.244.0.93   kind-control-plane   <none>           <none>
pod/tea-859766c68c-kttkb             1/1     Running   0          3h19m   10.244.0.97   kind-control-plane   <none>           <none>

NAME                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE     SELECTOR
service/coffee          ClusterIP   10.96.169.1    <none>        80/TCP    3h19m   app=coffee
service/latte           ClusterIP   10.96.42.39    <none>        80/TCP    3h19m   app=latte
service/tea             ClusterIP   10.96.81.103   <none>        80/TCP    3h19m   app=tea

Create a Gateway:

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

After creating the Gateway resource, NGINX Gateway Fabric will provision an NGINX Pod and Service fronting it to route traffic. Verify the gateway is created:

kubectl describe gateways.gateway.networking.k8s.io gateway

Verify the status is Accepted:

text
Status:
  Addresses:
    Type:   IPAddress
    Value:  10.96.36.219
  Conditions:
    Last Transition Time:  2026-01-09T05:40:37Z
    Message:               The Gateway is accepted
    Observed Generation:   1
    Reason:                Accepted
    Status:                True
    Type:                  Accepted
    Last Transition Time:  2026-01-09T05:40:37Z
    Message:               The Gateway is programmed
    Observed Generation:   1
    Reason:                Programmed
    Status:                True
    Type:                  Programmed

Save the public IP address and port(s) of the Gateway into shell variables:

text
GW_IP=XXX.YYY.ZZZ.III
GW_PORT=<port number>
In a production environment, you should have a DNS record for the external IP address that is exposed, and it should refer to the hostname that the gateway will forward for.

Session Persistence Methods

Choosing the right session persistence method for your environment

The choice between ip_hash and cookie-based session persistence depends on your use case.

The ip_hash load-balancing method provides basic IP-based affinity: as long as NGINX sees the real client IP and not many users share that IP, requests from the same client will usually go to the same upstream Pod. However, there are important limitations:

  • Shared IPs: When there is a load balancer or proxy in front of NGINX that does not preserve the real client IP, or when many users appear to come from a single IP address (for example, corporate NAT or VPNs), ip_hash operates on the shared IP rather than on individual users. This means many different users behind the same IP are all routed to the same upstream Pod, so you do not get true per-user stickiness.
  • Changing IPs: If a user’s apparent IP changes over time (for example, when a load balancer or NAT pool uses multiple egress addresses), that user can be rehashed to a different upstream Pod and lose stickiness.

Cookie-based session persistence with sticky cookie provides stronger, per-user stickiness. NGINX issues a session cookie, and all subsequent requests that present that cookie are routed to the same upstream Pod, regardless of changes in client IP or intermediate proxies. This is generally preferable for stateful, user-centric applications, while ip_hash can be a simpler option in NGINX OSS deployments where NGINX sees distinct, stable client IP addresses.

Session Persistence with NGINX OSS

In this section, you’ll configure a basic coffee HTTPRoute that routes traffic to the coffee Service. You’ll then attach an UpstreamSettingsPolicy to change the load-balancing method for that upstream to showcase session affinity behavior. NGINX hashes the client IP to select an upstream server, so requests from the same IP are routed to the same upstream as long as it is available. Session affinity quality with ip_hash depends on NGINX seeing the real client IP. In environments with external load balancers or proxies, operators must ensure appropriate real_ip_header/set_real_ip_from configuration so that $remote_addr reflects the end-user address otherwise, stickiness will be determined by the address of the front-end proxy rather than the actual client.

To create an HTTPRoute for the coffee service, copy and paste the following into your terminal:

yaml
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: coffee
spec:
  parentRefs:
  - name: gateway
    sectionName: http
  hostnames:
  - "cafe.example.com"
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /coffee
    backendRefs:
    - name: coffee
      port: 80
EOF

Verify the coffee HTTPRoute is Accepted:

text
Status:
  Parents:
    Conditions:
      Last Transition Time:  2025-12-09T23:51:52Z
      Message:               The Route is accepted
      Observed Generation:   1
      Reason:                Accepted
      Status:                True
      Type:                  Accepted
      Last Transition Time:  2025-12-09T23:51:52Z
      Message:               All references are resolved
      Observed Generation:   1
      Reason:                ResolvedRefs
      Status:                True
      Type:                  ResolvedRefs
    Controller Name:         gateway.nginx.org/nginx-gateway-controller

Now, let’s create an UpstreamSettingsPolicy targeting the coffee Service to change the load-balancing method for its upstream:

yaml
kubectl apply -f - <<EOF
apiVersion: gateway.nginx.org/v1alpha1
kind: UpstreamSettingsPolicy
metadata:
  name: lb-method
spec:
  targetRefs:
  - group: core
    kind: Service
    name: coffee
  loadBalancingMethod: "ip_hash"
EOF

Verify that the UpstreamSettingsPolicy is Accepted:

text
Status:
  Ancestors:
    Ancestor Ref:
      Group:      gateway.networking.k8s.io
      Kind:       Gateway
      Name:       gateway
      Namespace:  default
    Conditions:
      Last Transition Time:  2025-12-10T00:05:26Z
      Message:               The Policy is accepted
      Observed Generation:   1
      Reason:                Accepted
      Status:                True
      Type:                  Accepted
    Controller Name:         gateway.nginx.org/nginx-gateway-controller

Next, verify that the policy has been applied to the coffee upstream by inspecting the NGINX configuration:

kubectl exec -it deployments/gateway-nginx -- nginx -T

You should see the ip_hash directive on the coffee upstream:

text
upstream default_coffee_80 {
    ip_hash;
    zone default_coffee_80 1m;
    state /var/lib/nginx/state/default_coffee_80.conf;
}

In this example, the coffee Service currently has two backend Pods with IPs 10.244.0.95 and 10.244.0.94. We’ll send five requests to the /coffee endpoint and observe that the responses consistently come from the same backend Pod, demonstrating session affinity.

shell
for i in $(seq 5); do
  echo "Request #$i"
  curl -s -H "Host: cafe.example.com" \
    http://localhost:8080/coffee \
    | grep -E 'Server (address|name)'
  echo
done

You will observe that all responses are served by the Pod coffee-5b9c74f9d9-7gfwn with IP 10.244.0.94:8080:

text
Request #1
Server address: 10.244.0.94:8080
Server name: coffee-5b9c74f9d9-7gfwn

Request #2
Server address: 10.244.0.94:8080
Server name: coffee-5b9c74f9d9-7gfwn

Request #3
Server address: 10.244.0.94:8080
Server name: coffee-5b9c74f9d9-7gfwn

Request #4
Server address: 10.244.0.94:8080
Server name: coffee-5b9c74f9d9-7gfwn

Request #5
Server address: 10.244.0.94:8080
Server name: coffee-5b9c74f9d9-7gfwn

Session Persistence with NGINX Plus

You can configure session persistence by specifying the sessionPersistence field on an HTTPRouteRule or GRPCRouteRule. This configuration is translated to the sticky cookie directive on the NGINX data plane. In this guide, you’ll create a tea HTTPRoute with sessionPersistence configured at the rule level and then verify how traffic behaves when the route has multiple backend Pods.

yaml
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: tea
spec:
  parentRefs:
  - name: gateway
    sectionName: http
  hostnames:
  - "cafe.example.com"
  rules:
  - matches:
    - path:
        type: Exact
        value: /tea
    backendRefs:
    - name: tea
      port: 80
    sessionPersistence:
      sessionName: "cookie-tea"
      type: Cookie
      absoluteTimeout: 24h
      cookieConfig:
        lifetimeType: Permanent
EOF

Verify the tea HTTPRoute is Accepted:

text
Status:
  Parents:
    Conditions:
      Last Transition Time:  2025-12-10T00:15:12Z
      Message:               The Route is accepted
      Observed Generation:   1
      Reason:                Accepted
      Status:                True
      Type:                  Accepted
      Last Transition Time:  2025-12-10T00:15:12Z
      Message:               All references are resolved
      Observed Generation:   1
      Reason:                ResolvedRefs
      Status:                True
      Type:                  ResolvedRefs
    Controller Name:         gateway.nginx.org/nginx-gateway-controller

Next, verify that the tea upstream has a sticky cookie directive configured, which is responsible for issuing the session cookie and its attributes. The sticky cookie directive’s attributes are derived from the sessionPersistence configuration, such as the expiry (24h) and the route path (/tea). Inspect the NGINX configuration with:

kubectl exec -it deployments/gateway-nginx -- nginx -T
text
upstream default_tea_80_tea_default_0 {
    random two least_conn;
    zone default_tea_80_tea_default_0 1m;
    sticky cookie cookie-tea expires=24h path=/tea;
    state /var/lib/nginx/state/default_tea_80.conf;
}

In this example, the tea Service has two backend Pods with IPs 10.244.0.93 and 10.244.0.97. We’ll send five requests to the /tea endpoint and observe that all responses are served by the same backend Pod, demonstrating cookie-based session persistence.

First, send a request to /tea and store the session cookie:

shell
curl -v -c /tmp/tea-cookies.txt \
  -H "Host: cafe.example.com" \
  http://localhost:8080/tea

You’ll see a cookie being set, for example:

text
* Added cookie cookie-tea="2878e97a4c7a8406b791aa0bd0b2f145" for domain cafe.example.com, path /tea, expire 1765417195
< Set-Cookie: cookie-tea=2878e97a4c7a8406b791aa0bd0b2f145; expires=Thu, 11-Dec-25 01:39:55 GMT; max-age=86400; path=/tea

Next, send five requests using the stored cookie:

shell
for i in $(seq 5); do
  echo "Request #$i"
  curl -s -b /tmp/tea-cookies.txt \
    -H "Host: cafe.example.com" \
    http://localhost:8080/tea \
    | grep -E 'Server (address|name)'
  echo
done

All responses are served by the same backend Pod, tea-859766c68c-cnb8n with IP 10.244.0.93:8080, confirming session persistence:

text
Request #1
Server address: 10.244.0.93:8080
Server name: tea-859766c68c-cnb8n

Request #2
Server address: 10.244.0.93:8080
Server name: tea-859766c68c-cnb8n

Request #3
Server address: 10.244.0.93:8080
Server name: tea-859766c68c-cnb8n

Request #4
Server address: 10.244.0.93:8080
Server name: tea-859766c68c-cnb8n

Request #5
Server address: 10.244.0.93:8080
Server name: tea-859766c68c-cnb8n

Regular Application

We’ll create routing rules for latte application without any session affinity or persistence settings and then verify how the traffic behaves.

Let’s create the latte HTTPRoute:

yaml
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: latte
spec:
  parentRefs:
  - name: gateway
    sectionName: http
  hostnames:
  - "cafe.example.com"
  rules:
  - matches:
    - path:
        type: Exact
        value: /latte
    backendRefs:
    - name: latte
      port: 80
EOF

Verify the NGINX configuration:

kubectl exec -it deployments/gateway -- nginx -T
text
upstream default_latte_80 {
    random two least_conn;
    zone default_latte_80 1m;
    state /var/lib/nginx/state/default_latte_80.conf;
}

In this example, the latte Service currently has two backend Pods with IPs 10.244.0.96 and 10.244.0.98. We’ll send five requests to the /latte endpoint and observe which backend Pod serves each response to understand how a regular backend behaves without any session affinity or persistence configured.

shell
for i in $(seq 5); do
  echo "Request #$i"
  curl -s -H "Host: cafe.example.com" \
    http://localhost:8080/latte \
    | grep -E 'Server (address|name)'
  echo
done

You will see responses coming from both backend Pods, for example:

text
Request #1
Server address: 10.244.0.98:8080
Server name: latte-d5f64f67f-drwc6

Request #2
Server address: 10.244.0.96:8080
Server name: latte-d5f64f67f-9t2j5

Request #3
Server address: 10.244.0.98:8080
Server name: latte-d5f64f67f-drwc6

Request #4
Server address: 10.244.0.98:8080
Server name: latte-d5f64f67f-drwc6

Request #5
Server address: 10.244.0.96:8080
Server name: latte-d5f64f67f-9t2j5

Because there is no session persistence configured for latte, traffic is distributed across both backend Pods according to the default load-balancing method, and requests from the same client are not guaranteed to hit the same Pod.

Further reading