EQVPS

How to check open ports on a VPS: what is listening, what the internet sees, and the Docker surprise

See every port your server listens on, find the process behind it, scan the server from outside the way an attacker would, and close what you don't need. Includes the Docker port that stays open even with the firewall on. Every number measured on a fresh Ubuntu 24.04 server.

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:

AddressWho can reach it
127.0.0.1:… or 127.0.0.53Only 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

  1. sudo ss -tulpn: is every 0.0.0.0 or [::] line something you meant to expose?
  2. nmap your-server-ip from your laptop: do the open ports match?
  3. A database, cache or admin panel on a public address: move it to 127.0.0.1 or behind the firewall today.
  4. Every Docker ports: entry: does it start with 127.0.0.1: unless it is the public web port?
  5. 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.

FAQ

Which ports are open on a fresh VPS?

On our Ubuntu 24.04 image, one: SSH on port 22. The only other listeners are the local DNS resolver on 127.0.0.53 and 127.0.0.54, which the internet cannot reach. A scan of all 65,535 TCP ports from another machine found exactly one open port.

What is the difference between a closed and a filtered port?

Closed means the server answered that nothing listens there, so the scanner knows at once. Filtered means no answer at all, which is what a firewall does when it drops packets. Both keep intruders out, but filtered also hides which services exist and makes a full scan about twelve times slower on our server.

Why does ss show a port that nmap says is closed?

The service listens only on 127.0.0.1, the loopback address. Programs on the server itself can connect, but nothing from outside can. In ss, look at the Local Address column: 127.0.0.1:8080 is private, 0.0.0.0:8080 or [::]:8080 is reachable from the internet, firewall permitting.

I enabled ufw, so why is my Docker port still open?

Docker writes its own firewall rules that run before ufw's. A container started with -p 8081:80 was reachable from outside on our server with ufw active and no rule for 8081. Publish to localhost instead, -p 127.0.0.1:8081:80, and put nginx or Caddy in front for the public side.

How do I check a port from my own computer?

Run nc -zv your-server-ip 443 for one port, or install nmap and run nmap your-server-ip for the thousand most common ports. On a NAT plan only your forwarded SSH port answers from the internet; everything else is unreachable by design.

Comments

No comments yet. Be the first.

Leave a comment

Comments are moderated before they appear.