A layer-by-layer method: link, routes, DNS, ports, TLS and HTTP, with failures reproduced on a lab and playbooks for common symptoms
▶Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Networking and Firewalls playlist.
Network Troubleshooting: a layer-by-layer method with real tools
ping; ip route get; dig; ss; curl -v; openssl s_client
"The network is broken" is a symptom, not a diagnosis. The way through is the same every time: walk UP the stack, one layer at a time, and find the first layer that fails. Is there a link and an address? A route? Does the name resolve? Does the port answer? Does TLS succeed? Does the application reply? Each question has one or two commands, and each failure looks different.
This guide gives you that order, how to read what the tools tell you, and the playbooks for the problems that make up most tickets: "the site is down for me", "works by IP but not by name", "SSH hangs", "intermittent timeouts", "VPN connected but nothing works".
To make every example real without touching anything else, I built a small LAB on this machine's loopback: a tiny DNS server that can answer, refuse, fail or stay silent; TCP servers that answer, hang, reset or are closed; and TLS servers with a valid, an expired, a wrong-name and a self-signed certificate. Everything below that shows output was run against that lab, with the real tools (dig, curl, openssl, ss, nc, ping). Against your real servers you leave out the lab parts: no @127.0.0.1 -p 15353 for dig, and real hostnames instead of 127.0.0.1. Commands that need root, or that depend on your own network, are shown without output.
The method: walk up the stack
Layer Question Tools
1. Link Is the cable, Wi-Fi or virtual NIC up? ip -br link, ethtool, dmesg
2. Address Do I have an IP address, in the right net? ip -br addr, ip neigh
3. Route Can packets find the way (gateway)? ip route, ip route get, ping, mtr, traceroute
4. Name Does the name turn into the right address? dig, getent, resolvectl
5. Port (TCP/UDP) Does something answer on that port? ss, nc, curl, nmap, a firewall check
6. Security (TLS) Does the handshake and certificate check out? openssl s_client, curl -v
7. Application Does the program answer correctly and fast? curl -w, logs, the application's own tools
Work UP the list and stop at the first failure: everything below it is fine, everything above it is untestable until you fix it. And keep asking the three scoping questions: from where (which machine, which network)? to what (which address and port)? since when (and what changed)? A result that differs between two machines tells you the problem is in the path or the client, not the server.
$ date -u; hostname; ip -br addr # start your notes with the time, the machine, and its addresses
Write things down as you go: the exact command, the exact error, the time. "It says connection timed out" and "curl: (28) Connection timed out after 3002 milliseconds" are two different bug reports.
Three ways a connection fails (and what each one tells you)
Trying to open a TCP connection ends in one of four ways, and the difference is the most useful clue in all of networking. The lab has a server for each:
$ curl -sS -m 3 http://127.0.0.1:18080/ok
hello
$ curl -sS -m 3 http://127.0.0.1:18083/ok; echo "exit status $?"
curl: (7) Failed to connect to 127.0.0.1 port 18083 after 0 ms: Couldn't connect to server
exit status 7
$ curl -sS -m 3 http://127.0.0.1:18082/ok; echo "exit status $?"
curl: (56) Recv failure: Connection reset by peer
exit status 56
$ curl -sS -m 3 http://127.0.0.1:18081/ok; echo "exit status $?"
curl: (28) Operation timed out after 3000 milliseconds with 0 bytes received
exit status 28
What each outcome means:
Success (hello above) the whole path works, a program is listening and answered
Connection REFUSED (exit 7) your packet REACHED the machine and the machine said "nothing listens on that port". Network, routing and firewall are fine; the program is not running, is bound to another address or port, or you used the wrong port
Connection RESET (exit 56) the connection was accepted and then torn down: a proxy or load balancer rejecting you, an application that crashed on your request, or a firewall injecting resets
TIMEOUT (exit 28) silence. Either a firewall DROPS the packets, there is no route back, the host is down, OR (as in the last lab server) the connection was accepted but the application never replies. You must find out which layer is silent
Refused is a quick, clear answer; a timeout is the hard one. The same distinction at the lowest level, with no HTTP involved, using bash itself and nc:
$ for p in 18080 18081 18082 18083; do (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null && echo "port $p: connected" || echo "port $p: refused"; done
port 18080: connected
port 18081: connected
port 18082: connected
port 18083: refused
$ nc -zv -w2 127.0.0.1 18080-18083 2>&1
Connection to 127.0.0.1 18080 port [tcp/*] succeeded!
Connection to 127.0.0.1 18081 port [tcp/*] succeeded!
Connection to 127.0.0.1 18082 port [tcp/*] succeeded!
nc: connect to 127.0.0.1 port 18083 (tcp) failed: Connection refused
Connected on 18081 means the TCP handshake worked even though the application there never answers HTTP: TCP only proves something accepted the connection. A firewall that DROPS makes the connect itself time out; one that REJECTS answers "refused" (or an ICMP error): see the Firewall guide. To tell the two timeouts apart (silent firewall versus a hung application), check whether the connect completed: curl -v prints "* Connected to" only when it did, and curl -w '%{time_connect}' is 0 when it did not.
$ curl -s -m 3 -o /dev/null -w 'connect=%{time_connect}\nhttp=%{http_code}\n' http://127.0.0.1:18081/ok | awk -F= '$1 == "connect" {printf "connect=%.1fs\n", $2} $1 == "http" {print "http=" $2}'
connect=0.0s
http=000
(time_connect is how long the TCP connection took to open; the 0.0s on a lab server with a hung application means the connect was instant, and the silence came AFTER it: an application problem, not a network one.)
Layers 1 to 3: link, address and route
These are your own machine's view. They are quick, and most "no network" reports end here.
$ ip -br link # interfaces and whether they are UP (state UP, LOWER_UP means the cable or Wi-Fi is connected)
$ ip -br addr # addresses (an interface with no address, or a 169.254.x.x address, did not get one from DHCP)
$ ip route # the routing table: "default via 192.168.1.1 dev eth0" is the way to everywhere else
$ ip neigh # the ARP/neighbour cache: the gateway should be REACHABLE or STALE, not FAILED or INCOMPLETE
$ sudo ethtool eth0 | grep -E 'Speed|Duplex|Link detected' # negotiated speed (a gigabit port at 100 Mb/s means a bad cable) and link
$ ip -s link show eth0 # packet counters: errors and drops that grow point at cable, driver or congestion problems
$ sudo dmesg -T | grep -iE 'link|eth0|wlan|firmware' | tail # link flaps and driver messages
Ask "which way would my packet go?" with route get: it names the interface, the source address and the gateway that would be used.
$ ip -br addr show lo
lo UNKNOWN 127.0.0.1/8 ::1/128
$ ip route get 127.0.0.1
local 127.0.0.1 dev lo src 127.0.0.1 uid 1000
cache <local>
$ ip route get 8.8.8.8 # on your machine: via 192.168.1.1 dev eth0 src 192.168.1.50
$ ip rule; ip route show table all | head -20 # policy routing (VPNs and containers add rules: a packet may use a different table)
Read the counters. Here is a typed sample of ip -s link output from a machine with a bad cable, and a one-liner that flags it:
$ awk '/^[0-9]+:/ {dev = $2} /RX:/ {rx = 1; next} /TX:/ {tx = 1; next} rx {rx = 0; if ($3 + $4 > 0) print dev, "RX errors=" $3, "dropped=" $4} tx {tx = 0; if ($3 + $5 > 0) print dev, "TX errors=" $3, "carrier=" $5}' iplink.sample
eth1: RX errors=1843 dropped=201
eth1: TX errors=12 carrier=37
Errors, drops and carrier problems that keep growing (not just a stale count from last year: look twice a minute apart) mean physical or driver trouble. Subnet maths is a classic mistake: is that address on my network? Ask Python instead of doing binary in your head:
$ python3 -c 'import ipaddress as i; n = i.ip_network("10.0.4.0/22"); print(n[0], "to", n[-1], "|", len(list(n.hosts())), "hosts"); print("10.0.5.200 in net:", i.ip_address("10.0.5.200") in n); print("10.0.8.1 in net:", i.ip_address("10.0.8.1") in n)'
10.0.4.0 to 10.0.7.255 | 1022 hosts
10.0.5.200 in net: True
10.0.8.1 in net: False
$ python3 -c 'import ipaddress as i; a = i.ip_network("10.0.0.0/16"); b = i.ip_network("10.0.5.0/24"); print("VPN and LAN overlap:", a.overlaps(b))'
VPN and LAN overlap: True
Overlapping subnets (a VPN range that overlaps your home network, or Docker's 172.17.0.0/16 range inside a company that also uses it) make some addresses unreachable in confusing ways: the route table sends the packet to the wrong place.
ping: reachability and loss, and what the replies say
$ ping -c 3 -n 127.0.0.1 | grep -E 'bytes from' | sed 's/time=.*/time=<fast>/'
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=<fast>
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=<fast>
64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=<fast>
$ ping -c 2 -q 127.0.0.1 | grep packets | sed 's/, time.*//'
2 packets transmitted, 2 received, 0% packet loss
$ ping -c 4 192.168.1.1 # the gateway: if this fails, the problem is in your own LAN
$ ping -c 4 1.1.1.1 # an internet address WITHOUT a name: tests routing and the internet, not DNS
$ ping -c 4 example.com # same, with a name: if this fails but 1.1.1.1 works, suspect DNS
How to read ping: replies = the packet got there and back; "Destination Host Unreachable" from YOUR address = ARP failed, the host is not on the local network; "Destination Net Unreachable" from a ROUTER's address = that router has no route; no reply at all = dropped (many networks block ping on purpose, so no ping does NOT prove a server is down: test the real port). ttl in the reply hints at the distance and the OS (64 Linux, 128 Windows). Loss above a percent or two, or time that jumps around, means congestion or a bad link.
Where does the path break? mtr (ping and traceroute together) shows loss and latency per hop:
$ mtr -rwn -c 20 example.com # a report: look for the FIRST hop where loss starts AND continues to the end (loss at one hop only is often just a router ignoring probes)
$ tracepath -n example.com # also discovers the path MTU
$ traceroute -T -p 443 -n example.com # TCP traceroute: gets through firewalls that block ping and UDP
MTU problems: small things work (ping, DNS) but big things hang (large downloads, SSH logins that stall after the key exchange, HTTPS sites that start loading and stop). A tunnel or VPN has a smaller MTU, and a firewall that drops the "fragmentation needed" ICMP message breaks path MTU discovery. Test by sending full-size packets that may NOT fragment (1472 bytes of data + 28 header = 1500):
$ ping -c 3 -M do -s 1472 example.com # works on plain Ethernet
$ ping -c 3 -M do -s 1372 example.com # try smaller until it works: the biggest one that passes shows the real path MTU
$ sudo ip link set dev eth0 mtu 1400 # as a fix: lower the interface MTU (or MSS clamping on the firewall: see the Firewall guide)
Layer 4: DNS, the usual suspect
A huge share of "network" problems are DNS. The system resolves names through a chain: the application asks the C library, which follows /etc/nsswitch.conf (usually: /etc/hosts first, then DNS), which asks the resolver listed in /etc/resolv.conf (on modern Ubuntu that is the local stub 127.0.0.53 of systemd-resolved, which forwards to the real servers).
$ grep '^hosts' /etc/nsswitch.conf
hosts: files mdns4_minimal [NOTFOUND=return] dns
$ cat /etc/resolv.conf; resolvectl status | head -20 # which servers does THIS machine use, per interface?
$ getent hosts example.com # resolves exactly like an application does (honours /etc/hosts and nsswitch)
$ getent ahosts example.com | head # all addresses, IPv4 and IPv6
The tool for asking DNS directly is dig. The lab server answers the names in example.test (it listens on port 15353 here; against a real resolver you leave off @127.0.0.1 -p 15353). Success first:
$ dig @127.0.0.1 -p 15353 www.example.test +noall +answer
www.example.test. 300 IN A 192.0.2.10
$ dig @127.0.0.1 -p 15353 api.example.test +noall +answer
api.example.test. 60 IN CNAME www.example.test.
www.example.test. 300 IN A 192.0.2.10
$ dig @127.0.0.1 -p 15353 www.example.test +short
192.0.2.10
$ dig @127.0.0.1 -p 15353 MX example.test +short
10 mail.example.test.
$ dig @127.0.0.1 -p 15353 TXT example.test +short
"v=spf1 ip4:192.0.2.25 -all"
Read the columns: name, TTL (seconds a resolver may cache the answer), class IN, type (A = IPv4, AAAA = IPv6, CNAME = an alias, MX = mail, TXT = text such as SPF) and the data. The api name is a CNAME: dig shows the alias and then what it points to. Now the four ways a lookup fails, which look very different:
$ dig @127.0.0.1 -p 15353 nope.example.test | grep -o 'status: [A-Z]*'
status: NXDOMAIN
$ dig @127.0.0.1 -p 15353 broken.example.test | grep -o 'status: [A-Z]*'
status: SERVFAIL
$ dig @127.0.0.1 -p 15353 www.example.org | grep -o 'status: [A-Z]*'
status: REFUSED
$ dig @127.0.0.1 -p 15353 silent.example.test +time=1 +tries=1 2>&1 | grep -E 'timed out|no servers'
;; communications error to 127.0.0.1#15353: timed out
;; no servers could be reached
NOERROR with an answer the name exists and resolved
NOERROR with NO answer the name exists but has no record of the type you asked for (asking for AAAA on an IPv4-only name)
NXDOMAIN the name DOES NOT EXIST in that zone: a typo, a record that was never created, or a record that has not reached this resolver yet (negative answers are cached too)
SERVFAIL the resolver could not get an answer: the authoritative server is broken or unreachable, or DNSSEC validation failed. Ask a different resolver (dig @1.1.1.1 ...) to see if it is you or them
REFUSED that server will not answer for that name: you asked an authoritative-only server for a name it does not host, or a resolver that does not allow you
timeout ("no servers could be reached") the server is down, a firewall drops port 53, or the query went to the wrong address
How long a lookup took, and what happens when the server is slow (the lab's slow name answers after three seconds):
$ dig @127.0.0.1 -p 15353 slow.example.test +time=5 | grep -o 'Query time: [0-9]*' | awk '{printf "Query time: about %d seconds\n", $3 / 1000 + 0.5}'
Query time: about 3 seconds
$ dig @127.0.0.1 -p 15353 www.example.test +tcp +short
192.0.2.10
A resolver that takes seconds makes every connection to a new name slow, and applications may time out before they even connect: always measure the lookup separately. +tcp forces TCP (needed for big answers, and a good test that port 53/TCP is open). Test the application path without DNS at all with curl --resolve, which pins a name to an address:
$ curl -sS -m 3 --resolve www.example.test:18080:127.0.0.1 http://www.example.test:18080/ok
hello
$ curl -sS -m 3 http://no-such-name.invalid/ ; echo "exit status $?"
curl: (6) Could not resolve host: no-such-name.invalid
exit status 6
Exit 6 = could not resolve the host; the same URL with --resolve works, which proves the server is fine and the problem is purely the name. The DNS checklist for a name that "does not work":
$ dig example.com +short # what does MY resolver say?
$ dig @1.1.1.1 example.com +short # what does a public resolver say? (a difference = a stale cache or split-horizon DNS)
$ dig example.com NS +short; dig @ns1.example.com example.com +norecurse # ask the AUTHORITATIVE server directly: the truth, no caches
$ dig +trace example.com # follow the delegation from the root: finds a broken delegation or lame server
$ dig -x 192.0.2.10 +short # reverse lookup (PTR): mail servers and logs care
$ dig example.com SOA +short # the serial number: did the change reach this server?
$ dig example.com A +noall +answer +ttlid # the TTL shows how long a wrong answer can live in caches
$ sudo resolvectl flush-caches; resolvectl statistics # clear the local cache (after a DNS change); cache hit rates
Common DNS problems: a record changed but the old answer persists (the TTL, or a negative cache: wait it out, or flush); it works for some people and not others (different resolvers, or split-horizon DNS giving internal and external answers); a name works from the server but not from a container (the container uses another resolver: docker exec c cat /etc/resolv.conf); search domains (a bare name like db is tried as db.corp.example first: dig +search db; slow lookups of single-label names usually come from this and ndots); IPv6 AAAA records that point nowhere make some clients wait before falling back to IPv4; a CNAME cannot sit at the zone apex next to other records; and DNSSEC failures show as SERVFAIL on validating resolvers only.
Layer 5: ports, listeners and sockets
Is anything listening, on which address, and who owns it?
$ ss -ltn '( sport = :18080 or sport = :18081 or sport = :18082 )'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 5 127.0.0.1:18080 0.0.0.0:*
LISTEN 0 20 127.0.0.1:18081 0.0.0.0:*
LISTEN 0 20 127.0.0.1:18082 0.0.0.0:*
$ sudo ss -ltnp # every listening TCP socket with its program (the -p part needs root for other users' programs)
$ ss -ltn | awk 'NR == 1 || /:443 /' # who listens on 443?
$ ss -tn state established '( dport = :443 )' | head # current connections to a remote port
$ ss -s # a summary: totals by state
The "Local Address" column is the key detail: 127.0.0.1:18080 means only this machine can connect; 0.0.0.0:PORT (or *:PORT or [::]:PORT) means all interfaces; a specific address (10.0.0.5:8080) only that one. "It listens but I cannot connect from another machine" is almost always the 127.0.0.1 versus 0.0.0.0 difference, or a firewall. Starting a second server on a port that is taken:
$ python3 -c "import socket; s = socket.socket(); s.bind(('127.0.0.1', 18080))" 2>&1 | tail -1
OSError: [Errno 98] Address already in use
"Address already in use" means another process holds the port: ss -ltnp | grep :PORT names it (or lsof -i :PORT). A service that will not start after a crash may be waiting out TIME-WAIT connections; the server should set SO_REUSEADDR, and most do.
Look at a live connection: the lab's hung server accepts us and keeps the connection open, so we can see an ESTABLISHED socket:
$ exec 3<>/dev/tcp/127.0.0.1/18081; ss -Htn state established '( dport = :18081 )' | awk '{print "ESTABLISHED Recv-Q=" $1 " Send-Q=" $2}'; exec 3>&-
ESTABLISHED Recv-Q=0 Send-Q=0
Connection states you will meet in ss: LISTEN (waiting), ESTAB (open), SYN-SENT (we sent a SYN and wait: a firewall DROP or a dead host shows many of these), SYN-RECV (half-open: a flood, or clients that vanished), TIME-WAIT (a recently closed connection waiting a minute: normal in large numbers on busy servers), CLOSE-WAIT (the remote end closed but OUR application never did: a bug, and many of them leak file descriptors), FIN-WAIT-2 (we closed, waiting for the peer). Two numbers that matter on a LISTEN socket: Recv-Q (connections waiting for the application to accept them: a growing value means a slow or stuck application) and Send-Q (the backlog size).
Test a port from another machine: nc, bash and nmap (the last one tells apart open, closed and filtered):
$ nc -zv -w3 server.example.com 22 443 # open? "succeeded" or "refused" or a timeout
$ timeout 3 bash -c 'exec 3<>/dev/tcp/server.example.com/443' && echo open || echo "closed or filtered"
$ nmap -Pn -p 22,80,443 server.example.com # open / closed (refused) / filtered (silence: a firewall)
$ nmap -Pn -sU -p 53,123 server.example.com # UDP is harder: open|filtered is often all you learn
And the other direction: your own machine's outgoing connections, and who made them:
$ ss -tnp state established | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head # busiest remote peers
$ sudo lsof -iTCP -sTCP:ESTABLISHED -n -P | head # which program owns each connection
Layer 6: TLS and certificates
When a connection to port 443 "works" but the browser or curl complains, the problem is in the handshake. openssl s_client is the microscope. Four servers on the lab, each with a different certificate. The summary format (-brief) shows the protocol, the certificate it presented, and whether verification passed (we check against the lab's own CA and name):
$ echo | openssl s_client -connect 127.0.0.1:18443 -servername good.example.test -CAfile ca.pem -verify_hostname good.example.test -brief 2>&1 | grep -E 'Protocol version|Peer certificate|Verification'
Protocol version: TLSv1.3
Peer certificate: CN = good.example.test
Verification: OK
$ echo | openssl s_client -connect 127.0.0.1:18444 -servername expired.example.test -CAfile ca.pem -verify_hostname expired.example.test -brief 2>&1 | grep -E 'Peer certificate|Verification'
Peer certificate: CN = expired.example.test
Verification error: certificate has expired
$ echo | openssl s_client -connect 127.0.0.1:18445 -servername good.example.test -CAfile ca.pem -verify_hostname good.example.test -brief 2>&1 | grep -E 'Peer certificate|Verification'
Peer certificate: CN = other.example.test
Verification error: hostname mismatch
$ echo | openssl s_client -connect 127.0.0.1:18446 -servername self.example.test -CAfile ca.pem -verify_hostname self.example.test -brief 2>&1 | grep -E 'Peer certificate|Verification'
Peer certificate: CN = self.example.test
Verification error: self-signed certificate
The three failures and what they mean:
certificate has expired the certificate's validity period ended (or has not begun): renew it, and check the server's CLOCK as well as the date on the certificate
hostname mismatch the certificate is valid but issued for OTHER names: the name you typed is not in its Subject Alternative Names (a wrong certificate on a virtual host, a missing name, or you used an IP address)
self-signed certificate / unable to get local issuer certificate the certificate is not signed by an authority your system trusts: a private CA that clients do not have, a self-signed test certificate, or a server that does not send its intermediate certificate
curl reports the same problems with exit status 60, which is what scripts and monitoring see:
$ curl -sS -m 5 --cacert ca.pem --resolve good.example.test:18443:127.0.0.1 https://good.example.test:18443/ -o /dev/null -w '%{http_code}\n'
200
$ curl -sS -m 5 --cacert ca.pem --resolve expired.example.test:18444:127.0.0.1 https://expired.example.test:18444/ -o /dev/null 2>&1 | head -1
curl: (60) SSL certificate problem: certificate has expired
$ curl -sS -m 5 --cacert ca.pem --resolve good.example.test:18445:127.0.0.1 https://good.example.test:18445/ -o /dev/null 2>&1 | head -1
curl: (60) SSL: no alternative certificate subject name matches target host name 'good.example.test'
$ curl -sS -m 5 --resolve self.example.test:18446:127.0.0.1 https://self.example.test:18446/ -o /dev/null 2>&1 | head -1
curl: (60) SSL certificate problem: self-signed certificate
curl -k (and wget --no-check-certificate) turn verification off: fine for one test to prove "TLS is the only problem", never in scripts or production.
Inspect a certificate file or a live server's certificate: names, issuer, validity.
$ openssl x509 -in good.pem -noout -subject -issuer -dates
subject=CN = good.example.test
issuer=CN = Lab Root CA
notBefore=Jan 1 00:00:00 2026 GMT
notAfter=Dec 31 00:00:00 2036 GMT
$ openssl x509 -in good.pem -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:good.example.test
$ openssl x509 -in expired.pem -noout -checkend 0; echo "exit status $? (1 means it is expired or will be)"
Certificate will expire
exit status 1 (1 means it is expired or will be)
$ openssl verify -CAfile ca.pem good.pem
good.pem: OK
$ openssl verify -CAfile ca.pem expired.pem
CN = expired.example.test
error 10 at 0 depth lookup: certificate has expired
error expired.pem: verification failed
# (exit status 2)
$ echo | openssl s_client -connect 127.0.0.1:18443 -CAfile ca.pem -showcerts 2>/dev/null | grep -E '^ *[0-9] s:|^ i:'
0 s:CN = good.example.test
i:CN = Lab Root CA
Does the private key belong to this certificate? A mismatch is why a server refuses to start with a new certificate (compare the public keys; the hashes must be equal):
$ [ "$(openssl x509 -in good.pem -noout -pubkey | sha256sum)" = "$(openssl pkey -in good.key -pubout | sha256sum)" ] && echo "certificate and key MATCH"
certificate and key MATCH
$ [ "$(openssl x509 -in good.pem -noout -pubkey | sha256sum)" = "$(openssl pkey -in wrong.key -pubout | sha256sum)" ] || echo "certificate and key do NOT match"
certificate and key do NOT match
Versions and ciphers, and what a client without SNI sees:
$ echo | openssl s_client -connect 127.0.0.1:18443 -tls1_2 -brief 2>&1 | grep -E 'Protocol version'
Protocol version: TLSv1.2
$ echo | openssl s_client -connect 127.0.0.1:18443 -tls1_3 -brief 2>&1 | grep -E 'Protocol version|Ciphersuite'
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
$ echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates # a live site (with SNI: the name the client asks for, which selects the certificate)
$ echo | openssl s_client -connect 203.0.113.5:443 2>/dev/null | openssl x509 -noout -subject # WITHOUT -servername a shared server may show a default certificate: a classic "works in the browser, wrong cert in my script"
$ echo | openssl s_client -connect smtp.example.com:587 -starttls smtp -brief # TLS on mail ports upgrades with STARTTLS (also imap, pop3, ftp)
Common certificate problems: renewed on the disk but the server was never reloaded (it still serves the old one: compare the file with what s_client sees); the intermediate certificate missing from the chain file (browsers cope, curl and Java do not); a clock that is wrong (expired or "not yet valid" for everyone); the certificate lists the hostname only as the Common Name and not in the Subject Alternative Names (modern clients require SAN); and a proxy or antivirus that re-signs TLS with its own CA (the issuer is your company, not a public CA).
Layer 7: HTTP and the application
curl is the universal client. Start with the facts of one request: the status line and headers.
$ curl -sS -D - -o /dev/null http://127.0.0.1:18080/ok | grep -vE '^(Date|Server):'
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 6
$ curl -s -o /dev/null -w 'status=%{http_code} size=%{size_download} ip=%{remote_ip} port=%{remote_port}\n' http://127.0.0.1:18080/ok
status=200 size=6 ip=127.0.0.1 port=18080
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/missing
404
$ curl -sSIL http://127.0.0.1:18080/redirect | grep -E '^(HTTP|Location)'
HTTP/1.1 301 Moved Permanently
Location: /ok
HTTP/1.1 200 OK
$ curl -s -o /dev/null -w 'final=%{url_effective} redirects=%{num_redirects}\n' -L http://127.0.0.1:18080/redirect
final=http://127.0.0.1:18080/ok redirects=1
$ curl -s -w '[%{http_code}]\n' http://127.0.0.1:18080/error
bad gateway
[502]
What the status codes tell you: 2xx success; 3xx redirect (the Location header says where; loops show as "too many redirects"); 400 your request was malformed; 401 not authenticated; 403 refused even though known; 404 no such path; 429 rate limited; 500 the application crashed; 502 Bad Gateway (a proxy or load balancer could not get a valid answer from the application behind it); 503 unavailable (overloaded, down for maintenance, or no healthy backend); 504 Gateway Timeout (the application behind the proxy took too long). 5xx from a proxy means the problem is BEHIND it: go to the backend's own logs.
Where does the time go? Break one request into its phases with -w. The lab's /slow path waits three seconds before answering:
$ curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n' http://127.0.0.1:18080/ok | awk '{for (i = 1; i <= NF; i++) { split($i, a, "="); printf "%s=%.0fs ", a[1], a[2] } print ""}'
dns=0s connect=0s first_byte=0s total=0s
$ curl -s -o /dev/null -w 'dns=%{time_namelookup} connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n' http://127.0.0.1:18080/slow | awk '{for (i = 1; i <= NF; i++) { split($i, a, "="); printf "%s=%.0fs ", a[1], a[2] } print ""}'
dns=0s connect=0s first_byte=3s total=3s
$ curl -sS -m 1 http://127.0.0.1:18080/slow
curl: (28) Operation timed out after 1000 milliseconds with 0 bytes received
# (exit status 28)
Read it as phases: dns (the lookup), connect (TCP, plus TLS time in time_appconnect for HTTPS), first_byte (everything until the server started answering: this is the application "thinking"), total (the whole download). A big first_byte with a small connect is a slow application, not a slow network. -m sets the overall time limit (exit 28 if exceeded); --connect-timeout limits just the connect phase.
The same on a real site (shown without output because it depends on your network):
$ curl -s -o /dev/null -w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} first-byte %{time_starttransfer} total %{time_total} http %{http_code}\n' https://example.com/
Pretend to be a browser for a specific site, or test one backend behind a load balancer:
$ curl -sS -m 3 -H 'Host: www.example.test' http://127.0.0.1:18080/ok
hello
$ curl -sS -H 'Host: www.example.com' http://203.0.113.5/ # a virtual host on a specific server, bypassing DNS and the load balancer
$ curl -sS --connect-to www.example.com:443:203.0.113.5:443 https://www.example.com/ # keep TLS and SNI correct while pinning the server address
$ curl -sS -x http://proxy.example.com:3128 https://example.com/ -I # through a proxy; env vars http_proxy, https_proxy and no_proxy do the same for many tools
$ curl -sv https://example.com/ 2>&1 | grep -E '^[*<>] ' | head -30 # the whole conversation: connect, TLS, request, response headers
$ curl -sS --http1.1 https://example.com/ -I; curl -sS --http2 https://example.com/ -I # compare protocol versions
$ curl --retry 3 --retry-delay 2 --retry-connrefused -fsS https://example.com/health # a robust health check for scripts (-f makes HTTP errors exit non-zero)
curl exit codes worth knowing: 6 could not resolve host (DNS); 7 failed to connect (refused, or no route); 28 timed out; 35 TLS handshake failure; 52 empty reply; 56 receive failure (a reset); 60 certificate problem; 22 the server returned an HTTP error (with -f); 47 too many redirects.
$ curl -sS -m 3 http://127.0.0.1:18083/ok ; echo "exit $?"
curl: (7) Failed to connect to 127.0.0.1 port 18083 after 0 ms: Couldn't connect to server
exit 7
$ curl -sS -m 3 http://127.0.0.1:18082/ok ; echo "exit $?"
curl: (56) Recv failure: Connection reset by peer
exit 56
$ curl -sS -m 2 http://127.0.0.1:18081/ok ; echo "exit $?"
curl: (28) Operation timed out after 2000 milliseconds with 0 bytes received
exit 28
$ curl -fsS -m 3 http://127.0.0.1:18080/missing ; echo "exit $?"
curl: (22) The requested URL returned error: 404
exit 22
Capturing packets when nothing else explains it
tcpdump shows what really crosses the wire. It needs root and it is the last resort, but it settles arguments. Capture a narrow filter (one host and one port), count packets, and save to a file for later or for Wireshark:
$ sudo tcpdump -ni any host 203.0.113.5 and port 443 -c 20 # -n no name lookups, -i any every interface, -c stop after 20
$ sudo tcpdump -ni eth0 'tcp[tcpflags] & (tcp-syn) != 0 and not tcp[tcpflags] & (tcp-ack) != 0' -c 20 # only SYN packets: who is trying to connect?
$ sudo tcpdump -ni any port 53 -c 20 # DNS queries and answers
$ sudo tcpdump -ni eth0 -w /tmp/capture.pcap port 443 -c 1000; tcpdump -nr /tmp/capture.pcap | head # save, then read back
$ sudo tcpdump -ni any icmp # pings and ICMP errors (unreachable, fragmentation needed)
Read the flags in the output: S = SYN (start), S. = SYN-ACK (the server agreed), . = ACK, P. = data pushed, F. = FIN (close), R = RST (reset). A typed sample, and what each pattern means:
$ awk '/Flags \[S\],/ {syn[$5]++} /Flags \[S\.\]/ {ok[$3]++} /Flags \[R\.?\]/ {rst[$3]++} END {for (h in syn) print h, "SYNs sent:", syn[h]; for (h in ok) print h, "answered with SYN-ACK (open)"; for (h in rst) print h, "answered with RST (closed port)"}' capture.sample | sort
203.0.113.5.443: SYNs sent: 3
203.0.113.7.443 answered with SYN-ACK (open)
203.0.113.7.443: SYNs sent: 1
203.0.113.9.443 answered with RST (closed port)
203.0.113.9.443: SYNs sent: 1
Three SYNs to .5 with no answer (retries at 1, then 2 seconds later) = a firewall DROP or a dead host; one SYN answered by RST = the port is closed; SYN, SYN-ACK, ACK = a healthy handshake. Capture on BOTH ends when you can: a packet seen leaving one side and never arriving on the other was dropped in between.
Playbooks: symptom to steps
"The website is down for me"
1. Is it down for everyone? Test from another network (a phone on mobile data, or an online checker): if it works there, the problem is your network or DNS.
2. dig example.com +short, then dig @1.1.1.1 example.com +short: a name that does not resolve, or resolves differently.
3. nc -zv example.com 443 (or curl -v): refused, timeout or connected?
4. curl -sS -w for the phases: slow DNS, slow connect, slow first byte?
5. openssl s_client -connect example.com:443 -servername example.com -brief: certificate expired or wrong?
6. curl -I https://example.com/: the status code, and which server answered (Server, Via, X- headers).
"It works by IP address but not by name"
DNS, not the network. dig the name; compare with a public resolver; check /etc/hosts, search domains and the resolver in /etc/resolv.conf; test with curl --resolve to prove the server is fine.
"SSH hangs" (stalls before the password prompt, or freezes after logging in)
Stalls before the prompt: reverse DNS lookups on the server (UseDNS) or GSSAPI authentication timeouts: ssh -vvv user@host shows the step that waits; try ssh -o GSSAPIAuthentication=no. Freezes after the key exchange or when output is large: an MTU problem (test with ping -M do -s). Disconnects after idle time: a firewall or NAT expired the connection: ServerAliveInterval 30 in ~/.ssh/config. "Connection refused": sshd not running or a different port: ss -ltnp | grep sshd on the server.
"Intermittent timeouts"
Look for pattern: one backend among several (curl each by IP: for ip in ...; do curl --resolve ... done), a DNS answer that rotates, packet loss on one path (mtr over time), connection tracking table full on a firewall (nstat, dmesg: "nf_conntrack: table full"), ephemeral ports exhausted on a busy client (ss -s: thousands of TIME-WAIT), a saturated link (iperf3, ip -s link), or an application whose workers are all busy (Recv-Q growing on the listener).
"The server cannot reach the internet" (but the LAN works)
ip route (is there a default route?), ping the gateway, ping 1.1.1.1 (routing and NAT), dig example.com (DNS), curl -I https://example.com (proxy or firewall rules for the outbound web). A server behind a proxy needs http_proxy/https_proxy for curl and apt separately (/etc/apt/apt.conf.d). Check the cloud security group and route table, and NAT for private subnets.
"VPN is connected but nothing works"
ip route (did the VPN add routes, or only some?), ip route get TARGET (does it go through the tunnel?), overlapping subnets (the Python overlap check above), DNS (the VPN's resolver pushed or not: resolvectl status), MTU (lower the tunnel MTU), and the firewall on the far side (does it know your VPN address range?).
"Slow downloads or uploads"
mtr for loss and latency; iperf3 -c server to separate the network from the application (iperf3 -s on one end); ss -ti dst SERVER (look at rtt, retrans, cwnd: lots of retransmits mean loss); a wrong duplex or a 100 Mb/s negotiation (ethtool); a middlebox or VPN limiting throughput.
"Mail is not delivered"
dig MX example.com and the TXT records (SPF); nc -zv mail.example.com 25 (many ISPs block outbound 25 from consumer lines); openssl s_client -connect mail.example.com:25 -starttls smtp -brief; the mail log on the server; reverse DNS (PTR) for the sending IP.
"A container cannot reach X"
docker exec c getent hosts NAME (DNS inside the container), the container's /etc/resolv.conf, docker network inspect (is it on the right network? user-defined networks have name resolution, the default bridge does not), published ports (docker port c), and the host firewall. See the Docker Troubleshooting guide.
What the errors say
Message Layer and first move
Network is unreachable routing: no route to that network: ip route, ip route get ADDRESS
No route to host ARP failed on the local net, or a router returned ICMP unreachable: ip neigh, the gateway, the cable
Connection refused port: the host answered "no listener": ss -ltnp on the server, the port and address it binds
Connection timed out port: silence: a firewall drop, a dead host, a bad route. Capture with tcpdump to see whether the SYN even arrives
Connection reset by peer port/app: something accepted and tore it down: a proxy or application error, an idle timeout, or a firewall with reject rules
Name or service not known / Could not resolve host name: DNS or /etc/hosts: dig, getent hosts, resolvectl
Temporary failure in name resolution name: the resolver itself did not answer: /etc/resolv.conf, the resolver service, a firewall on port 53
certificate has expired / verify error TLS: the validity dates or the clock: openssl x509 -dates, timedatectl
hostname mismatch / no alternative certificate subject name TLS: the name is not in the certificate's SAN: the certificate for that virtual host
unable to get local issuer certificate TLS: a missing intermediate or a private CA the client does not trust
502 Bad Gateway / 504 Gateway Timeout app: the proxy is fine; the backend is not: the application's logs and health
Address already in use port: another process has it: ss -ltnp | grep :PORT
Too many open files system: file descriptor limits (ulimit -n, LimitNOFILE=) or a leak (CLOSE-WAIT)
Destination Host Unreachable (ping) ARP/routing: from your address = not on your LAN; from a router's address = it has no route
Tips
Walk up the stack and stop at the first failure. Resist the urge to start at the application.
Refused, reset and timeout are three different problems. Never report "connection failed" without saying which.
Test by IP first, then by name: it splits network problems from DNS problems in one step.
Compare two vantage points (another machine, another network, the server itself): a difference IS the diagnosis.
Measure, do not guess: curl -w for phases, dig for lookup time, ss for queues, mtr for loss. A number beats an opinion.
Check the boring things: the clock, the certificate dates, a full disk, a service that is not running, a firewall rule added last week.
Keep the packet capture for last, narrow and short, on both ends if you can, and save it with a timestamp.
"It must be the firewall" is true often enough to be dangerous: prove it (capture, or a test from inside the same network) before you change a rule.
Write down what you tried and the exact messages. Half of troubleshooting is not repeating yourself.