Controlling upstream HTTP versions in NGINX Ingress Controller

—

by

in , ,

If you run microservices in Kubernetes, NGINX Ingress Controller talks to your backend pods using HTTP/1.1 by default.

For most web apps, HTTP/1.1 is fine. But more backend services now accept HTTP/2 without TLS (h2c), which multiplexes many requests over one connection. Some older backends still need HTTP/1.0. (gRPC backends were already covered: nginx.org/grpc-services and type: grpc always proxy over HTTP/2.)

Until recently, there wasn’t a clean, native way in NGINX Ingress Controller to tell NGINX which HTTP version it should use when proxying traffic to your pods. People had to write custom config snippets or work around it.

NGINX Ingress Controller 5.7.0 solves this. Here is what we built, how it works, and how you can use it in your cluster.


What we added

We wanted this feature to be as simple and automatic as possible, while still letting you override it when you need to. We added three things:

  1. Automatic detection from Kubernetes Services: If your Service has appProtocol: kubernetes.io/h2c, the controller configures NGINX to use HTTP/2 (proxy_http_version 2;). You don’t have to configure any annotations at all.
  2. A new Ingress annotation: To set the version manually on an Ingress, use nginx.org/proxy-http-version: "1.0", "1.1", or "2".
  3. A new VirtualServer field: If you use VirtualServer resources instead of standard Ingress, there is now a native proxy-http-version field under upstreams.

How it works in practice

Let’s look at a simple example with two services: tea (which accepts h2c) and coffee (which uses the default, HTTP/1.1).

1. Setting up the Service

In Kubernetes, you can tell the cluster what protocol your pod speaks using the appProtocol field on the Service port:

apiVersion: v1
kind: Service
metadata:
  name: tea-svc
spec:
  ports:
    - port: 80
      targetPort: 8080
      name: h2c
      appProtocol: kubernetes.io/h2c   # Tells NGINX Ingress Controller this port speaks cleartext HTTP/2
  selector:
    app: tea

kubernetes.io/h2c is the only appProtocol value the controller acts on. Make sure your pod accepts h2c connections on that port, or requests to it will fail with 502 Bad Gateway. If the backend is NGINX, http2 on; on a plain listen is enough:

server {
    listen 8080;
    http2 on;   # Accepts h2c and HTTP/1.x on the same port
}

See the Kubernetes docs on application protocol. The example’s cafe.yaml has the full tea and coffee Deployments and Services, including coffee-svc. Its backends echo the protocol they received, which we use below to check the result.

For tea-svc, you don’t need to do anything else.

2. Creating the Ingress

Now we create a standard Ingress to route /tea and /coffee (the example’s cafe-ingress-app-protocol.yaml):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cafe-ingress-app-protocol
spec:
  ingressClassName: nginx
  rules:
    - host: cafe.example.com
      http:
        paths:
          - path: /tea
            pathType: Prefix
            backend:
              service:
                name: tea-svc
                port:
                  number: 80
          - path: /coffee
            pathType: Prefix
            backend:
              service:
                name: coffee-svc
                port:
                  number: 80

Notice that we didn’t put any version annotations on cafe-ingress-app-protocol.

When NGINX Ingress Controller generates the NGINX config (/etc/nginx/conf.d/default-cafe-ingress-app-protocol.conf), the relevant part looks like this:

location "/tea" {
    ...
    proxy_http_version 2;    # Picked up from tea-svc
    proxy_pass http://default-cafe-ingress-app-protocol-cafe.example.com-tea-svc-80;
}

location "/coffee" {
    ...
    # No directive here, so NGINX uses its default, HTTP/1.1
    proxy_pass http://default-cafe-ingress-app-protocol-cafe.example.com-coffee-svc-80;
}

The controller saw appProtocol: kubernetes.io/h2c on tea-svc and automatically rendered proxy_http_version 2;. To see the config on your own cluster, run nginx -T in the controller pod:

kubectl exec -n nginx-ingress <controller-pod> -- nginx -T | grep -A20 'location "/tea"'

To check what the backends actually received, save the controller’s IP address and HTTP port in IC_IP and IC_HTTP_PORT, then send a request to each path:

$ curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/tea
Server name: tea-5c457db9-4dzrk
URI: /tea
Upstream protocol: HTTP/2.0
Connection header:

$ curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/coffee
Server name: coffee-7586895968-r26zn
URI: /coffee
Upstream protocol: HTTP/1.1
Connection header:

/tea reached its backend over cleartext HTTP/2, and /coffee over HTTP/1.1.


What happens when there’s a conflict? (Precedence)

What if the Service says one thing, but you want to override it on the Ingress?

The rule for who wins is:

nginx.org/proxy-http-version annotation or VirtualServer field > Service appProtocol > Default (HTTP/1.1)

For example, if tea-svc has appProtocol: kubernetes.io/h2c, but you want to force it to use 1.1 for debugging, add:

kubectl annotate ingress cafe-ingress-app-protocol nginx.org/proxy-http-version="1.1" --overwrite

The controller regenerates the config and reloads NGINX. Because the annotation outranks appProtocol, /tea now uses HTTP/1.1:

$ curl --resolve cafe.example.com:$IC_HTTP_PORT:$IC_IP http://cafe.example.com:$IC_HTTP_PORT/tea
...
Upstream protocol: HTTP/1.1

Remove the annotation (kubectl annotate ingress cafe-ingress-app-protocol nginx.org/proxy-http-version-) and /tea flips back to HTTP/2.

