Sockets and Ports
Topic 50

Sockets and Ports

NetworkingDiagnostics

A socket is the kernel's endpoint for communication. For TCP and UDP it is identified by an address and a port — 0.0.0.0:443, 10.0.0.42:5432 — and the kernel uses that pair to decide which process receives an arriving packet. A port on its own is just a 16-bit number from 1 to 65535; it means nothing until a process binds a socket to it and tells the kernel "deliver traffic for this port to me." When you say a service is "listening on 443," you mean it created a socket, bound it to port 443, and called listen().

The operational consequence is that almost every "the service is down" page starts here. Before you blame DNS, routing, or the firewall, you ask one question: is the process actually running and bound to the address and port you expect? One command — ss -tlnp — answers it, showing every listening TCP socket, the exact address it is bound to, and the PID that owns it. Half of all connectivity incidents end the moment you read that output, because the answer is "nothing is listening there" or "it is bound to the wrong address."

Ports, Sockets, and the 4-Tuple

A listening socket is half-specified: it has a local address and port but no peer. When a client connects, the kernel creates a second, fully-specified socket for that connection — and what makes it unique is the 4-tuple: source IP, source port, destination IP, destination port. This is why a single listening socket on :443 can serve thousands of simultaneous connections. Each connected client has a different source IP or source port, so each 4-tuple is distinct, and the kernel demultiplexes incoming packets to the right connection by matching all four values. The listening socket itself never carries data; it only spawns connected sockets.

TCP and UDP differ in what a socket means. A TCP socket is connection-oriented: listen() then accept() establishes a stateful session with a handshake, and the kernel tracks its state through to teardown. A UDP socket is connectionless — there is no LISTEN state and no per-connection socket, just a single bound socket that receives datagrams from anyone. That is why ss shows UDP sockets as UNCONN rather than LISTEN: there is nothing to listen for. Ports below 1024 are privileged — a process needs CAP_NET_BIND_SERVICE (or root) to bind them — which is why a web server binding 80 or 443 either starts as root and drops privileges, or is granted the capability explicitly.

ss in Practice

