I was handed a server with no docs and no idea what it was listening on. One command fixed that.

I was handed a server with no docs and no idea what it was listening on. One command fixed that.

Leader 1 5 32
calendar_today agoschedule5 min read

A client handed me SSH access to a server they'd been running for two years.

No documentation. No handoff notes. No "here's what's running and why." Just a root password in a LastPass share and a Slack message that said "it hosts our web app, let us know if you need anything."

I didn't know what the web app was. I didn't know what was installed. I didn't know what ports were open to the internet, what services were running, or what this machine had been doing quietly for 730 days before I touched it.

This is not unusual. This is actually most of the "inherited server" situations I've been in. The person who set it up is gone, or was a contractor, or was the founder who also did DevOps because someone had to, and the documentation lived entirely inside one person's head and left with them.

The first thing I do on an unfamiliar server is not grep logs or check running services. It's figure out what the machine is actually reachable on from the outside. Not what anyone says it's supposed to be doing. What it is doing. What ports are in LISTEN state, what process owns each one, and whether that process is bound to localhost or to every network interface on the machine.

I ran netstat -an first, because that's what I knew at the time. I got back 200 lines of connection state. TIME_WAIT entries for connections that had already closed. ESTABLISHED entries for active sessions. CLOSE_WAIT entries from something that hadn't cleaned up properly. All of it technically useful for debugging specific connection issues. None of it useful for getting a fast security picture of what's actually listening for new connections.

Finding the ports in LISTEN state in netstat -an output feels like searching for a specific ingredient in a paragraph of text. It's all there, but you have to read every line.

Then someone showed me ss.


The Command

ss -tlnp

That's the whole thing.

-t — TCP only (use -u for UDP, or -tu for both)
-l — listening sockets only (filters out ESTABLISHED, TIME_WAIT, everything else)
-n — numeric output (don't resolve port numbers to service names, don't do reverse DNS)
-p — show the process name and PID holding each socket

Output on a typical web server:

State   Recv-Q  Send-Q  Local Address:Port   Peer Address:Port  Process
LISTEN  0       128     0.0.0.0:22           0.0.0.0:*          users:(("sshd",pid=1234,fd=3))
LISTEN  0       511     0.0.0.0:80           0.0.0.0:*          users:(("nginx",pid=5678,fd=6))
LISTEN  0       511     0.0.0.0:443          0.0.0.0:*          users:(("nginx",pid=5678,fd=7))
LISTEN  0       128     0.0.0.0:5432         0.0.0.0:*          users:(("postgres",pid=9012,fd=5))

Port 22 — SSH. Expected.
Port 80, 443 — nginx. Expected.
Port 5432 — Postgres. Bound to 0.0.0.0.

That last one.

0.0.0.0:5432 means Postgres was accepting connections from any IP address on the internet. Not just localhost. Not just the application server. Any IP. Port 5432, wide open, reachable from anywhere.

It had a strong password. It had been running that way for two years without an incident anyone knew about. But "strong password" is not a substitute for "not exposed to the internet." A service that should only accept connections from localhost or from your application tier has no business being bound to 0.0.0.0.

One command. Three seconds. Caught a misconfiguration that had been sitting there since the server was provisioned.


What the Output Is Actually Telling You

The column that matters is Local Address. This is where the socket is bound — who it will accept connections from.

0.0.0.0:5432    → Externally reachable on all IPv4 interfaces
127.0.0.1:5432  → Localhost only — not reachable from outside
:::5432         → All IPv6 interfaces (equivalent to 0.0.0.0 for IPv6)
::1:5432        → IPv6 localhost only

If a service should only be accessed from the same machine — a database, an internal cache, a monitoring agent — it should be bound to 127.0.0.1. If you see it bound to 0.0.0.0 and you don't have an explicit reason for that, that's a conversation to have with whoever configured the service.

For the Postgres case, the fix is one line in postgresql.conf:

listen_addresses = 'localhost'

Restart Postgres, confirm the bind address changed with ss -tlnp again, done. That configuration change is a 30-second fix. The two years it had been wrong before someone checked is the part that should concern you.


