Filter concepts by levelShowing all levels.

Django · Section 92

Networking

Level
advanced
Read
38 min
Concepts
4

The section asks for one thing: be able to reason about the path a request takes — browser, DNS, CDN or load balancer, nginx, gunicorn or uvicorn, Django, then PostgreSQL, Redis and object storage — and every item on the list is a property of one of those hops. Each hop is an address and a port, and the bind address is a security decision rather than a detail: `0.0.0.0` means every interface the host has, `127.0.0.1` means loopback only, and a Unix domain socket is a file whose ordinary permissions replace a firewall rule. Keeping the application off the public surface is what makes the reverse proxy non-optional, which matters because everything the proxy does — terminating TLS, stripping and setting forwarding headers, capping body size, rate limiting — can be bypassed by a connection made straight to port 8000. Underneath, TCP handshakes before any data moves, which is why a connect timeout and a read timeout are separate numbers and why reused connections are worth so much; UDP promises nothing, which suits DNS queries and metrics and nothing you must not lose. Resolution deserves its own place in your head: it happens before a connection exists, it is cached at several layers for the TTL, and a DNS failure reaches your code as the same exception as a refused connection while needing the opposite fix. HTTPS is HTTP over TLS, and in almost every deployment TLS ends at the edge, so the hop into Django is plain HTTP and `request.is_secure()` is `False` until `SECURE_PROXY_SSL_HEADER` tells Django to read a header. Django states the condition rather than assuming it — the proxy must strip the client's own `X-Forwarded-Proto`, "even when it contains a comma-separated list of protocols" — because otherwise the client decides whether their own request counts as secure. Around that sit `SECURE_SSL_REDIRECT` with the health paths exempted, `Secure` cookies, and HSTS raised in steps because browsers cache it. The proxy layer is where capacity is protected. Buffering is the property that matters most: nginx reads a slow client's whole body before it opens anything upstream, which is what stops one mobile uploader occupying a synchronous worker for a minute. Static and media never reach Python, oversized bodies are refused at the edge, and the timeouts along the chain must be ordered outside-in — load balancer, then nginx, then gunicorn, then your own outbound calls — so the application gives up first and the failure arrives with a traceback instead of as a bare 504. Connection pools follow the same arithmetic as everything else in a forked deployment: they are per process, so pool size multiplied by worker count, plus Celery, is the number to compare against `max_connections`. And NAT is the reminder that a source address is not an identity, since a whole office shares one. Finally, two ways to push. Server-Sent Events are ordinary HTTP that never ends, with automatic reconnection and free resumption through `id:` and `Last-Event-ID`; WebSockets upgrade away from HTTP into a two-way channel where reconnection, backoff and replay are yours to write. Both hold a connection per client, so both need ASGI — and polling remains the right answer more often than it is chosen.

What is true here

  1. Every hop is an address and a port; the bind address decides what is public.
  2. TLS ends at the proxy, so Django learns the scheme from a header it must be able to trust.
  3. The proxy buffers slow clients and serves static — that is capacity you do not spend.
  4. Timeouts must decrease inward, or the error you get cannot be explained.
  5. SSE is one-way and self-healing; WebSockets are two-way and hand-rolled.

What you will be able to do

  • Audit a host and say which services are public and which are not
  • Make `request.is_secure()` true behind a proxy without letting a client forge it
  • Order a timeout chain so failures surface where they can be debugged
  • Choose between polling, SSE and WebSockets for a real feature, with reasons
The roadmap's own path, annotated with what each hop owns
name →addressTCP/443plain HTTP +X-Forwarded-Protoproxy_pass overa unix socketprivatenetworkif the featurepushesif the appbinds publiclyproxybypassed

Browser

holds the TLS session and the cookies

DNS · UDP/53

resolves before any connection exists; cached for the TTL

CDN / load balancer

TLS terminates here · health checks decide which instances exist

nginx

buffers slow clients · static · body size · rate limit · forwarding headers

gunicorn / uvicorn

unix socket, not a public port · `--timeout` shorter than nginx's

Django

is_secure() reads SECURE_PROXY_SSL_HEADER · ALLOWED_HOSTS checks Host

PostgreSQL · Redis · object storage

private addresses; pools are per worker process

SSE / WebSocket

a held connection per client — ASGI only

Binding 0.0.0.0:8000

the proxy becomes optional, and its headers forgeable

  • Browser — holds the TLS session and the cookies
    • leads to DNS · UDP/53 (name → address)
    • on error, leads to Binding 0.0.0.0:8000 (if the app binds publicly)
  • DNS · UDP/53 — resolves before any connection exists; cached for the TTL
    • leads to CDN / load balancer (TCP/443)
  • CDN / load balancer — TLS terminates here · health checks decide which instances exist
    • leads to nginx (plain HTTP + X-Forwarded-Proto)
  • nginx — buffers slow clients · static · body size · rate limit · forwarding headers
    • leads to gunicorn / uvicorn (proxy_pass over a unix socket)
  • gunicorn / uvicorn — unix socket, not a public port · `--timeout` shorter than nginx's
    • leads to Django
  • Django — is_secure() reads SECURE_PROXY_SSL_HEADER · ALLOWED_HOSTS checks Host
    • leads to PostgreSQL · Redis · object storage (private network)
    • leads to SSE / WebSocket (if the feature pushes)
  • PostgreSQL · Redis · object storage — private addresses; pools are per worker process
  • SSE / WebSocket — a held connection per client — ASGI only
  • Binding 0.0.0.0:8000 — the proxy becomes optional, and its headers forgeable
    • on error, leads to Django (proxy bypassed)

