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:
- 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. - A new Ingress annotation: To set the version manually on an Ingress, use
nginx.org/proxy-http-version: "1.0","1.1", or"2". - A new VirtualServer field: If you use VirtualServer resources instead of standard Ingress, there is now a native
proxy-http-versionfield underupstreams.
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-versionannotation or VirtualServer field > ServiceappProtocol> 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
Upgrademechanism, which HTTP/1.0 and HTTP/2 don’t have. If a Service listed innginx.org/websocket-servicesresolves to HTTP/1.0 or HTTP/2, the controller emits a warning event. That includes HTTP/2 inferred fromappProtocol. The warning shows up inkubectl 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.1On 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 askubernetes.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: grpcupstreams, with a warning in the resource status.
- On Ingress, the annotation is ignored for Services in
Headers change with the version.
- For HTTP/2 upstreams, the controller omits the
ConnectionandUpgradeheaders, 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.
- For HTTP/2 upstreams, the controller omits the
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:


