The list of open ports is the list of ways to reach your server. Everything else — firewalls, WAFs, intrusion detection — is built on top of it. That is why "what is listening here" comes first in any audit, and why it is also the check that most often produces an unpleasant answer: half of what turns up was opened not by you but by some package's installer.
Reading the output
sudo ss -tulpn
The flags: t for TCP, u for UDP, l for listening only, p for the process, n to leave numbers as numbers. Without sudo the process column is empty and the exercise loses its point.
What matters is the Local Address:Port column, and the difference there is fundamental:
127.0.0.1:3306— the service is reachable only from the machine itself. That is good;0.0.0.0:3306— from every IPv4 address, which means from the internet. That is what needs checking;[::]:3306— the same thing for IPv6. A separate line, and regularly overlooked;203.0.113.25:443— on a specific address, usually deliberate.
Then, for every line with 0.0.0.0 or [::], ask one question: should a stranger be able to reach this. For 80 and 443 the answer is yes. For almost everything else it is no.
The usual findings
Redis, port 6379. The most dangerous line there is. By default Redis requires no password, and its commands allow writing a file to disk — which means somebody else's key in authorized_keys. Between Redis appearing on a public address and being used, hours pass, sometimes less. Check for bind 127.0.0.1 and protected-mode yes in the config.
Memcached, 11211/UDP. Even if there is nothing valuable inside, your server becomes an amplifier for other people's attacks — and the complaint will come from your provider.
MySQL and PostgreSQL, 3306 and 5432. There is a password, but guessing runs against it continuously, and database versions get updated less often than one would like. They almost never need to face outwards: the application lives on the same machine, and an SSH tunnel is enough for your own work.
Elasticsearch 9200, MongoDB 27017. Historically without authentication by default. Public instances of these are a standing source of data-leak news.
The Docker API, 2375. An open Docker control port is root on the host with no password whatsoever. It usually appears after experiments with remote access to Docker.
Control panels and phpMyAdmin on their own ports: 8080, 8083, 10000. Not that they must never be opened, but they are precisely what collects the bulk of the attempts.
Fix it at the service, not at the firewall
The urge to close every finding with a UFW rule is understandable, but that is the second line, not the first. A rule can be deleted by mistake, a firewall can be switched off temporarily while debugging, and Docker publishes ports around UFW entirely. A binding setting in the service's own config survives all of that:
- MySQL/MariaDB —
bind-address = 127.0.0.1; - PostgreSQL —
listen_addresses = 'localhost'; - Redis —
bind 127.0.0.1 ::1; - Docker Compose — publish as
"127.0.0.1:5432:5432".
The firewall goes on top as insurance, not instead of this.
Finding the process when it is unclear
ss gives the name and the pid. Then:
sudo systemctl status <pid>
sudo lsof -i :8080
The first command names the systemd unit the process belongs to — usually enough to understand what it is and whether it is needed. An unfamiliar process listening on a high port and started from outside the system directories — from /tmp or /dev/shm, say — is no longer a configuration question but grounds for a separate investigation.
Checking from outside is mandatory
ss answers "what is listening", not "what can be reached". Between the two stand the firewall, NAT and the provider's own rules. The only honest answer comes from scanning from another machine:
nmap -Pn -p- 203.0.113.25
nmap -Pn -p- -6 2001:db8::1
Do not skip the second command: nearly every VPS has an IPv6 address, its rules are written separately, and the service listens on both versions of the protocol at once.
This is not a one-off check
The list of open ports changes by itself. You installed a package and it brought a service and opened a port. You updated a panel and it restored a default. You started a container and it published a port around the firewall. A one-off check answers for today and nothing more.
The value is not in the list itself but in its changes: a new port that was not there yesterday is a short and very informative signal. That is exactly how it is worth watching — as a snapshot with history, not as ss output recalled from memory. The demo page below shows precisely that.