Addresses, ports and the transport

The four-tuple, the bind address, and why DNS is its own failure class.

Addresses, ports, sockets and the transport underneath

coreintermediate

An **IP address** identifies a machine; a **port** identifies which program on it. **DNS** turns a name like `api.example.com` into an address. A **socket** is one end of a connection, identified by the pair of address-and-port on each side. **TCP** delivers a reliable, ordered byte stream and retransmits what is lost; **UDP** just sends packets and makes no promises, which is why DNS uses it and your database does not.

Think of it as

The roadmap draws the path once — browser, DNS, CDN or load balancer, nginx, gunicorn, Django, then PostgreSQL, Redis and object storage — and being able to walk it in both directions is most of what "knows networking" means for a backend engineer. Every hop on that path is an address and a port, and every hop can fail in its own way. Start with resolution, because it happens before anything else and is invisible in most application logs: DNS maps the name to an address, the answer is cached for its TTL at several layers (the resolver, the OS, sometimes the process), and a name that resolves on your laptop can fail inside a container that has a different resolver. "DNS failure" is a distinct failure class from "connection refused" for exactly this reason. Then the connection. TCP performs a handshake before any data moves, which is why a connect timeout is a separate number from a read timeout, and why connection reuse — keep-alive, connection pooling — is worth so much: you pay the handshake once instead of per request. TCP guarantees ordering and retransmits lost segments, so your application sees a byte stream rather than packets; UDP hands you individual datagrams that may be lost, duplicated or reordered, which suits DNS queries and metrics samples and does not suit anything you must not lose. A socket is the endpoint. A *listening* socket is bound to an address and port — `0.0.0.0:8000` means every interface, `127.0.0.1:8000` means loopback only, and that single choice is the difference between a service reachable from the internet and one reachable only from the same host. Bind gunicorn to loopback or, better, to a Unix domain socket, and let nginx be the only process listening publicly. Unix sockets are worth knowing as a distinct kind: they are files on disk, they skip the TCP stack entirely, and access is controlled by ordinary file permissions rather than by a firewall rule. Ports below 1024 need root to bind, which is why nginx starts as root and drops privileges while gunicorn never needs to. And the accepted-connection socket is not the listening socket: the four-tuple of source address, source port, destination address, destination port is what makes thousands of simultaneous connections to port 443 unambiguous.

bash
gunicorn config.wsgi --bind unix:/run/gunicorn.sock   # not reachable off-box
gunicorn config.wsgi --bind 127.0.0.1:8000            # loopback only

What we're doing: Prove, from the shell, that each hop is listening where you think — and that the application is not exposed directly.

a five-command audit of one hostbash
# 1. What is listening, on what address, as which process? The address
#    column is the whole answer: 0.0.0.0 is public, 127.0.0.1 is not.
#    (-l listening, -t TCP, -n numeric, -p process)
ss -ltnp
#   LISTEN 0.0.0.0:443   users:(("nginx",pid=812,fd=6))
#   LISTEN 127.0.0.1:5432 users:(("postgres",pid=640,fd=5))
#   -- no 0.0.0.0:8000 line: gunicorn is on a Unix socket, correctly

# 2. The Unix socket is a FILE. Its permissions are the access control —
#    nginx's user must be able to write to it, and nobody else should.
ls -l /run/gunicorn.sock
#   srw-rw---- 1 deploy www-data 0 Sep  5 09:12 /run/gunicorn.sock

# 3. Resolution, separately from connection. This is the step that is
#    invisible in application logs and has its own failure mode.
dig +short api.example.com
#   203.0.113.42

# 4. Connect without sending anything. Separating "can I reach the port"
#    from "does the app answer" is what tells a firewall problem apart
#    from an application problem.
nc -zv 10.0.1.20 5432

# 5. From inside the network, confirm the app is NOT reachable except
#    through the proxy. A refused connection here is the correct result.
curl -sS --max-time 3 http://10.0.1.10:8000/ || echo "not exposed — correct"
1–4
`ss -ltnp` is the modern `netstat`. Read the address column before anything else: a service on `0.0.0.0` is reachable from every interface the host has, which usually includes one you did not intend.
5–7
The absence of a line is the finding. If gunicorn appeared on `0.0.0.0:8000`, every protection nginx provides — TLS, rate limits, header stripping — could be bypassed by connecting straight to it.
9–12
The leading `s` in `srw-rw----` marks a socket file. Access is a file permission, so the group `www-data` is how nginx is allowed in and everyone else is kept out — no firewall rule involved.
14–17
Resolve first, connect second. A stale DNS answer after a cutover looks exactly like an outage in the application, and only this step distinguishes them.
19–22
`nc -z` opens a TCP connection and closes it. It separates "the port is reachable and something accepted" from "the service answered correctly", which are different problems with different owners.
24–26
Testing that something is *not* reachable is as much a part of the audit as testing that it is. A success here would mean the application is exposed on the private network with no proxy in front.

Why this works: Each hop is confirmed by its address and port, the socket's permissions are shown to be the access control, and the application is demonstrated to be unreachable except through the proxy.

