Linux for the cloud · Lesson 9 of 15 · about 35 minutes

Networking from the shell

The three tiers get connected. Read your server's addresses and routes, see who's listening on which port with ss, make the app answer for real, and find out why it still gave 502: the port that's set twice. Then names with /etc/hosts, testing connections with curl, nc and ping, reaching the server from your own computer, and a firewall with ufw. Along the way: why refused and timed out mean different things.

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, nc and ping, and read what each failure means
  • How names become addresses, and how /etc/hosts fits in
  • How to switch on a firewall with ufw without 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:

BASH
cd linux-practice
vagrant ssh

The server's addresses

BASH
ip -br addr
ip route
hostname -I
OUTPUT
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):

  • lo is the loopback: a pretend network inside the machine. 127.0.0.1 (and ::1 in IPv6) always means "this machine". Nothing sent there ever leaves it.
  • eth0 is the real network card, here a virtual one. 192.168.34.130/24 is the server's address on the small private network between your computer and the VM. Addresses starting 192.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:

BASH
sudo ss -tlnp
OUTPUT
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 onMeansHere
0.0.0.0:PORTEvery IPv4 address: anyone who can reach the serverSSH on 22, nginx on 80
[::]:PORTEvery IPv6 addressSSH on 22
127.0.0.1:PORTOnly this machine. From anywhere else, the port looks emptyPostgreSQL on 5432
127.0.0.53%lo:53A loopback address, on the loopback onlyThe 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:

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

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

BASH
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
OUTPUT
● 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:

BASH
sudo tail -1 /var/log/nginx/error.log
sudo ss -tlnp | grep -E ':80 |:8080 |:9090 |:5432 '
OUTPUT
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:

BASH
grep -rn port /etc/books-api/
OUTPUT
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:

BASH
grep -rn port /practice/config/app/
OUTPUT
/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:

BASH
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
OUTPUT
{"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:

BASH
sudo grep -rn port /etc/books-api/
OUTPUT
/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:

BASH
sudo tail -2 /var/log/nginx/access.log
journalctl -u books-api --no-pager -n 2
OUTPUT
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:

BASH
getent hosts db.internal
echo "127.0.0.1 db.internal" | sudo tee -a /etc/hosts
getent hosts db.internal
getent hosts ubuntu.com
OUTPUT
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:

BASH
ping -c 2 db.internal
nc -zv db.internal 5432
nc -zv 127.0.0.1 5433
ping -c 2 -W 2 ubuntu.com
OUTPUT
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 to ubuntu.com and back. -c 2 sends two, -W 2 waits 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. -i shows the reply's headers too, and -I shows 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:

BASH
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
OUTPUT
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:

BASH
sudo ufw status
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw status verbose
OUTPUT
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:

BASH
curl -si http://localhost/api/books | head -1
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
OUTPUT
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:

BASH
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
OUTPUT
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:

BASH
sudo ufw allow 80/tcp
sudo ufw status numbered
OUTPUT
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:

BASH
curl -si http://192.168.34.130/api/books | head -1
OUTPUT
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 seeIt meansLook at
Connection refused, at once. curl exit code 7The machine answered: nothing is listening on that port, or not on that addresssudo 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 28Nothing answered at all: usually a firewall dropping it, or the wrong addresssudo ufw status, cloud firewalls, and the address itself
Could not resolve host. curl exit code 6The name didn't turn into an addressgetent hosts NAME, then /etc/hosts and DNS
502 Bad GatewayThe web server is fine; the app behind it didn't answerThe web server's error log, then the app's port

Try every combination you've seen in this lesson:

Try it: answer, refused, or timed out?

from
to
firewall
curl -s http://SERVER_ADDRESS:8080/api/books
resultrefused
you seeexit code 7 (curl), straight away

The server answers "nothing here" at once. The app is running, but listens only on 127.0.0.1, inside the server, so from outside the port is empty. That's deliberate: only nginx faces the outside.

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:

BASH
sudo ufw delete allow 80/tcp

Then on your own computer:

BASH
curl -s --max-time 5 http://192.168.34.130/api/books; echo "exit code $?"
OUTPUT
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 ufw allowing the port usually means that one. The AWS track covers them.
  • Bind to 127.0.0.1 unless something outside needs it, and let nginx be the front door. A database listening on 0.0.0.0 with a weak password is one of the most common ways servers get broken into.
  • ss -tnp, without the l, shows connections that are open right now: who's talking to your server this moment.
  • curl -v shows 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

ToRun
The server's addressesip -br addr, or just hostname -I
Where traffic goes, and the gatewayip route
What's listening, on which port and addresssudo ss -tlnp
Who's on one portsudo ss -tlnp | grep :8080
Connections open right nowsudo ss -tnp
Turn a name into an addressgetent hosts NAME
Name a machine yourselfecho "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 forevercurl --max-time 5, then echo $?: 7 refused, 28 timed out, 6 no name
Firewall: allow SSH first, then switch onsudo ufw allow OpenSSH, then sudo ufw enable
Allow a portsudo ufw allow 80/tcp
See the rules, numberedsudo ufw status numbered
Remove a rulesudo 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

  1. ss shows a program listening on 127.0.0.1:8080. Who can connect to it?

  2. The app ran fine, but nginx still gave 502. What was wrong?

  3. From your laptop, curl to the server waits, then exits with code 28. What's most likely?

  4. Why allow OpenSSH before running ufw enable?

  5. getent hosts db.internal printed nothing. What does that mean?

  6. 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. lo is the loopback; eth0 here is the real one.
  • Loopback: the network inside one machine. 127.0.0.1 and ::1 mean "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/hosts or DNS.
  • Firewall: rules that decide which connections are allowed in or out, whatever is listening.

Go deeper

  • Run sudo ss -tnp while you curl the site from your own computer, and find your own connection in the list.
  • curl -v http://localhost/api/books shows every step, from the connection to the headers both ways.
  • cat /etc/hosts to see what else is in there, and resolvectl status for where this server sends DNS questions.
  • The Networking track's Ports and protocols, DNS and Firewalls, for the ideas underneath these commands.