Addresses, ports, sockets and the transport underneath
coreintermediateAn **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.
What we're doing: Prove, from the shell, that each hop is listening where you think — and that the application is not exposed directly.
- 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
Better
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.
- 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
Together
TCP or UDP — and where each shows up in this stack
Together
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