Binding the application server to 0.0.0.0

Wrong

bash
gunicorn config.wsgi --bind 0.0.0.0:8000
# now reachable on every interface, TLS-free, with no proxy in front

Better

bash
gunicorn config.wsgi --bind unix:/run/gunicorn.sock
# nginx is the only thing that can reach it, and it holds the TLS

What you see: Traffic that never appears in the nginx access log, requests arriving with client-supplied `X-Forwarded-For` headers you trusted, and — if a security group is ever loosened — the app answering directly on port 8000 over plain HTTP.

Why: `0.0.0.0` means "every interface", including the public one if the host has it. Everything the reverse proxy does for you — terminating TLS, enforcing timeouts and body-size limits, stripping and setting forwarding headers, serving static files — is bypassed by a connection made straight to the application port. The forwarding headers are the sharpest edge: `SECURE_PROXY_SSL_HEADER` tells Django to trust `X-Forwarded-Proto`, and Django's own documentation makes trusting it conditional on the proxy stripping the client's copy first. If clients can reach the app directly, they can set that header themselves and `request.is_secure()` becomes a value the attacker chose. Bind to a Unix socket, or to loopback, so the proxy is not optional.

The request path as addresses and ports — and where the public surface ends

Only the top two boxes are reachable from the internet. Everything below binds to loopback, a Unix socket, or a private address — which is a configuration decision, not a firewall one.

  • A vertical chain of six boxes showing one request path, each labelled with the address and port it listens on.
  • Browser at the top, then a DNS lookup drawn to one side over UDP port 53, whose answer is cached for its TTL.
  • Then the load balancer listening on 0.0.0.0 port 443, and nginx listening on 0.0.0.0 ports 80 and 443. These two are inside a shaded band marked "public".
  • Below a dividing line marked "the public surface ends here" are gunicorn, bound to the Unix socket file /run/gunicorn.sock, and PostgreSQL and Redis on the private addresses 10.0.1.20 port 5432 and 10.0.1.30 port 6379.
  • A note states that binding gunicorn to 0.0.0.0 port 8000 moves it above the line and exposes it directly.

The path the roadmap draws, hop by hop

The path the roadmap draws, hop by hop
HopAddress it listens onFails as
DNS resolverUDP/53 (TCP/53 for large answers)NXDOMAIN, or a stale cached address after a cutover
CDN / load balancer`0.0.0.0:443` — public5xx from the LB itself; no origin was reached
nginx`0.0.0.0:80`, `0.0.0.0:443`502 (upstream refused), 504 (upstream too slow)
gunicorn / uvicorn`unix:/run/gunicorn.sock` — **not** publicrefused if the socket file is missing or unreadable
PostgreSQL`10.0.1.20:5432`, private network only"too many clients already", or a connect timeout
Redis`10.0.1.30:6379`, private network onlyconnection reset; note Redis has no auth by default

Together

bash
ss -ltnp | grep -E ':(80|443|8000|5432)'   # what is listening, and as whom

TCP or UDP — and where each shows up in this stack

TCP or UDP — and where each shows up in this stack
DimensionTCPUDP
deliveryreliable — lost segments are retransmittedbest effort; loss is normal and silent
orderingguaranteed, in-order byte streamnone — datagrams can arrive out of order
setuphandshake before any datanone; the first packet is the message
used here byHTTP, PostgreSQL, Redis, your whole appDNS queries, metrics (StatsD), QUIC/HTTP-3
when loss mattersyou never see it — TCP hides ityou must design for it or accept it

Together

bash
dig +short api.example.com        # UDP/53, and answers are cached for the TTL
curl -sv https://api.example.com  # TCP/443, handshake then request

Remember: Walk the path: browser → DNS → load balancer → nginx → gunicorn → Django → PostgreSQL/Redis. Every hop is an address and a port, and the bind address is the security decision — `0.0.0.0` is every interface, `127.0.0.1` is loopback, and a Unix socket is a file whose permissions replace a firewall rule. Keep the application off the public surface so the proxy cannot be bypassed. TCP handshakes and retransmits, which is why connect and read timeouts are separate and why connection reuse is worth so much; UDP promises nothing, which suits DNS and metrics. And treat resolution as its own step, because a DNS failure and a refused connection look identical in a traceback and need opposite fixes.

See also: http https and tls · reverse proxies load balancers pooling and timeouts · connection lifecycle and conn max age · infra settings

Advertisement

HTTP, TLS and the boxes in front

Where TLS ends, what the proxy takes off your workers, and the timeout chain.

HTTP, HTTPS and where TLS actually ends

coreintermediate

**HTTP** is the request/response text protocol your application speaks. **TLS** is the encryption layer underneath it, and **HTTPS** is just HTTP carried over TLS. In almost every deployment TLS is *terminated* at the load balancer or nginx — decrypted there — and the hop from the proxy to Django is plain HTTP on a private network. That is why Django needs to be told, explicitly, that the original request was secure.

Think of it as

