Filter concepts by levelShowing all levels.

Django · Networking

Networking

Walk the request path in both directions, hop by hop

  • The bind address is a security decision`0.0.0.0` is every interface; a Unix socket is a file with permissions
  • TLS ends at the proxyso `is_secure()` is False until a header Django can trust says otherwise
  • Order the timeouts outside-inLB > nginx > gunicorn > your calls, or you get a 504 with no traceback
  • Buffering is what saves sync workersnginx absorbs the slow client so a worker is never held by one
  • SSE reconnects; WebSockets do notone-way and self-healing, or two-way and hand-rolled

1.Addresses, ports, sockets and the transport underneath

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.

Key features

  • A connection is a four-tuple: source IP, source port, destination IP, destination port
  • `0.0.0.0:8000` listens on every interface; `127.0.0.1:8000` only on loopback
  • A Unix socket is a file — permissions replace firewall rules, and the TCP stack is skipped
  • Ports below 1024 need root to bind; nginx binds 80/443, gunicorn never should
  • TCP handshakes before data, so connect and read timeouts are separate numbers
  • DNS failure is its own failure class — resolution happens before any connection exists

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

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
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.

Command

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

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.

2.HTTP, HTTPS and where TLS actually ends

**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.

Key features

  • HTTPS = HTTP over TLS; the protocol above is unchanged
  • TLS usually ends at the proxy — Django then sees plain HTTP on a private hop
  • `SECURE_PROXY_SSL_HEADER` is what makes `request.is_secure()` true behind a proxy
  • Only set it if the proxy strips the client's own `X-Forwarded-Proto` first
  • `SECURE_SSL_REDIRECT` covers the typed-URL case; HSTS closes the first-request gap
  • Encryption is not authorization — an open API over TLS is still an open API

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

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

Command

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

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.

3.Reverse proxies, load balancers, pooling and timeouts

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.

Key features

  • The proxy buffers slow clients, which is what keeps a sync worker from being held hostage
  • Static and media should never reach a worker — the proxy or object storage serves them
  • Order the timeouts: LB > nginx `proxy_read_timeout` > gunicorn `--timeout` > your calls
  • nginx times out first → 504; gunicorn kills the worker first → 502 with no traceback
  • Pools are per process: pool 10 × 24 workers = 240 connections, not 10
  • NAT means one IP is many users — per-IP limits and `X-Forwarded-For` both need care

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

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

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
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

Command

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

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.

4.WebSockets and Server-Sent Events

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.

Key features

  • WebSocket is two-way; SSE is one-way, server to client, by design
  • SSE is plain HTTP with `Content-Type: text/event-stream` — proxies treat it normally
  • SSE reconnects automatically; `id:` plus `Last-Event-ID` gives you replay for free
  • WebSockets: reconnection, backoff and replay are all code you write
  • Both hold a connection per client, so both need ASGI — a sync worker serves one
  • Polling is the honest default until the latency actually matters

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

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
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

Command

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

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.

Remember

  • A DNS failure and a refused connection are the same exception and opposite fixes — log the host and the error.
  • `proxy_set_header` overwrites. That property is the entire safety of `SECURE_PROXY_SSL_HEADER`.
  • Exempt the health-check paths from `SECURE_SSL_REDIRECT`, or a plain-HTTP probe redirect-loops your instance out of the pool.
  • Every pool is per process: pool size × workers, plus Celery, is the number to compare with `max_connections`.

Pro tip

  • Read `X-Forwarded-For` from the right-hand side. Each proxy appends, so the leftmost entry is whatever the client chose to send.
  • Before building anything real-time, price polling. Thirty-second polling for a status that changes twice an hour needs no ASGI, no reconnection code and no proxy changes.

Next up

  • Docker for Django
  • Gunicorn, Uvicorn and Deployment Workers
  • CI/CD

Sources

  • Django settings reference (SECURE_PROXY_SSL_HEADER) — docs.djangoproject.com/en/5.2/ref/settings/
  • Django gunicorn how-to — docs.djangoproject.com/en/5.2/howto/deployment/wsgi/gunicorn/
  • MDN — Using server-sent events — developer.mozilla.org/en-US/docs/Web/API/Server-sent_events
  • Every Django claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.