Why ss Instead of netstat

ss replaced netstat on most Linux distributions when iproute2 superseded net-tools around 2016. On a fresh Ubuntu or Debian install, netstat isn't installed by default — it's in the net-tools package which isn't pulled in automatically anymore. That's why you've probably SSH'd into a server, typed netstat, and gotten command not found.

ss reads kernel socket tables directly instead of going through /proc/net/tcp. It's faster on machines with thousands of connections, and it's maintained. net-tools has been in maintenance-only mode for years. For new work, use ss.

That said, netstat -tlnp does the same thing if you have it installed. The flags are identical. If you're on an older system or have net-tools available, either works.


The Process Name Caveat

Run ss -tlnp without sudo and you'll see the ports and bind addresses, but the Process column will be empty for sockets owned by other users. You'll see the port, you won't see what's holding it.

sudo ss -tlnp

With sudo, you see everything — the socket, the process name, the PID, the file descriptor number. That's the version to run when you're doing a security audit on a machine you have root on.


The lsof Alternative

lsof -i -P -n | grep LISTEN gives you the same information from a different angle — process name first, port second.

nginx    5678 root   6u  IPv4  12345  0t0  TCP *:80 (LISTEN)
nginx    5678 root   7u  IPv4  12346  0t0  TCP *:443 (LISTEN)
sshd     1234 root   3u  IPv4  12347  0t0  TCP *:22 (LISTEN)
postgres 9012 postgres 5u IPv4 12348  0t0  TCP *:5432 (LISTEN)

Useful when you know the service name and want to find its ports. Less useful when you want a port-first view of everything listening. I use ss for the initial audit and lsof when I'm tracking down a specific process.


What I Actually Do With Ports I Can't Identify

When I see a port in LISTEN state and the process name isn't immediately obvious — java, python3, something that isn't a clear service name — I do three things:

# 1. Find the full command that started the process
ps aux | grep <PID>

# 2. Check what package installed the binary
dpkg -S /path/to/binary    # Debian/Ubuntu
rpm -qf /path/to/binary    # RHEL/CentOS

# 3. Check what the process has open (files, connections, everything)
sudo lsof -p <PID>

Most of the time it's something benign — a monitoring agent, a language runtime for an app, a service the previous admin installed and forgot about. Occasionally it's something that shouldn't be there at all. You don't know until you check.


Building This Into Your Server Checklist

Every time I take over a server now, this is the third command I run. After uptime (how long has it been running) and df -h (is it about to run out of disk), it's sudo ss -tlnp (what is this machine telling the internet it's listening on).

Takes three seconds. Has caught real problems more than once. The Postgres 0.0.0.0 situation is the one I remember most clearly, but it's not the only one — I've found web apps listening on non-standard ports that were supposed to be internal, Redis instances bound to 0.0.0.0 on development machines that got promoted to production without anyone auditing the config, SSH running on a second non-standard port "for convenience" that had been left open after a migration.

None of those were catastrophic on their own. All of them were things the people running those servers didn't know were there.

Three seconds to find out. I don't know why I didn't run this on every server from the beginning.


Full script with UDP ports, both TCP and UDP in one pass, lsof cross-reference, and notes on what to do when you can't identify a process:

bashsnippets.xyz/snippets/list-open-ports-linux

bashsnippets.xyz

2.2k Points38 Badges1 5 32
North Americabashsnippets.xyz
22Posts
24Comments
3Followers
3Connections
Linux user who got tired of Googling the same bash commands every time I sat down at a terminal. Started writing them down. That turned into bashsnippets.xyz -->
a free and growi... Show more
Build your own developer journey
Track progress. Share learning. Stay consistent.
Part 7 of 7 in Bash Snippets Pages
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Comparison: Universal Import vs. Plaid/Yodlee

Pocket Portfolio - Mar 12

My Nginx Died at 2 AM and Nobody Noticed for 6 Hours. Now I Have a Watchdog Script

BashSnippets - May 21

The Interface of Uncertainty: Designing Human-in-the-Loop

Pocket Portfolio - Mar 10
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!