Think of HTTPS as two independent things stacked: a transport that is encrypted and authenticated, and a protocol that is not. The transport does three jobs — it encrypts so the bytes are unreadable in transit, it verifies the server's certificate so you know which server you reached, and it detects tampering. It does not know what a status code is. Getting the boundary right matters because of where TLS ends. Terminating at the edge is normal and desirable: certificates live in one place, renewals happen in one place, and the CPU cost of the handshake is paid by a machine built for it. The consequence is that by the time a request reaches gunicorn it is ordinary HTTP, and Django cannot tell from the connection whether the user typed `https://`. Left alone, `request.is_secure()` returns `False` for every request, so secure-cookie logic and CSRF checks see a site that appears to be plain HTTP even though every user is on TLS. The fix is `SECURE_PROXY_SSL_HEADER`, and its documentation is unusually blunt about the condition: only set it "if you control your proxy or have some other guarantee that it sets/strips this header appropriately", and specifically the proxy must strip a client-supplied `X-Forwarded-Proto` "even when it contains a comma-separated list of protocols". The reason is direct — if a client can send that header and reach Django, the client decides whether their own request counts as secure. Underneath that sit the redirect and the pin. `SECURE_SSL_REDIRECT` turns a plain HTTP request into a redirect to HTTPS, which covers the user who typed the bare domain, and HSTS (`SECURE_HSTS_SECONDS`) tells the browser to refuse plain HTTP for this host for a period, which closes the gap the very first redirect leaves open. HSTS is worth respecting: it is cached by the browser and a long `max-age` set on a host you cannot serve over HTTPS is not something you can quickly undo. Finally, keep the two forms of "secure" separate in your head. TLS protects the channel; it says nothing about who is calling or what they may do. An API served over HTTPS with no authentication is fully encrypted and completely open.

python
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

What we're doing: Terminate TLS at the proxy and have Django know the truth about it — without letting a client decide.

nginx site config + config/settings/production.pypython
# ---- nginx -----------------------------------------------------------
# server { listen 443 ssl; ... }
#
#   # OVERWRITE. proxy_set_header replaces whatever the client sent, so a
#   # forged X-Forwarded-Proto from the internet never reaches Django.
#   # An "add_header"-style append here would be the vulnerability.
#   proxy_set_header Host              $host;
#   proxy_set_header X-Forwarded-Proto $scheme;
#   proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
#   proxy_pass http://unix:/run/gunicorn.sock;

# ---- config/settings/production.py ------------------------------------

# Django's docs make the condition explicit: set this only if the proxy
# strips the client's own header "even when it contains a comma-separated
# list of protocols". The nginx block above is that guarantee.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# Covers the user who typed the bare domain. The exempt list keeps the
# load balancer's plain-HTTP health probe from being redirected forever.
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r"^healthz$", r"^readyz$"]

# Without Secure, a single plain-HTTP request would put the session
# cookie on the wire in clear text.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True

# HSTS: start at one hour, confirm nothing breaks, then raise. Browsers
# cache this, so a year set on a host you cannot serve over TLS is a
# self-inflicted outage you cannot roll back quickly.
SECURE_HSTS_SECONDS = 3600
SECURE_HSTS_INCLUDE_SUBDOMAINS = False   # only once every subdomain has TLS
4–9
`proxy_set_header` overwrites. That single property is what makes the Django setting safe: a client can send `X-Forwarded-Proto: https` all it likes, and nginx replaces it with what nginx itself saw.
14–17
The setting only reads a header. It carries no proof, so its correctness rests entirely on the proxy configuration three lines above — which is why Django's documentation states the precondition rather than assuming it.
19–22
The exemption is the detail that bites. A load balancer probing `/healthz` over plain HTTP receives a 301, marks the instance unhealthy, and removes a perfectly healthy server from the pool.
24–28
`Secure` means the browser will not send the cookie over plain HTTP at all, which turns a single downgraded request from a session leak into a missing cookie.
30–34
HSTS is a promise with a memory. Raise `max-age` in steps and enable `INCLUDE_SUBDOMAINS` only when every subdomain — including the ones another team owns — can serve HTTPS.

Why this works: Django knows the original scheme because the proxy guarantees the header, health probes are not redirected, cookies never travel in clear text, and HSTS is introduced at a length you can still undo.

Setting SECURE_PROXY_SSL_HEADER without the proxy guarantee

Wrong

python
# settings.py — and nginx appends rather than overwrites, or the app
# is reachable directly on :8000
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Better

text
# nginx first: overwrite the header, and bind the app off the network
proxy_set_header X-Forwarded-Proto $scheme;
# gunicorn --bind unix:/run/gunicorn.sock

What you see: Nothing, for a long time. Then a request that arrived over plain HTTP is treated as secure, `SECURE_SSL_REDIRECT` does not fire for it, and a session cookie marked `Secure` is issued for a connection that was never encrypted.

Why: The setting tells Django to believe a header. Django's documentation lists the conditions plainly: your app must be behind a proxy, the proxy must strip the client's copy of `X-Forwarded-Proto` — including a comma-separated list — and must set it only for requests that genuinely arrived over HTTPS. Break any one of those and the client controls `request.is_secure()`, which is the value your redirect, cookie and CSRF logic all rest on. The failure is silent because the mechanism works perfectly for ordinary traffic; only an attacker exercises the difference.