The annotation applies to every path in the Ingress. To pin a single backend, put it in its own Ingress or minion, or use the per-upstream VirtualServer field.


Works with VirtualServer resources too

If your team uses VirtualServer resources, you don’t need annotations. You can set the version right in the YAML:

apiVersion: k8s.nginx.org/v1
kind: VirtualServer
metadata:
  name: cafe-vs
spec:
  host: cafe.example.com
  upstreams:
    - name: tea
      service: tea-svc
      port: 80
      # No proxy-http-version, so HTTP/2 is inferred from tea-svc
    - name: coffee
      service: coffee-svc
      port: 80
      proxy-http-version: "1.0"   # Explicitly set to HTTP/1.0
  routes:
    - path: /tea
      action:
        pass: tea
    - path: /coffee
      action:
        pass: coffee

The field works per upstream, so each backend can use a different version. VirtualServerRoute upstreams support it too, so teams that own their routes can set it themselves.


Works with multi-team mergeable Ingresses (master / minions)

Platform teams often use Mergeable Ingress: the platform team manages a Master Ingress for the domain (cafe.example.com), and different app teams manage their own Minion Ingresses for /tea and /coffee.

The feature works here too. Say the master cafe-ingress-master owns cafe.example.com, and each team has a minion that looks like the cafe-ingress-app-protocol example above, with one path each:

  • The tea team’s minion needs no annotation. HTTP/2 is inferred from tea-svc.
  • The coffee team’s minion pins its path to HTTP/1.0 with one annotation:
metadata:
  name: cafe-ingress-coffee-minion
  annotations:
    nginx.org/mergeable-ingress-type: "minion"
    # Quote the value: unquoted, YAML reads 1.0 as a number, and Kubernetes rejects it.
    nginx.org/proxy-http-version: "1.0"

To try this, keep the tea and coffee apps from the proxy-http-version cafe.yaml above, and delete cafe-ingress-app-protocol, since only one Ingress or master can own cafe.example.com. Then apply cafe-secret.yaml, cafe-master.yaml, tea-minion.yaml and coffee-minion.yaml from the mergeable Ingress example, and add the annotation above to coffee-minion.yaml. Skip that example’s own cafe.yaml: its Services have the same names but no appProtocol, so applying it would turn HTTP/2 off for tea.

Resulting merged NGINX config

NGINX Ingress Controller merges the master and both minions into a single file (/etc/nginx/conf.d/default-cafe-ingress-master.conf). Abridged:

server {
    server_name cafe.example.com;
    ...

    location "/tea" {
        set $service "tea-svc";
        # location for minion default/cafe-ingress-tea-minion
        ...
        # Inferred from tea-svc (appProtocol: kubernetes.io/h2c)
        proxy_http_version 2;
        ...
        proxy_pass http://default-cafe-ingress-tea-minion-cafe.example.com-tea-svc-80;
    }

    location "/coffee" {
        set $service "coffee-svc";
        # location for minion default/cafe-ingress-coffee-minion
        ...
        # Set by the coffee minion's annotation
        proxy_http_version 1.0;
        proxy_set_header Connection close;
        ...
        proxy_pass http://default-cafe-ingress-coffee-minion-cafe.example.com-coffee-svc-80;
    }
}

If the platform team sets nginx.org/proxy-http-version on the master, every minion that doesn’t set its own inherits it. Because annotations outrank appProtocol, that also overrides HTTP/2 inference for minions like tea. Leave the master unset if you want Services to decide.


Limitations

  • WebSocket needs HTTP/1.1. WebSocket depends on the HTTP/1.1 Upgrade mechanism, which HTTP/1.0 and HTTP/2 don’t have. If a Service listed in nginx.org/websocket-services resolves to HTTP/1.0 or HTTP/2, the controller emits a warning event. That includes HTTP/2 inferred from appProtocol. The warning shows up in kubectl describe ingress:

    Events:
    Type Reason Age From Message
    ---- ------ --- ---- -------
    Warning AddedOrUpdatedWithWarning 5s nginx-ingress-controller ... with warning(s): service "chat-svc" is configured for WebSocket but resolves to an HTTP/2 upstream connection; WebSocket requires HTTP/1.1

    On VirtualServer there is no warning: an upstream on HTTP/1.0 or HTTP/2, including HTTP/2 inferred from appProtocol, can’t carry WebSocket traffic. Don’t mark a WebSocket backend as kubernetes.io/h2c.

  • gRPC isn’t affected. gRPC backends are proxied with grpc_pass, which always uses HTTP/2.

    • On Ingress, the annotation is ignored for Services in nginx.org/grpc-services, with a warning event.
    • On VirtualServer, the field is ignored for type: grpc upstreams, with a warning in the resource status.
  • Headers change with the version.

    • For HTTP/2 upstreams, the controller omits the Connection and Upgrade headers, which HTTP/2 forbids (RFC 9113, section 8.2.2).
    • For HTTP/1.0 upstreams, it sends Connection: close, so those connections aren’t kept alive.

Conclusion

You can now set the upstream HTTP version per Service (appProtocol), per Ingress (annotation), or per upstream (VirtualServer field), within the limits above.

The feature is available in NGINX Ingress Controller 5.7.0. To try it, use the runnable examples for Ingress and VirtualServer. For the full reference, see the documentation for the nginx.org/proxy-http-version annotation and the VirtualServer proxy-http-version field. For more NGINX Ingress Controller materials, check the links below:

NGINX Community Forum