The tool is ss from iproute2. It replaced netstat from the deprecated net-tools package: ss reads socket state directly from the kernel over Netlink instead of parsing /proc/net line by line, so it is faster on a busy host with tens of thousands of sockets and it reports more state. The flags worth committing to memory are -t (TCP), -u (UDP), -l (listening only), -n (numeric — do not resolve ports to service names or addresses to hostnames), and -p (show the owning process, which requires root to see other users' sockets).

# Every listening TCP socket, numeric, with the owning process
sudo ss -tlnp
# Typical output
State   Local Address:Port   Peer Address:Port  Process
LISTEN  127.0.0.1:5432       0.0.0.0:*          users:(("postgres",pid=812,fd=5))
LISTEN  0.0.0.0:443          0.0.0.0:*          users:(("nginx",pid=1190,fd=6))
LISTEN  [::]:22              [::]:*             users:(("sshd",pid=701,fd=3))

Read that output as three facts per line: what state the socket is in, the address and port it is bound to, and who owns it. Postgres above is bound to 127.0.0.1:5432 — loopback only, reachable from this host and nowhere else. Nginx is on 0.0.0.0:443 — every IPv4 interface. SSH on [::]:22 is the IPv6 wildcard, and on a dual-stack Linux box that single socket usually accepts IPv4 too via mapped addresses. To see established connections instead of listeners, drop the -l; to filter, ss takes expressions like ss -tn state established '( dport = :443 )' or ss -tnp dst 10.0.0.5.

Bind Address Pitfalls

The single most common "works locally, fails remotely" cause is a service bound to 127.0.0.1 instead of 0.0.0.0. Binding to 127.0.0.1 attaches the socket to the loopback interface alone: the process is up, the port is open, curl localhost on the box succeeds — and every connection from another machine is refused at the kernel before the application ever sees it, because no socket is bound to the interface the packet arrived on. Binding to 0.0.0.0 attaches to all IPv4 interfaces; binding to a specific address like 10.0.0.42 attaches to exactly that interface and is the right choice when a host has a public and a private NIC and the service should only answer on one.

The fix is in the application's own config, not the network. Postgres has listen_addresses, MySQL has bind-address, an app server has a host flag — defaults frequently ship as 127.0.0.1 for safety. Confirm with ss -tlnp: if the Local Address column reads 127.0.0.1 when you expected 0.0.0.0, you have found the fault, and no amount of firewall or routing work will help until the bind address changes.

Connection States

Every TCP socket moves through a state machine, and four states show up constantly in ss -tan output. Reading them correctly separates a non-problem from a real bug.

StateMeaningWhat it implies
LISTENSocket bound, waiting for connectionsService is up and accepting; the baseline healthy state
ESTABLISHEDHandshake complete, data can flowAn active connection; count them to gauge load
TIME_WAITLocal side closed first; waiting out 2×MSL (~60s)Normal. Protects against stray packets from the old connection
CLOSE_WAITPeer closed; local app has not called close()Usually an application bug — sockets are being leaked

The two that get misread are TIME_WAIT and CLOSE_WAIT, and they mean opposite things. Thousands of sockets in TIME_WAIT after a load test is expected: whichever side closes the connection first holds the socket for roughly 60 seconds (twice the maximum segment lifetime) so that delayed packets from the finished connection cannot be misdelivered to a new one reusing the same 4-tuple. It is not a leak and it clears on its own; the kernel reuses these efficiently and the historical net.ipv4.tcp_tw_reuse knob is rarely needed. CLOSE_WAIT is the alarm. It means the remote end closed the connection but your application never called close() on its file descriptor, so the socket sits forever. A growing CLOSE_WAIT count is a file-descriptor leak that will eventually exhaust the process's ulimit -n and cause "too many open files" — the bug is in the application code, not the kernel.

Unix Domain Sockets

Not every socket has a port. A Unix domain socket is a local-only endpoint addressed by a filesystem path — /run/postgresql/.s.PGSQL.5432, /var/run/docker.sock — or by an abstract name, and it never touches the network stack. Two processes on the same host talk through it directly via the kernel, skipping TCP/IP entirely, which is faster and means the connection cannot be reached from another machine no matter how the firewall is set. This is why Postgres, MySQL, and the Docker daemon all expose a Unix socket alongside (or instead of) a TCP port: local clients get lower latency and access is governed by ordinary filesystem permissions on the socket file rather than by network rules.

You list them with ss -xlnp (the -x selects Unix sockets). The practical consequence for debugging is that a client failing to connect over a Unix socket is a different problem from a network failure: there is no port, no bind address, and no firewall involved — the usual culprits are the wrong socket path or filesystem permissions denying the client access to the socket file. When a tool defaults to the Unix socket and you want TCP, you generally have to pass an explicit host (psql -h 127.0.0.1 rather than letting psql reach for the local socket).

Binding to 127.0.0.1 vs 0.0.0.0

127.0.0.1 — the loopback interface only. The service answers requests originating on the same host and refuses everything from the wire at the kernel. The correct default for databases and admin interfaces that only a co-located app or an SSH tunnel should reach. The number-one cause of "the port is listening but remote clients can't connect."

0.0.0.0 — every IPv4 interface on the box, loopback and external alike. Required for a service that genuinely must accept remote clients, such as a public web server. Pair it with a default-deny firewall; binding to all interfaces makes the port reachable from everywhere the network allows.

A specific address (10.0.0.42) — exactly one interface. The right choice on a multi-homed host that should answer on its private NIC but never on its public one. More precise than 0.0.0.0 and safer than relying on a firewall to undo an over-broad bind.

Common Mistakes
  • Leaving a service bound to 127.0.0.1 and then debugging the firewall and routing for an hour — curl localhost works, remote clients get connection refused, and ss -tlnp would have shown the loopback bind in one line.
  • Reading a pile of TIME_WAIT sockets as a leak and reaching for sysctl tuning. TIME_WAIT is the normal post-close state, clears in about 60 seconds on its own, and tuning tcp_tw_reuse usually fixes nothing real.
  • Ignoring a steadily climbing CLOSE_WAIT count — that is the application failing to close() sockets, a file-descriptor leak that ends in "too many open files" when it hits ulimit -n.
  • Still using netstat from the deprecated net-tools package — it may not be installed on a minimal Ubuntu image, parses /proc/net slowly under load, and shows less state than ss.
  • Confirming the port is listening and stopping there, forgetting the host firewall (ufw/nftables) or a cloud security group can still drop the packet before it reaches the listening socket — "listening" and "reachable" are two separate checks.
  • Running ss without sudo and seeing blank Process columns for other users' sockets, then concluding nothing owns the port when in fact a root-owned daemon does.
  • Forgetting -n, so ss tries reverse DNS on every peer and stalls for seconds against an unreachable resolver — the numeric flag is not cosmetic on a busy or DNS-impaired host.
Best Practices
  • Make sudo ss -tlnp the first command in any "service unreachable" investigation — it confirms in one line whether the process is up, what address it bound, and which PID owns it.
  • Bind services to the narrowest address that works: 127.0.0.1 for anything only a local app or SSH tunnel needs, a specific NIC address on multi-homed hosts, and 0.0.0.0 only when remote access is genuinely required.
  • Check listening status and the firewall as two separate steps — verify the socket with ss, then confirm ufw status or the nftables ruleset actually permits the port.
  • Treat a growing CLOSE_WAIT count as an application bug and fix the missing close(), rather than masking it by raising ulimit -n.
  • Prefer the Unix domain socket for local clients of Postgres, MySQL, and Docker — lower latency and access controlled by filesystem permissions instead of exposing a TCP port.
  • Always pass -n to ss on servers so it never blocks on reverse DNS, and learn the filter syntax (ss -tn state established dst 10.0.0.5) to cut large connection lists down to the question you are asking.
  • Standardize on ss over netstat in runbooks and monitoring scripts; net-tools is deprecated and absent from many minimal images.
Comparable toolsWindowsnetstat -ano for PID-to-port mapping, or PowerShell's Get-NetTCPConnectionmacOSlsof -i and netstat; macOS has no ss, and lsof ties sockets to processesnetstat — the legacy net-tools utility ss replaced on Linux; still works but slower and less complete

Knowledge Check

A web service is up, curl localhost works on the box, but remote clients get "connection refused." ss -tlnp shows it bound to 127.0.0.1:8080. What is the cause?

  • The socket is bound to loopback only, so the kernel refuses connections on any external interface — it must bind 0.0.0.0 or the NIC address
  • The firewall is silently dropping the inbound packets to port 8080 from remote clients and must be explicitly opened with a dedicated ufw allow rule
  • The default route is missing, so reply packets cannot reach remote clients
  • Port 8080 is privileged and the process lacks CAP_NET_BIND_SERVICE

After a load test you see tens of thousands of sockets in TIME_WAIT. What does this indicate?

  • Normal behavior — the side that closed first holds each socket about 60 seconds to prevent stray old packets being misdelivered; it clears on its own
  • A socket leak in the application that will steadily climb and eventually exhaust the per-process open file-descriptor limit set by the ulimit ceiling
  • The kernel has run out of ephemeral ports and is refusing new connections
  • The remote peers closed without the local app calling close()

Which state, when it grows steadily, points to a real application bug rather than expected behavior?

  • CLOSE_WAIT — the peer closed but the local app never called close(), leaking file descriptors toward the ulimit -n ceiling
  • TIME_WAIT — these accumulate because the kernel forgot to reclaim closed sockets
  • ESTABLISHED — too many of these always means a leak
  • LISTEN — a steadily growing count of sockets stuck in LISTEN means the server can no longer accept any new incoming client connections

Why is ss preferred over netstat on a current Ubuntu server?

  • It reads socket state directly from the kernel over Netlink rather than parsing /proc/net, so it is faster under load and net-tools is deprecated
  • ss is the only tool that can show the process owning a socket
  • netstat cannot display IPv6 sockets at all, so on a dual-stack production server it silently hides roughly half of the host's listening ports from view
  • ss opens ports automatically in the firewall while it inspects them

What distinguishes a Unix domain socket from a TCP socket for a local database client?

  • It is addressed by a filesystem path, bypasses the network stack entirely, and access is governed by file permissions rather than ports or firewalls
  • It uses a reserved port below 1024 that only root may bind
  • It is reachable from other hosts on the same local LAN segment but, unlike an ordinary TCP socket bound to the NIC, never from the public internet beyond it
  • It is just a TCP connection to 127.0.0.1 with a friendlier name

You got correct