By the end you'll know
- How to read a server's addresses and routes with
ip - How to see what's listening, on which port and address, with
ss - How to test a connection with
curl,ncandping, and read what each failure means - How names become addresses, and how
/etc/hostsfits in - How to switch on a firewall with
ufwwithout locking yourself out
The problem: three tiers, not connected
Lesson 8 left the bookshop with all three tiers installed and none of them talking. nginx answers /api/ with 502 Bad Gateway, because nothing listens where it sends those requests. This lesson connects them, and on the way meets the trap Lesson 3 found in the practice files' settings: the app's port is set twice.
For the background on addresses, ports and DNS, the Networking track goes deeper. This lesson is the hands-on side: the commands you run on a Linux server.
On your own computer, from your practice folder, log in to the server:
cd linux-practice
vagrant ssh
The server's addresses
ip -br addr
ip route
hostname -I
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0 UP 192.168.34.130/24 metric 100 fe80::20c:29ff:fe3d:9d/64
default via 192.168.34.2 dev eth0 proto dhcp src 192.168.34.130 metric 100
192.168.34.0/24 dev eth0 proto kernel scope link src 192.168.34.130 metric 100
192.168.34.2 dev eth0 proto dhcp scope link src 192.168.34.130 metric 100
192.168.34.130
(Your addresses will differ.) ip -br addr lists the server's network interfaces, briefly (-br):
lois the loopback: a pretend network inside the machine.127.0.0.1(and::1in IPv6) always means "this machine". Nothing sent there ever leaves it.eth0is the real network card, here a virtual one.192.168.34.130/24is the server's address on the small private network between your computer and the VM. Addresses starting192.168.are private: they work inside a network, never on the internet.
ip route says where traffic goes. default via 192.168.34.2 is the default gateway: anything not on the local network is sent there, here to your computer's virtual network, which passes it on to the internet. hostname -I just prints the machine's addresses, the quickest way to find out where to reach it.
Who's listening: ss
A program that waits for connections listens on a port, a number from 1 to 65535, on one or more addresses. ss (socket statistics) lists them:
sudo ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1318,fd=3),("systemd",pid=1,fd=178))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=3399,fd=5),("nginx",pid=3398,fd=5),("nginx",pid=3284,fd=5))
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=655,fd=15))
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=5562,fd=6))
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=655,fd=17))
LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=1318,fd=4),("systemd",pid=1,fd=179))
-t TCP connections, -l only listening ones, -n numbers instead of names, -p which program, which needs sudo. One line per listener, and the local address column is the one to read:
| Listening on | Means | Here |
|---|---|---|
0.0.0.0:PORT | Every IPv4 address: anyone who can reach the server | SSH on 22, nginx on 80 |
[::]:PORT | Every IPv6 address | SSH on 22 |
127.0.0.1:PORT | Only this machine. From anywhere else, the port looks empty | PostgreSQL on 5432 |
127.0.0.53%lo:53 | A loopback address, on the loopback only | The server's own DNS helper, systemd-resolved |
This is Lesson 8's three tiers, as the network sees them: nginx open to everyone, the database only to itself, and nothing at all on 8080.
Make the app answer
The stand-in books-api from Lessons 6 and 7 printed log lines but never answered a request. Replace it with a tiny real web app in Python, which the server already has. Like a real app, it reads its settings: the practice files' app.conf, then override.conf, the one its comment says "wins". First, put the settings where Lesson 5 put them, readable by the app's group:
sudo mkdir -p /etc/books-api
sudo cp /practice/config/app/app.conf /practice/config/app/override.conf /etc/books-api/
sudo chown root:bookshop /etc/books-api/app.conf /etc/books-api/override.conf
sudo chmod 640 /etc/books-api/app.conf /etc/books-api/override.conf
Then the app. You don't need to follow the Python: it reads every key = value line from both files, later ones replacing earlier ones, then answers every request with a short list of books, as JSON:
sudo tee /usr/local/bin/books-api > /dev/null <<'EOF'
#!/usr/bin/env python3
import http.server
import json
import socket
settings = {}
for path in ["/etc/books-api/app.conf", "/etc/books-api/override.conf"]:
for line in open(path):
if "=" in line and not line.startswith("#"):
key, value = line.split("=", 1)
settings[key.strip()] = value.strip()
port = int(settings["port"])
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = json.dumps({"books": ["Dune", "Beloved", "Kindred"], "served_by": socket.gethostname()}) + "\n"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
print(f"INFO books-api listening on port {port}", flush=True)
http.server.HTTPServer(("127.0.0.1", port), Handler).serve_forever()
EOF
The unit file from Lesson 7 still points at /usr/local/bin/books-api, so a restart is all it takes:
sudo systemctl restart books-api
sleep 2
systemctl status books-api --no-pager | head -4
journalctl -u books-api --no-pager -n 2
curl -si http://localhost/api/books | head -1
● books-api.service - Bookshop API (practice)
Loaded: loaded (/etc/systemd/system/books-api.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-30 01:34:14 UTC; 2s ago
Main PID: 9190 (python3)
Oct 30 01:34:14 bookshop systemd[1]: Started books-api.service - Bookshop API (practice).
Oct 30 01:34:14 bookshop books-api[9190]: INFO books-api listening on port 9090
HTTP/1.1 502 Bad Gateway
The app is running. nginx still says 502. And the app's own log has already said why: listening on port 9090.
The port that's set twice
Ask nginx and the network what each one thinks:
sudo tail -1 /var/log/nginx/error.log
sudo ss -tlnp | grep -E ':80 |:8080 |:9090 |:5432 '
2026/10/30 01:34:16 [error] 3398#3398: *9 connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1, server: books.example.com, request: "GET /api/books HTTP/1.1", upstream: "http://127.0.0.1:8080/api/books", host: "localhost"
LISTEN 0 5 127.0.0.1:9090 0.0.0.0:* users:(("python3",pid=9190,fd=3))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=3399,fd=5),("nginx",pid=3398,fd=5),("nginx",pid=3284,fd=5))
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=5562,fd=6))
nginx knocks on 8080. The app (python3) answers on 9090. Both are working exactly as configured; they just disagree. Find every place the port is set, with Lesson 3's grep -r:
grep -rn port /etc/books-api/
grep: /etc/books-api/app.conf: Permission denied
grep: /etc/books-api/override.conf: Permission denied
Lesson 5's own permissions, working: the files are 640 root:bookshop, and you're vagrant, neither the owner nor in the group. sudo grep would read them. These are copies of the practice files, though, which anyone can read:
grep -rn port /practice/config/app/
/practice/config/app/override.conf:3:port = 9090
/practice/config/app/app.conf:3:port = 8080
/practice/config/app/app.conf:6:database_port = 5432
-n adds line numbers, so you know exactly where to look.
There it is: 8080 in app.conf, overridden by 9090 in override.conf, the trap Lesson 3 spotted in the practice files. Check the app really does answer there, then fix the override:
curl -s http://127.0.0.1:9090/api/books
sudo sed -i 's/^port = 9090/port = 8080/' /etc/books-api/override.conf
sudo systemctl restart books-api
sleep 2
sudo ss -tlnp | grep python3
curl -si http://localhost/api/books
{"books": ["Dune", "Beloved", "Kindred"], "served_by": "bookshop"}
LISTEN 0 5 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=9215,fd=3))
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Fri, 30 Oct 2026 01:34:37 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
{"books": ["Dune", "Beloved", "Kindred"], "served_by": "bookshop"}
200 OK, through nginx, from the app. Check the server's copy of the settings now says what you meant, this time reading it with sudo:
sudo grep -rn port /etc/books-api/
/etc/books-api/app.conf:3:port = 8080
/etc/books-api/app.conf:6:database_port = 5432
/etc/books-api/override.conf:3:port = 8080
Both now say 8080. The practice files' own override.conf still says 9090, and that's fine: the server reads its copy in /etc/books-api, and that's the one you changed.
Both sides of the conversation recorded the change:
sudo tail -2 /var/log/nginx/access.log
journalctl -u books-api --no-pager -n 2
127.0.0.1 - - [30/Oct/2026:01:34:16 +0000] "GET /api/books HTTP/1.1" 502 166 "-" "curl/8.5.0"
127.0.0.1 - - [30/Oct/2026:01:34:37 +0000] "GET /api/books HTTP/1.1" 200 78 "-" "curl/8.5.0"
Oct 30 01:34:35 bookshop books-api[9215]: INFO books-api listening on port 8080
Oct 30 01:34:37 bookshop books-api[9215]: 127.0.0.1 - - [30/Oct/2026 01:34:37] "GET /api/books HTTP/1.0" 200 -
The 502, then the 200, 21 seconds apart. Notice the app's log line: the request came from 127.0.0.1, nginx, not from you, and as HTTP/1.0, an older version of HTTP that nginx uses to talk to apps behind it unless told otherwise. The app only ever talks to the tier next to it.
Names: from db.internal to an address
The app's settings also say database_host = db.internal. Computers connect to addresses, so a name has to be resolved to one first. getent hosts asks the system to resolve a name, the same way every program does:
getent hosts db.internal
echo "127.0.0.1 db.internal" | sudo tee -a /etc/hosts
getent hosts db.internal
getent hosts ubuntu.com
127.0.0.1 db.internal
127.0.0.1 db.internal
185.125.190.21 ubuntu.com
The first getent printed nothing: db.internal isn't a name anyone knows. The output's first line is tee echoing what it added. /etc/hosts is a small file of names and addresses that the system checks first, before asking DNS (the Domain Name System, the internet's directory of names). With the line added, db.internal means 127.0.0.1: on this server, the database really is on the same machine. ubuntu.com isn't in /etc/hosts, so the answer came from DNS, through the systemd-resolve helper you saw listening on 127.0.0.53.
On a real network, db.internal would be in the company's own DNS, pointing at a separate database server. /etc/hosts is for quick fixes and tests on one machine, and a classic place for forgotten entries to cause confusion months later.
Testing a connection
Three tools, three questions:
ping -c 2 db.internal
nc -zv db.internal 5432
nc -zv 127.0.0.1 5433
ping -c 2 -W 2 ubuntu.com
PING db.internal (127.0.0.1) 56(84) bytes of data.
64 bytes from localhost (127.0.0.1): icmp_seq=1 ttl=64 time=0.031 ms
64 bytes from localhost (127.0.0.1): icmp_seq=2 ttl=64 time=0.133 ms
--- db.internal ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1063ms
rtt min/avg/max/mdev = 0.031/0.082/0.133/0.051 ms
Connection to db.internal (127.0.0.1) 5432 port [tcp/postgresql] succeeded!
nc: connect to 127.0.0.1 port 5433 (tcp) failed: Connection refused
PING ubuntu.com (185.125.190.21) 56(84) bytes of data.
64 bytes from 185.125.190.21: icmp_seq=1 ttl=128 time=82.6 ms
64 bytes from 185.125.190.21: icmp_seq=2 ttl=128 time=84.2 ms
--- ubuntu.com ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1003ms
rtt min/avg/max/mdev = 82.619/83.421/84.223/0.802 ms
ping: can I reach the machine at all? It sends a tiny "are you there?" and times the reply: 0.03 milliseconds to itself, 83 toubuntu.comand back.-c 2sends two,-W 2waits at most 2 seconds for each. Some servers are set not to answer ping, so silence isn't always proof it's down.nc -zv: is anything listening on that port?nc(netcat) opens a connection and closes it again (-z), saying what happened (-v). PostgreSQL's port:succeeded. Port 5433:Connection refused, nothing there.curl: does the program on that port answer properly? It speaks HTTP (the language of the web), as you've done all along.-ishows the reply's headers too, and-Ishows only the headers.
From the outside
So far, everything has been tested from the server itself. Visitors aren't there. Leave the server with exit, and try from your own computer, using the address hostname -I printed. On your own computer:
ping -c 2 192.168.34.130
curl -si http://192.168.34.130/api/books | head -1
curl -s --max-time 5 http://192.168.34.130:8080/api/books; echo "exit code $?"
nc -zv -G 3 192.168.34.130 5432
PING 192.168.34.130 (192.168.34.130): 56 data bytes
64 bytes from 192.168.34.130: icmp_seq=0 ttl=64 time=1.644 ms
64 bytes from 192.168.34.130: icmp_seq=1 ttl=64 time=1.888 ms
--- 192.168.34.130 ping statistics ---
2 packets transmitted, 2 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 1.644/1.766/1.888/0.122 ms
HTTP/1.1 200 OK
exit code 7
nc: connectx to 192.168.34.130 port 5432 (tcp) failed: Connection refused
(On a Mac, nc -G 3 sets a 3-second limit; Linux uses -w 3. On Windows, use WSL.) Through nginx, port 80: 200 OK. The app's port 8080: curl gave up at once with exit code 7, "couldn't connect". PostgreSQL: refused. Both are running, but both listen only on 127.0.0.1, so from outside the server, their ports are empty. That's Lesson 8's three tiers, enforced by the network: the outside world can only reach the web tier.
A firewall: ufw
Listening only on 127.0.0.1 protects the app and the database, but a server often runs things that listen everywhere. A firewall decides which incoming connections are allowed at all, whatever is listening. Ubuntu's is ufw (uncomplicated firewall). Back on the server, with vagrant ssh:
sudo ufw status
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw status verbose
Status: inactive
Rules updated
Rules updated (v6)
Firewall is active and enabled on system startup
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
Allow SSH before you switch it on. The default is deny (incoming): once the firewall is active, anything not allowed is blocked, and that includes the SSH connection you're using. Turn it on without allowing SSH, and you're locked out of your own server. With Vagrant, you'd fix it from the VM's console; on a cloud server, it can mean rebuilding it. --force skips the "this may disrupt SSH connections" question. OpenSSH is a ready-made rule for port 22, from the SSH package.
Port 80 isn't allowed yet. From the server itself, the site still works:
curl -si http://localhost/api/books | head -1
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
HTTP/1.1 200 OK
{"books": ["Dune", "Beloved", "Kindred"], "served_by": "bookshop"}
exit code 0
Even using its own network address, 192.168.34.130. A machine talking to itself, by any of its addresses, never really goes out over the network, so the firewall never sees it. You can't test a firewall from the server it protects. Exit, and try from your own computer:
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
exit code 28
This time curl waited the full 5 seconds, then gave up with exit code 28: timed out. Allow web traffic, on the server:
sudo ufw allow 80/tcp
sudo ufw status numbered
Rule added
Rule added (v6)
Status: active
To Action From
-- ------ ----
[ 1] OpenSSH ALLOW IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] OpenSSH (v6) ALLOW IN Anywhere (v6)
[ 4] 80/tcp (v6) ALLOW IN Anywhere (v6)
And from your own computer again:
curl -si http://192.168.34.130/api/books | head -1
HTTP/1.1 200 OK
Refused, or timed out?
Those two failures look alike, and mean different things. Telling them apart is half of network troubleshooting:
| You see | It means | Look at |
|---|---|---|
Connection refused, at once. curl exit code 7 | The machine answered: nothing is listening on that port, or not on that address | sudo ss -tlnp on the server: is it running, on the right port, on 0.0.0.0 or 127.0.0.1? |
A wait, then a timeout. curl exit code 28 | Nothing answered at all: usually a firewall dropping it, or the wrong address | sudo ufw status, cloud firewalls, and the address itself |
Could not resolve host. curl exit code 6 | The name didn't turn into an address | getent hosts NAME, then /etc/hosts and DNS |
502 Bad Gateway | The web server is fine; the app behind it didn't answer | The web server's error log, then the app's port |
Try every combination you've seen in this lesson:
Break it on purpose
You've already broken it several ways in this lesson, for real: the port set twice (a 502), the app's own port from outside (refused), and the web port behind the firewall (timed out). One more that every admin meets eventually: take away the rule you just added, and test from both sides. On the server:
sudo ufw delete allow 80/tcp
Then on your own computer:
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
exit code 28
ufw delete takes the same words as the rule, or its number from ufw status numbered. The site is down for every visitor, and still looks perfectly healthy to anyone checking from the server itself. Put it back on the server with sudo ufw allow 80/tcp, and check again from outside.
On a real server
- Work outwards, one question at a time. Does the name resolve (
getent hosts)? Is the machine reachable (ping)? Is the port open from here (nc -zv)? Does the program answer properly (curl -i)? What do its logs say? The first "no" is where the problem is. - Cloud servers have a second firewall, outside the machine: security groups on AWS, for example. A timeout with
ufwallowing the port usually means that one. The AWS track covers them. - Bind to
127.0.0.1unless something outside needs it, and let nginx be the front door. A database listening on0.0.0.0with a weak password is one of the most common ways servers get broken into. ss -tnp, without thel, shows connections that are open right now: who's talking to your server this moment.curl -vshows every step of a request, from resolving the name to the reply's headers: the quickest way to see where a web request is going wrong.
Spot the problem
Work out what's wrong before you read the explanation.
It works on the server. A new service is installed and started. On the server, curl http://localhost:3000 answers perfectly. From a laptop, curl http://SERVER:3000 fails. What do you check? First, how it fails. Refused, at once: sudo ss -tlnp will show it listening on 127.0.0.1:3000, only inside the server; change its settings to listen on 0.0.0.0, or put it behind nginx. A timeout: the firewall. sudo ufw allow 3000/tcp, and on a cloud server, the cloud firewall too. The same symptom, two different fixes, and the way it fails tells you which.
Keep this
| To | Run |
|---|---|
| The server's addresses | ip -br addr, or just hostname -I |
| Where traffic goes, and the gateway | ip route |
| What's listening, on which port and address | sudo ss -tlnp |
| Who's on one port | sudo ss -tlnp | grep :8080 |
| Connections open right now | sudo ss -tnp |
| Turn a name into an address | getent hosts NAME |
| Name a machine yourself | echo "ADDRESS NAME" | sudo tee -a /etc/hosts |
| Is the machine reachable? | ping -c 2 HOST |
| Is the port open? | nc -zv HOST PORT |
| Does the web service answer? | curl -si http://HOST/PATH, every step: curl -v |
| Don't wait forever | curl --max-time 5, then echo $?: 7 refused, 28 timed out, 6 no name |
| Firewall: allow SSH first, then switch on | sudo ufw allow OpenSSH, then sudo ufw enable |
| Allow a port | sudo ufw allow 80/tcp |
| See the rules, numbered | sudo ufw status numbered |
| Remove a rule | sudo ufw delete allow 80/tcp, or sudo ufw delete NUMBER |
Where you are now
The bookshop works end to end: a visitor's request reaches nginx through the firewall, nginx passes it to the app on 127.0.0.1:8080, and the app could reach its database at db.internal. Lesson 10 is about what fills up as it runs: disks, storage and memory, starting with the practice files' 2.5-megabyte debug.log, and what happens when a disk is completely full.
Check yourself
ss shows a program listening on 127.0.0.1:8080. Who can connect to it?
The app ran fine, but nginx still gave 502. What was wrong?
From your laptop, curl to the server waits, then exits with code 28. What's most likely?
Why allow OpenSSH before running ufw enable?
getent hosts db.internal printed nothing. What does that mean?
Why does curl to the server's own address work from the server, with port 80 blocked?
Key terms
- Network interface: a connection to a network.
lois the loopback;eth0here is the real one. - Loopback: the network inside one machine.
127.0.0.1and::1mean "this machine". - Port: a number that picks which program on a machine gets a connection.
- Listening: waiting for connections on a port, on one address or all of them (
0.0.0.0). - Default gateway: where traffic goes when it isn't for the local network.
- Resolving: turning a name into an address, from
/etc/hostsor DNS. - Firewall: rules that decide which connections are allowed in or out, whatever is listening.
Go deeper
- Run
sudo ss -tnpwhile youcurlthe site from your own computer, and find your own connection in the list. curl -v http://localhost/api/booksshows every step, from the connection to the headers both ways.cat /etc/hoststo see what else is in there, andresolvectl statusfor where this server sends DNS questions.- The Networking track's Ports and protocols, DNS and Firewalls, for the ideas underneath these commands.