One HTTPS request, and the two hops it is really made of
browser
load balancer
nginx
gunicorn / Django
  1. 1. TLS handshake, then GET /checkout/encrypted; the certificate proves which host answered
  2. 2. plain HTTP + X-Forwarded-Proto: httpsTLS ended here — this hop is private, not encrypted
  3. 3. proxy_pass over the Unix socketnginx overwrites the forwarding headers it sets
  4. 4. is_secure() reads SECURE_PROXY_SSL_HEADERwithout the setting this is False for every request
  5. 5. 200 + Set-Cookie; Secure; HttpOnly
  6. 6. response, unchanged
  7. 7. re-encrypted over the original TLS session
  1. browser → load balancer: TLS handshake, then GET /checkout/ (encrypted; the certificate proves which host answered)
  2. load balancer → nginx: plain HTTP + X-Forwarded-Proto: https (TLS ended here — this hop is private, not encrypted)
  3. nginx → gunicorn / Django: proxy_pass over the Unix socket (nginx overwrites the forwarding headers it sets)
  4. gunicorn / Django → gunicorn / Django: is_secure() reads SECURE_PROXY_SSL_HEADER (without the setting this is False for every request)
  5. gunicorn / Django → nginx: 200 + Set-Cookie; Secure; HttpOnly
  6. nginx → load balancer: response, unchanged
  7. load balancer → browser: re-encrypted over the original TLS session

The four settings that make a proxied deployment honest about HTTPS

The four settings that make a proxied deployment honest about HTTPS
SettingWhat it doesPrecondition
`SECURE_PROXY_SSL_HEADER`makes `is_secure()` true when the proxy says sothe proxy **strips** the client's copy of the header
`SECURE_SSL_REDIRECT`redirects plain HTTP to HTTPShealth-check paths excluded, or probes redirect-loop
`SESSION_COOKIE_SECURE` / `CSRF_COOKIE_SECURE`the cookie is never sent over plain HTTPthe site is genuinely reachable over HTTPS
`SECURE_HSTS_SECONDS`browser refuses plain HTTP for this hoststart small — a long `max-age` is cached and hard to undo

Together

python
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True

The headers a proxy adds, and what each is worth

The headers a proxy adds, and what each is worth
HeaderSet byTrust it when
`X-Forwarded-Proto`the proxy, per requestthe proxy overwrites, never appends — otherwise a client can forge it
`X-Forwarded-For`each proxy appends the previous peeryou count from the **right**; the leftmost entry is client-supplied
`X-Forwarded-Host`the proxy, from the original `Host`you have set `USE_X_FORWARDED_HOST` deliberately
`Host`the client`ALLOWED_HOSTS` validates it — that is what the setting is for

Together

text
# nginx: overwrite, do not append. $scheme is what nginx itself saw.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;

Remember: HTTPS is HTTP over TLS, and TLS almost always ends at the proxy — so Django sees a plain HTTP hop and `request.is_secure()` is `False` until you set `SECURE_PROXY_SSL_HEADER`. Set it only under the condition Django states: the proxy must strip the client's own `X-Forwarded-Proto`, even a comma-separated one, or the client decides whether their request counts as secure. Add `SECURE_SSL_REDIRECT` for typed URLs, exempt the health paths so probes are not redirected, mark session and CSRF cookies `Secure`, and raise HSTS in steps because browsers cache it. And keep the layers distinct: the certificate protects the channel, not the door.

See also: addresses ports sockets and the transport · reverse proxies load balancers pooling and timeouts · https and hsts settings · host transport and cookie hardening

Reverse proxies, load balancers, pooling and timeouts

coreadvanced

A **reverse proxy** sits in front of your application and speaks to clients on its behalf — terminating TLS, serving static files, buffering slow uploads. A **load balancer** spreads requests across several instances and stops sending to the ones failing health checks. A **forward proxy** is the opposite arrangement: it sits in front of *clients* making outbound calls. **NAT** rewrites addresses at a network boundary, which is why every request from your office appears to come from one IP.

Think of it as

The proxy in front of Django is doing work you would otherwise pay for in worker time, and the list is worth knowing because each item is a class of outage it prevents. It terminates TLS, so handshake CPU never touches your fleet. It serves static and media files from disk or object storage, so a worker is never occupied streaming a 40 MB PDF. It *buffers* — reading the whole request body from a slow client before opening anything upstream, and buffering the response back out — which is the property that protects synchronous workers: with a sync worker, a client on a 3G connection would otherwise hold a worker for the whole upload. It enforces limits your application should not have to: body size, request rate, and its own timeouts. The load balancer adds distribution and health. Requests go to the instances currently passing a check, which is what makes a rolling deploy possible; an instance that fails readiness is simply not sent traffic. The important operational rule is that timeouts must be *ordered* along the path, and this is where most 502/504 confusion comes from. The load balancer's idle timeout, nginx's `proxy_read_timeout`, gunicorn's `--timeout`, and your own outbound call timeouts form a chain, and each hop should allow slightly more than the hop beneath it. When gunicorn's timeout is longer than nginx's, nginx gives up first and returns 504 while the worker keeps going, so you burn capacity on a response nobody will read. When it is much shorter, the worker is killed mid-request and nginx reports 502 without the traceback that would have explained it. Connection pooling is the same idea applied downwards: opening a TCP connection costs a handshake, and TLS costs several, so long-lived connections are reused everywhere sensible — nginx keeps upstream keepalives, `requests.Session` reuses connections via urllib3, and Django keeps a database connection per worker for `CONN_MAX_AGE` seconds. The trap is that pools are per process, so "pool size 10" with 24 workers is up to 240 connections, and that arithmetic is what exhausts a database. Two smaller things complete the picture. A forward proxy is what an outbound call goes through in a locked-down network, configured by `HTTP_PROXY`/`HTTPS_PROXY` environment variables that most Python HTTP clients read automatically — worth knowing before you spend an afternoon on a timeout that is really an unset variable. And NAT explains why source IPs are not identities: many clients share one public address, so per-IP rate limits punish whole offices, and `X-Forwarded-For` is a list you must read from the right-hand side.

