
- Caddy reverse proxy WebSocket traffic upgrades on its own without manual header code.
- RFC 6455 handshakes turn basic web calls into full-duplex TCP channels.
- The value -1 stops proxy buffering for real-time WebSocket frames.
How Caddy v2 handles WebSocket protocol upgrades
Caddy proxies WebSocket connections with no extra rules. You do not need manual Upgrade headers. The server passes client handshake headers to upstream apps unchanged. Once the backend returns an HTTP 101 status code, the proxy links both sides into an open TCP byte stream.
Older web proxies drop hop-by-hop headers by default. This breaks the RFC 6455 handshake unless teams write custom map blocks. The modern proxy fixes this problem. It checks upstream status codes and starts two-way streaming at once.
Architectural comparison: Caddy versus Nginx WebSocket proxying
Engineers moving from Nginx often hunt for missing lines. Nginx needs explicit map blocks to pass the Upgrade header. Without those blocks, Nginx drops the upgrade token. It falls back to plain HTTP requests. That extra step adds work to routine setup jobs.
The proxy spots the upstream 101 response on its own. It tunnels the TCP connection with no extra rules, as noted in the docs. As seen in our review of Caddy vs Nginx reverse proxy architecture, the engine uses clean defaults to avoid bugs.
This table compares key tasks across both systems for live traffic.
| Operational parameter | Caddy v2 reverse proxy | Nginx reverse proxy |
|---|---|---|
| Default WebSocket support | Automatic and enabled natively | Requires manual header directives |
| Upgrade header management | Forwarded transparently by proxy | Must map $http_upgrade to $connection_upgrade |
| Configuration lines needed | 1 directive line (reverse_proxy) | 4 to 6 lines of proxy_set_header syntax |
| Streaming flush control | Native flush_interval parameter | proxy_buffering off directive |
| HTTP/2 and HTTP/3 support | Automatic dual transport and ALPN | Manual http2 and http3 listen options |
| Zero-touch TLS automation | Built-in ACME certificate management | External tools like Certbot required |
Production Caddyfile configurations for single and multi-path services
Because the proxy handles protocol upgrades on its own, a basic setup needs only one block. An app serving web pages and live sockets needs a short config. The server proxies normal REST calls. It upgrades socket links smoothly.
Here is a working configuration file for a single app node.
example.com {
# Automatic TLS provisioning via Let's Encrypt / ZeroSSL
encode zstd gzip
# Unified reverse proxy handling both HTTP and WebSockets
reverse_proxy localhost:8080 {
# Flush frames immediately without buffering delays
flush_interval -1
}
}
Large projects split static files, REST APIs, and socket apps. Standard API calls hit one app server. Real-time frames route to a separate socket backend. Route matchers keep these jobs cleanly apart.
app.example.com {
encode zstd gzip
# Route dedicated WebSocket connections on /socket/ path
handle /socket/* {
reverse_proxy ws-backend:9000 {
flush_interval -1
header_up X-Real-IP {remote_host}
}
}
# Route standard API and web traffic
handle {
reverse_proxy web-backend:3000
}
}
The handle directive keeps endpoints separate. Live socket traffic goes straight to upstream socket servers, and regular web calls flow to primary app instances.
Docker Compose networking and persistent socket streaming
Docker isolates proxies from backend apps. When running in Docker, place services on a shared private bridge. As covered in our guide on pinning container images by digest, predictable image versions ensure network services stay stable. Do not bind container ports to host ports unless needed.
Here is a clean Docker Compose file running Caddy with a Node.js socket app.
# Modern Compose syntax (top-level version omitted per Compose v2 spec)
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 QUIC
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- webnet
realtime-service:
image: my-websocket-app:latest
restart: unless-stopped
expose:
- "8080"
networks:
- webnet
networks:
webnet:
driver: bridge
volumes:
caddy_data:
caddy_config:
The container resolves realtime-service:8080 through internal Docker DNS. It handles public TLS with modern ciphers, as shown in our guide on TLS 1.3 vs TLS 1.2 handshake speed, and keeps backend tunnels open.
Tuning stream timeouts and eliminating frame buffer delays
Live apps need low lag. Standard web proxies hold network packets to group bytes. For small socket frames, holding bytes adds lag. The proxy provides the flush_interval knob to control byte flow.
The directive flush_interval -1 tells the proxy to flush payload frames to clients right after each write, as noted in the docs. Setting flush_interval to -1 disables proxy buffering and delivers frames immediately. This setting prevents frame delay across chat tools, shared editors, and game servers. Also, idle connections risk sudden drops if ping frames stay absent.
RFC 6455 defines ping and pong frames to keep quiet links fresh. The proxy passes these control frames through cleanly. App servers should send regular ping frames to prevent intermediaries from dropping inactive connections. This routine stops cloud firewalls and routers from closing idle links.
Troubleshooting checklist for failed WebSocket connections
When socket handshakes fail through an intermediary proxy, the client connection terminates abruptly without application payload data. Because this disconnect gives no context, engineers must check steps in order. Follow this checklist to find errors.
| Diagnostic checkpoint | Common root cause | Resolution procedure |
|---|---|---|
| HTTP 400 Bad Request | Upstream missing RFC 6455 headers | Verify upstream service receives Sec-WebSocket-Key and Version |
| HTTP 502 Bad Gateway | Upstream container unreachable or down | Verify container health and Docker network DNS resolution |
| Handshake stalls at 200 OK | Backend treated socket as plain HTTP | Check upstream framework router for WebSocket upgrade handler |
| Silent disconnect on quiet stream | Firewall or proxy idle connection timeout | Implement application-level ping/pong heartbeats on idle streams |
| Mixed content security error | Browser on HTTPS requesting ws:// | Ensure client application requests secure wss:// protocol |
| Origin validation failure | CORS or allowed origins mismatch | Configure backend allowed origins to match public domain |
Test handshakes from your terminal using cURL with upgrade headers.
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/socket/
A healthy server returns HTTP/1.1 101 Switching Protocols and Upgrade: websocket. Once verified, your live socket proxy is ready for production.
Frequently asked questions about Caddy WebSocket proxying
Does Caddy v2 support WebSockets out of the box?
Yes. Caddy v2 handles WebSocket protocol upgrades automatically without extra configuration.
Why does Nginx require extra headers while Caddy does not?
Nginx clears hop-by-hop headers by default. Caddy inspects protocol switch responses and streams TCP bytes directly.
How do you route WebSockets on a specific subpath in Caddy?
Use a path matcher like reverse_proxy /ws/* localhost:8080 to isolate real-time traffic.