Skip to main content
RunBook Academy

Docker & ContainersXXIV Β· Reverse Proxies & TLSProtocols

HTTP versions through a reverse proxy β€” 1.1, 2, 3 and WebSocket upgrades

Advanced⏱ ~20 min

What you'll learn

  • Explain why the client-facing and upstream protocol versions are independent
  • Enable HTTP/2 and HTTP/3 on a containerised proxy and verify the negotiation
  • Configure upstream keepalive without breaking chunked requests
  • Proxy a WebSocket that survives longer than the idle timeout

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

A reverse proxy is two connections, not one. The version the browser speaks to the proxy and the version the proxy speaks to your container are negotiated separately, and confusing the two produces a set of failures that look nothing like protocol problems: uploads that hang, WebSockets that die at exactly sixty seconds, and a connection pool that is not pooling anything.

The three versions, briefly

HTTP/1.1 is text over TCP. One request is in flight per connection at a time; keepalive reuses the connection for the next request, sequentially. Every intermediary understands it, which is why it is still the safe choice for the hop between your proxy and your application.

HTTP/2 is binary over TCP with multiplexed streams: many concurrent requests share one connection, and headers are compressed with HPACK. It removes the need for the six-connections-per-origin workaround browsers used to need. It does not remove TCP’s head-of-line blocking β€” a lost segment stalls every stream on that connection, because TCP delivers in order regardless of which stream a byte belongs to.

HTTP/3 is HTTP over QUIC, which is over UDP. Loss recovery is per-stream, so one lost packet stalls one stream instead of all of them. It also handles connection migration, so a phone moving from Wi-Fi to cellular keeps its session rather than reconnecting.

HTTP/1.1HTTP/2HTTP/3
TransportTCPTCPQUIC over UDP
ConcurrencyOne request at a time per connectionMultiplexed streamsMultiplexed streams
Head-of-line blockingPer connectionAt the TCP layerPer stream only
Negotiated byDefaultALPN in the TLS handshakeAlt-Svc advertisement, then QUIC
Firewall needsTCP 443TCP 443TCP 443 and UDP 443

Enabling it at the edge

In nginx 1.25.1 and later, HTTP/2 is a directive rather than a listen parameter:

server {
    listen 443 ssl;
    http2 on;

    server_name shop.example.com;
    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    location / {
        proxy_pass http://app:8080;
    }
}

Older builds use listen 443 ssl http2;, which is deprecated and warns on newer ones. Adding HTTP/3 requires a build with the http_v3 module, a second listen on UDP, and the advertisement:

    listen 443 quic reuseport;
    listen 443 ssl;
    http2 on;

    add_header Alt-Svc 'h3=":443"; ma=86400' always;

Verify with the client rather than the config:

Read-only / Safewhat did the server actually negotiate?
$ curl -sI --http2 https://shop.example.com/ -o /dev/null -w 'negotiated: %{http_version}\n'
negotiated: 2

Illustrative output

Read-only / Safeconfirm the HTTP/3 advertisement is present
$ curl -sI https://shop.example.com/ | grep -i '^alt-svc'
alt-svc: h3=":443"; ma=86400

Illustrative output

If your curl was built with HTTP/3 support, curl --http3 forces QUIC and proves the UDP path end to end. Many distribution builds are not, and curl --version lists HTTP3 among its features when it is β€” check before concluding the server is at fault.

The upstream hop, where the real bugs live

Here is the setting that surprises people: nginx proxies to upstreams using HTTP/1.0 by default.

location / {
    proxy_pass http://app:8080;      # HTTP/1.0 β€” no keepalive, no chunked
}

HTTP/1.0 has no keepalive, so every single request opens a fresh TCP connection to your container. At a few hundred requests per second that is a measurable cost in latency and in sockets sitting in TIME_WAIT on the host. It also has no chunked transfer encoding, so a client streaming a request body without a Content-Length header gets a 411 Length Required β€” which reads as an application bug and is not one.

The correct upstream block:

upstream app {
    server app:8080;
    keepalive 32;                    # idle connections to hold open
}

server {
    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;      # required for keepalive and chunked
        proxy_set_header Connection "";   # required for keepalive to work
    }
}

Both of the last two lines are needed, and the second is the one that gets missed. nginx passes through a Connection: close header by default; clearing it is what allows the upstream connection to be returned to the pool. With keepalive 32 and no proxy_set_header Connection "", you have configured a connection pool that closes every connection after one use.

WebSockets

A WebSocket starts as an ordinary HTTP/1.1 request carrying Upgrade: websocket, and becomes a bidirectional connection when the server answers 101 Switching Protocols. Two things follow.

First, the upgrade mechanism is HTTP/1.1-only. HTTP/2 has no Upgrade; the equivalent is Extended CONNECT from RFC 8441, and it is not universally supported. In practice browsers still open WebSockets over HTTP/1.1 even to an HTTP/2 origin, so this is rarely a problem β€” but it does mean the upstream hop must be HTTP/1.1, which is another reason for proxy_http_version 1.1.

Second, Upgrade and Connection are hop-by-hop headers, so a proxy does not forward them unless told to:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

location /ws/ {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    # A WebSocket is idle by design. The default read timeout of 60s
    # closes it mid-session.
    proxy_read_timeout  3600s;
    proxy_send_timeout  3600s;
}

The map matters: hard-coding Connection: upgrade on a location that also serves ordinary requests breaks keepalive for those, because every response then claims to be upgrading.

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. A client negotiates HTTP/2 with your nginx proxy. What does that tell you about the connection between nginx and the application container?

  2. Q2. You add `keepalive 32` to an nginx upstream block but connection reuse does not happen. What is missing?

  3. Q3. HTTP/3 is configured on a containerised nginx but clients never use it. Which explanations are plausible? Select all that apply.

  4. Q4. A WebSocket through nginx that closes after almost exactly 60 seconds of inactivity points at `proxy_read_timeout` rather than at the application.

Passing score: 75%. Answers are checked in this browser.

Where next

That completes the reverse-proxy part. The certificates part takes up what the proxy presents at the edge, and the DNS part covers how clients find it in the first place.