text
proxy_read_timeout 60s;     # nginx waits this long for gunicorn
# gunicorn --timeout 30       # gunicorn gives up first, on purpose

What we're doing: Configure nginx and gunicorn so slow clients cannot hold workers, static files never reach Python, and a timeout produces the error you expect.

/etc/nginx/sites-enabled/storefronttext
upstream app {
    server unix:/run/gunicorn.sock;
    # Reuse upstream connections instead of a new one per request. The
    # handshake is small but it is paid on EVERY request without this.
    keepalive 32;
}

server {
    listen 443 ssl;
    server_name storefront.example.com;

    # Refused at the edge, before a worker is involved at all. Without
    # it, Django parses a 2 GB body just to reject it.
    client_max_body_size 25m;

    # Buffering is the setting that protects synchronous workers: nginx
    # reads the whole body from a slow client, then opens upstream.
    proxy_request_buffering on;
    proxy_buffering on;

    # Served from disk by nginx. A worker never touches these bytes.
    location /static/ {
        alias /srv/app/static/;
        expires 30d;
    }

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;          # required for upstream keepalive
        proxy_set_header Connection "";  # ditto

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

        # 60 > gunicorn's 30, so gunicorn gives up first and you get a
        # traceback instead of a bare 504 with nothing in the app log.
        proxy_connect_timeout 5s;
        proxy_read_timeout   60s;
    }

    # Not redirected to HTTPS and not rate-limited: the load balancer
    # probes this over plain HTTP from inside the network.
    location = /healthz {
        proxy_pass http://app;
        access_log off;
    }
}
3–5
Upstream keepalive turns per-request connection setup into a reused connection. It needs `proxy_http_version 1.1` and an empty `Connection` header further down, and it silently does nothing without them.
12–14
Size limits belong at the edge. A body rejected here costs nginx a few microseconds; the same body rejected in Django costs a worker the entire upload time first.
16–19
Request buffering is the reason a sync worker deployment survives mobile clients. nginx absorbs the slow upload, and the worker is only involved once the whole body is in hand.
21–25
Static served by nginx with a cache header. Every one of these that reaches a worker is capacity spent on a file that never changes.
33–34
`$proxy_add_x_forwarded_for` appends the peer address to any existing list, which is why you read that header from the right — the leftmost entry came from the client and can say anything.
36–39
The ordering that decides which error you get. nginx waits 60s, gunicorn gives up at 30s, so the worker is recycled and the failure is logged with a traceback rather than surfacing as an unexplained 504.

Why this works: Slow clients are absorbed by nginx, static files never reach Python, oversized bodies are refused before a worker exists, upstream connections are reused, and a timeout fails in the layer that can explain it.

A gunicorn timeout longer than nginx's

Wrong

bash
# nginx: proxy_read_timeout 30s;
gunicorn config.wsgi --timeout 120
# nginx returns 504 at 30s; the worker keeps computing for 90 more seconds

Better

bash
# nginx: proxy_read_timeout 60s;
gunicorn config.wsgi --timeout 30      # the app gives up first, and logs why

What you see: A steady rate of 504s with nothing in the application log to match them, and CPU that stays high after the errors stop — workers finishing responses that were abandoned half a minute earlier.

Why: Whichever layer times out first decides what the user sees and what you can debug. If nginx gives up first, it returns 504 and drops the upstream connection, but gunicorn does not know that: the worker runs to completion, holding a database connection and a slot in the pool, producing a response that is discarded. Under load that compounds, because arriving requests queue behind workers doing work for nobody. Making the application's timeout the shortest inverts it — the worker is stopped, the event is recorded on your side with a traceback, and nginx sees a real upstream error rather than a silence it has to interpret.

The chain, and the timeout each layer owns

Load balancer · idle timeout 65s

health checks decide which instances exist; TLS often ends here

nginx · proxy_read_timeout 60s

buffers slow clients, serves static, caps body size, rate-limits

gunicorn · --timeout 30s

kills a worker silent for longer than this and starts a fresh one

Django view · your budget

everything below has to fit inside the 30s the worker is allowed

Outbound calls · timeout=(3, 8)

attempts × timeout must fit the view's budget, not the LB's

PostgreSQL · statement_timeout

the last line: a query that outlives the request helps nobody

  1. Load balancer · idle timeout 65s — health checks decide which instances exist; TLS often ends here
  2. nginx · proxy_read_timeout 60s — buffers slow clients, serves static, caps body size, rate-limits
  3. gunicorn · --timeout 30s — kills a worker silent for longer than this and starts a fresh one
  4. Django view · your budget — everything below has to fit inside the 30s the worker is allowed
  5. Outbound calls · timeout=(3, 8) — attempts × timeout must fit the view's budget, not the LB's
  6. PostgreSQL · statement_timeout — the last line: a query that outlives the request helps nobody

What the proxy takes off your workers

