A port that is open and forgotten is the classic way into a server: a database left on a public address, a debug dashboard, a container that published a port nobody meant to publish. Checking takes a minute once you know two commands and one trap. We ran everything below on a fresh Ubuntu 24.04 server from our image and scanned it from a second machine, so the outputs are what you will see on your own server.
1. What is listening
The command is ss. The letters mean TCP, UDP, listening sockets, numeric ports and the process name:
sudo ss -tulpn
On a fresh server it prints six lines:
Netid State Local Address:Port Process
udp UNCONN 127.0.0.54:53 systemd-resolve
udp UNCONN 127.0.0.53%lo:53 systemd-resolve
tcp LISTEN 127.0.0.54:53 systemd-resolve
tcp LISTEN 127.0.0.53%lo:53 systemd-resolve
tcp LISTEN 0.0.0.0:22 sshd
tcp LISTEN [::]:22 sshd
The column that matters is Local Address. It tells you who can connect:
| Address | Who can reach it |
|---|---|
127.0.0.1:… or 127.0.0.53 | Only programs on this server |
0.0.0.0:… | Anyone, over IPv4, unless the firewall blocks it |
[::]:… | Anyone, over IPv6 (and usually IPv4 too) |
10.0.0.5:… (a specific address) | Anyone who can reach that address |
So the fresh server exposes exactly one thing, SSH on port 22, on both IPv4 and IPv6. The DNS resolver on 127.0.0.53 is private.
To list only what the internet could see, filter out the loopback addresses:
sudo ss -tlnp | awk 'NR>1 && $4 !~ /^(127\.|\[::1\])/ {print $4, $6}'
Run this after every install. We installed nginx, Docker and one small Python app, and the list grew from 2 lines to 10, including a docker-proxy on 0.0.0.0:8081 and a containerd socket on a random localhost port.
2. Which process owns a port
When a port is taken and you want to know by whom:
sudo lsof -i :80 -P -n
sudo fuser 80/tcp
sudo ss -tlnp 'sport = :80'
lsof is the most readable. For nginx it listed one master process as root and a worker as www-data, both on *:80, for IPv4 and IPv6. fuser prints just the PIDs, handy in scripts. If you need to free the port, stop the service rather than kill the PID, otherwise systemd restarts it: sudo systemctl stop nginx.
3. What the internet sees
ss shows the server's view. The internet's view can differ, because a firewall sits in between, or because a service binds to 127.0.0.1. To see the real picture, scan the server from another machine, your laptop or a second server. The tool is nmap (apt install nmap, or the installer from nmap.org on Windows and macOS).
nmap your-server-ip
nmap -p- your-server-ip
nmap -p 22,80,443,3306 your-server-ip
The first form checks the 1,000 most common ports, the second all 65,535, the third a list. On our fresh server, before any firewall:
Not shown: 999 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
Nmap done: 1 IP address (1 host up) scanned in 6.83 seconds
The full 65,535-port scan took 8.9 seconds and found the same single port. Closed ports answer instantly with a reset, which is why scanning them is fast.
Three states matter:
- open: a program accepted the connection. This is your attack surface.
- closed: the server answered that nothing listens there. Safe, but it confirms the server exists and what it does not run.
- filtered: no answer. A firewall dropped the packet. The scanner cannot tell whether a service is behind it.
For a single port without installing anything, nc -zv your-server-ip 443 reports succeeded or refused, and hangs until a timeout on a filtered port. For UDP, add -sU to nmap and expect it to be slow; on our server the three common UDP ports, 53, 123 and 161, were all closed.
One note for NAT plans: the internet reaches only the SSH port we forwarded to you. A scan of your shared address shows our edge, not your server, and the other ports are unreachable no matter what listens. On a plan with its own IPv4 address, every open port is yours to manage.
4. Close what you don't need
Three ways, from best to broadest.
Bind to localhost. If only programs on the server use a service, tell it to listen on 127.0.0.1. We started a small web app with --bind 127.0.0.1 8080: ss showed it on 127.0.0.1:8080, and nmap from outside reported 8080/tcp closed. For databases this is the right default: Redis, MySQL and PostgreSQL all listen on localhost out of the box on Ubuntu, and our guides for Redis and MySQL keep it that way.
Stop the service. sudo systemctl disable --now name removes the listener for good. Check with ss that it is gone.
Turn on the firewall. Allow SSH first, then what you serve, then enable:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw enable
sudo ufw status verbose
The scan from outside changed completely:
Not shown: 997 filtered tcp ports (no-response)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
8081/tcp open blackice-icecap
Nmap done: 1 IP address (1 host up) scanned in 10.18 seconds
Every unlisted port went from closed to filtered, and the full 65,535-port scan now took 110 seconds instead of 8.9. More time per port for the scanner, less information about your server. Our UFW guide covers rate limits, allowing a single IP and IPv6.
But look at the third line.
5. The Docker surprise
Port 8081 stayed open with the firewall on and no rule allowing it. That port belongs to a container we started with docker run -p 8081:80 nginx:alpine. Docker adds its own rules to the kernel firewall, and they are evaluated before ufw's. The rule it wrote on our server:
-A DOCKER ! -i docker0 -p tcp --dport 8081 -j DNAT --to-destination 172.17.0.2:80
Any packet for port 8081 is forwarded into the container before ufw gets a say. Every container with a -p or ports: entry does this, and ss shows it honestly as docker-proxy on 0.0.0.0:8081.
The fix is to publish to localhost only:
docker rm -f web
docker run -d --name web -p 127.0.0.1:8081:80 nginx:alpine
In Compose, write "127.0.0.1:8081:80" under ports:. After the change, ss showed 127.0.0.1:8081, the Docker rule gained -d 127.0.0.1/32, and the scan from outside reported 8081/tcp filtered. Put nginx as a reverse proxy in front for the public side, with HTTPS, so the only open ports on the server are 22, 80 and 443.
Checklist after every change
sudo ss -tulpn: is every0.0.0.0or[::]line something you meant to expose?nmap your-server-ipfrom your laptop: do the open ports match?- A database, cache or admin panel on a public address: move it to
127.0.0.1or behind the firewall today. - Every Docker
ports:entry: does it start with127.0.0.1:unless it is the public web port? - SSH exposed to everyone: add fail2ban or key-only login.
On Windows Server the same check is Get-NetTCPConnection -State Listen and the rules live in Windows Firewall. Every Linux plan, from the $3 Nano up, has a version with its own IPv4 address, and there what you see in ss is what the world sees, minus your firewall.
Comments
No comments yet. Be the first.