What the proxy takes off your workers
JobDone by the proxyCost if the app does it instead
TLS terminationonce, on hardware built for ithandshake CPU inside every worker
static / mediasendfile from disk, or a redirect to storagea worker occupied for a whole download
slow-client bufferingbody read fully before upstream opensone held worker per slow uploader
body size limit`client_max_body_size`, refused at the edgea 2 GB upload parsed in Python before rejection
rate limiting`limit_req`, before any Python runsthrottling that still costs a worker to evaluate
gzip / brotlicompression on the way outCPU that competes with request handling

Together

text
client_max_body_size 25m;
location /static/ { alias /srv/app/static/; expires 30d; }
location /      { proxy_pass http://unix:/run/gunicorn.sock; }

The timeout chain, from outside in

The timeout chain, from outside in
HopSettingSuggested relationship
load balanceridle / request timeoutthe largest — say 65s
nginx → upstream`proxy_read_timeout`below the LB — say 60s
gunicorn`--timeout`below nginx — say 30s (its documented default)
your outbound calls`timeout=(connect, read)`small enough that retries still fit in 30s
database`statement_timeout`set per role, so one query cannot outlive the request

Together

bash
gunicorn config.wsgi --timeout 30 --graceful-timeout 30
# nginx: proxy_read_timeout 60s;   LB idle timeout: 65s

Three kinds of "proxy", and which one you mean

Three kinds of "proxy", and which one you mean
KindSits in front ofYou meet it as
reverse proxyyour serversnginx, Caddy, an ALB — clients never see the app directly
forward proxyclients making outbound calls`HTTPS_PROXY=http://proxy:3128` in a locked-down network
NAT gatewaya whole private networkevery outbound call appearing to come from one address

Together

bash
export HTTPS_PROXY=http://proxy.internal:3128
export NO_PROXY=localhost,127.0.0.1,10.0.0.0/8   # never proxy internal hops

Remember: The reverse proxy exists to keep work off your workers: TLS, static files, body-size limits, rate limits, and above all buffering, which is what stops a slow client holding a synchronous worker. The load balancer distributes and health-checks, which is what makes rolling deploys possible. Order the timeouts outside-in — LB > nginx > gunicorn > your outbound calls — so the application gives up first and you get a traceback instead of a bare 504. Remember every pool is per process, so multiply by worker count before comparing to `max_connections`. And NAT means one address is many users: read `X-Forwarded-For` from the right, and never treat a source IP as an identity.

See also: http https and tls · websockets and server sent events · pooling pgbouncer and long running transactions · production static architecture · throttle backends and multiple instances

Advertisement

Pushing to a browser

One-way and self-healing, two-way and hand-rolled — and the cost both share.

WebSockets and Server-Sent Events

standardadvanced

Both let a server push data to a browser without the browser asking again. A **WebSocket** upgrades an HTTP connection into a two-way channel — either side can send at any time. **Server-Sent Events** keep an ordinary HTTP response open and stream text down it; MDN is explicit that "this is a one-way connection, so you can't send events from a client to a server". SSE reconnects by itself; WebSockets do not.

Think of it as

Start with the question that actually decides between them: does the client need to send? A chat room, a collaborative editor and a multiplayer board all have clients that speak, so they need WebSockets. A progress bar, a live dashboard, a notification bell and a "your export is ready" toast only ever listen, and for those SSE is less machinery for the same result. The difference in cost is not the protocol, it is everything around it. SSE is an ordinary GET whose response never ends, so it passes through proxies, corporate middleboxes and HTTP caches as normal traffic, and the browser reconnects on its own — MDN: "By default, if the connection between the client and server closes, the connection is restarted." It also carries resumption for free: the server can emit an `id` with each event, and the browser sends the last one back in `Last-Event-ID` when it reconnects, so you can replay what was missed. A WebSocket starts as an HTTP request with an `Upgrade` header and then stops being HTTP, which means proxies must be configured for it explicitly, and reconnection, backoff and message replay are all yours to write. In a Django deployment the shared constraint matters more than the difference: both hold a connection open for as long as the feature lasts, and a held connection occupies a worker. Under WSGI that is fatal — a synchronous worker serves one request at a time, so ten thousand listeners would mean ten thousand workers. Both therefore need ASGI, and an async server that can hold many idle connections cheaply on one process. WebSockets in Django additionally need the consumer machinery that Channels provides, plus a channel layer if a message must reach a connection held by a *different* worker, which it usually must. There is a third option worth naming, because it is often the right one: polling. A request every thirty seconds for a status that changes twice an hour costs almost nothing, needs no ASGI, no reconnection logic and no proxy configuration, and it is what most "real-time" features should start as. Move to SSE when the latency genuinely matters, and to WebSockets when the client has something to say.

python
StreamingHttpResponse(event_stream(), content_type="text/event-stream")

What we're doing: Stream export progress to the browser with SSE, on ASGI, in a way that survives a dropped connection.

exports/views.py + the nginx locationpython
import asyncio
import json

from django.http import StreamingHttpResponse


async def export_progress(request, job_id):
    async def event_stream():
        last_seen = None
        # The browser sends this back by itself after a dropped
        # connection, which is how the stream resumes rather than
        # starting over from the beginning.
        resume_from = request.headers.get("Last-Event-ID")

        while True:
            state = await get_job_state(job_id, after=resume_from or last_seen)

            if state is not None:
                last_seen = state["seq"]
                # id: gives the browser something to send back.
                # The BLANK LINE at the end is what dispatches the
                # event; without it the client waits forever.
                yield (
                    f"event: progress\n"
                    f"id: {state['seq']}\n"
                    f"data: {json.dumps(state)}\n\n"
                )
                if state["done"] >= state["total"]:
                    yield "event: complete\ndata: {}\n\n"
                    return

            # A line starting with ':' is a comment. It resets idle
            # timers in every proxy on the path, dispatching nothing.
            yield ": keepalive\n\n"
            await asyncio.sleep(2)

    response = StreamingHttpResponse(
        event_stream(), content_type="text/event-stream"
    )
    # Without this, nginx buffers the stream and the client sees nothing
    # until the response ends — which, for a stream, is never.
    response["X-Accel-Buffering"] = "no"
    response["Cache-Control"] = "no-cache"
    return response


# nginx, for this location only:
#   proxy_buffering off;
#   proxy_read_timeout 1h;      # the connection is SUPPOSED to be idle
10–13
`Last-Event-ID` arrives on its own after a reconnect, because the browser stored the last `id:` it saw. Reading it is what turns automatic reconnection into automatic resumption.
20–27
The blank line at the end is not formatting — an event without it is never dispatched. It is the most common reason an SSE endpoint "sends nothing" while the server log clearly shows data being written.
32–35
A comment line keeps proxy idle timers alive without the client seeing an event, which is what stops a legitimately quiet stream from being cut at sixty seconds.
37–43
`X-Accel-Buffering: no` turns nginx buffering off for this response. With buffering on, nginx waits for the response to finish before sending anything, and a stream never finishes.
46–48
The read timeout for a streaming location has to be long, because idleness is the normal state here. Applying the site-wide sixty seconds would disconnect every client every minute.

Why this works: The browser reconnects and resumes with no client-side retry code, keepalives stop proxies cutting an idle stream, buffering is disabled so events arrive as they are produced, and nothing holds a synchronous worker.

One-way and two-way, and what each costs you

Server-Sent Events

  • +Ordinary GET; the response simply never ends
  • +`Content-Type: text/event-stream`, events ended by a blank line
  • +Browser reconnects on its own and sends `Last-Event-ID`
  • +Proxies pass it as normal HTTP — but buffering must be turned off
  • +Client cannot send: any action is a separate ordinary request
  • +Right for progress, notifications, dashboards, "job finished"

WebSockets

  • Starts as HTTP with `Upgrade`, then leaves HTTP behind
  • Either side sends at any time, in frames rather than requests
  • No automatic reconnect — you write retry, backoff and replay
  • Proxies and load balancers need explicit upgrade configuration
  • In Django: consumers, plus a channel layer to reach other workers
  • Right for chat, presence, collaborative editing, live cursors
  • Server-Sent Events
    • Ordinary GET; the response simply never ends
    • `Content-Type: text/event-stream`, events ended by a blank line
    • Browser reconnects on its own and sends `Last-Event-ID`
    • Proxies pass it as normal HTTP — but buffering must be turned off
    • Client cannot send: any action is a separate ordinary request
    • Right for progress, notifications, dashboards, "job finished"
  • WebSockets
    • Starts as HTTP with `Upgrade`, then leaves HTTP behind
    • Either side sends at any time, in frames rather than requests
    • No automatic reconnect — you write retry, backoff and replay
    • Proxies and load balancers need explicit upgrade configuration
    • In Django: consumers, plus a channel layer to reach other workers
    • Right for chat, presence, collaborative editing, live cursors

Choosing between the three

Choosing between the three
DimensionPollingSSEWebSocket
directionclient asksserver → client onlyboth ways
transportordinary requestsone long-lived HTTP responseupgraded, no longer HTTP
reconnectnothing to reconnect**automatic**, with `Last-Event-ID`you write it
proxy supportuniversaluniversal (buffering must be off)needs explicit configuration
server costa request per client per intervala held connection per clienta held connection per client
needs ASGInoyesyes, plus consumers and a channel layer

Together

text
progress bar, notifications, live metrics   -> SSE
chat, collaborative editing, presence       -> WebSocket
"has the export finished yet?" every 30s    -> polling

The SSE wire format — four field names, and that is all

The SSE wire format — four field names, and that is all
FieldMeaning
`data:`the payload; repeat the field for a multi-line message
`event:`a name the client can listen for separately from the default
`id:`sets the last event id, returned as `Last-Event-ID` on reconnect
`retry:`milliseconds the browser waits before reconnecting
a blank lineends the event — an event without it is never dispatched

Together

text
event: progress
id: 42
data: {"done": 1200, "total": 5000}
<-- this blank line is what dispatches the event

Remember: Ask whether the client needs to send. If it does, that is a WebSocket, and reconnection, backoff and replay are yours to write. If it only listens, SSE does the job as ordinary HTTP: `text/event-stream`, events ended by a blank line, automatic reconnection, and resumption through `id:` and `Last-Event-ID`. Both hold one connection per client, so both need ASGI — a synchronous worker serving one connection is how you run out of workers. Turn proxy buffering off and raise the read timeout for the streaming path, send periodic keepalive comments, and remember that polling every thirty seconds is often the correct answer.

See also: reverse proxies load balancers pooling and timeouts · wsgi and asgi as interfaces · async views and the asgi requirement · iterator batching and streaming responses

Advertisement