Minimize services & exposure
Every listener you remove is risk removed.
A server with eight listening services is a house with eight doors onto the street. It does not matter that you only ever use one of them. Every door is a lock that can be picked, a hinge that can rust, a frame that might have been fitted wrong at the factory. The safest door is the one that was never installed. That is the whole point of minimizing services. Code you do not run cannot be exploited, and a port you do not open cannot be reached. So hardening a Linux box starts by asking a blunt question of every running program: does this machine actually need you?
Take an honest inventory
Before you remove anything, you need to know what is there. The fastest way is ss, short for socket statistics, a tool that reads the kernel's live list of network sockets. The kernel is the core of the operating system, the part that owns the hardware and the network. Ask ss for the TCP listeners and the program behind each one. TCP (Transmission Control Protocol) is the connection-based traffic that most services speak.
Four flags do the work. -t limits it to TCP, -l to sockets in the LISTEN state (open and waiting), -n keeps ports as numbers instead of translating 22 into ssh, and -p shows the owning process, which is why you need sudo. The column that matters most is Local Address:Port. 0.0.0.0 means every network interface the machine has, reachable from outside. 127.0.0.1 (and 127.0.0.53) is the loopback address, reachable only from the machine itself. [::] is the same idea as 0.0.0.0 for IPv6 (the newer, longer addressing scheme that is slowly replacing the classic four-number addresses). Read this list and two things jump out. PostgreSQL, a database, is on 0.0.0.0:5432, and a print server is on 0.0.0.0:631. Both are answering the whole network. A database and a printer, facing the internet.
This is close to what an attacker sees. Point a port scanner like nmap (network mapper, the standard tool for probing which ports a host has open) at the box from outside and they get the same open ports, minus the process names, because they do not have your sudo. Every name on your list is a program they can start fingerprinting for a known bug. One habit worth keeping: add -u to also list UDP (User Datagram Protocol, the connectionless kind that DNS lookups and service discovery use), since some listeners never show up in a TCP-only view.
Match each listener to a running service
ss tells you what is on the network. systemd tells you what is running and meant to be. systemd is the init system, the very first process to start at boot (process ID 1, the root of the whole process tree) and the parent that supervises everything after it. Anything it manages is called a unit. Ask it which services are running right now.
Now pair the two lists. cups.service is running and owns port 631. CUPS (the Common Unix Printing System) turns a machine into a print server. Do you print from this box? Almost never. avahi-daemon is running too. Avahi advertises services on the local network, the magic that lets laptops find printers and shared folders on their own. A headless server (one with no monitor, keyboard, or desk user, just a network cable) has no reason to shout its presence to the LAN (local area network). Two clear candidates for removal, and neither is subtle once you look.
Stop, disable, mask, purge
There are four strengths of turning something off, and the difference matters. Stopping a service closes the door now, but it swings open again at the next reboot. Disabling it removes the boot-time startup link so it stays shut across reboots, though you can still start it by hand. Adding --now does both at once. Masking is stronger: it points the unit at /dev/null (the system's black hole, a destination that swallows anything sent to it), welding the door shut so nothing can start it, not even another service that depends on it. Purging is the most thorough: it removes the package and its config from disk entirely, taking the door out of the wall. Start with disable for the two you found.
Read the output. Disabling deletes the symlinks that told systemd to start these at boot. Look at how much came off for cups. Three links, not one: cups.path, cups.service, and cups.socket. They travel together because the service file lists the other two under its Also directive, so a single disable unlinks all three. That looks complete. It is not. Disabling only removes those boot-time links, and --now stopped exactly one unit, the service. The socket unit is still running this very second. That gap is the trap in the next section.
The socket-activation trap
systemd has a trick called socket activation. Think of a receptionist who does not keep every specialist sitting at a desk all day. The receptionist holds the phone line open, and only when a call comes in do they page the specialist, who arrives just in time. Here systemd is the receptionist. It holds the port open, and it starts the real program only when the first connection lands. That saves memory. It also means a port can be open while the service you think owns it is stopped. You stopped cups a moment ago. Look at 631 again.
The port is still listening, and the owner is now systemd, pid=1, not cupsd. The service is stopped, but systemd is holding the doorway, ready to relaunch CUPS the instant something knocks. Here is the catch. disable --now stopped the one unit you named, cups.service. It never touched cups.socket, and a disabled unit that is already running keeps running until you stop it. The socket is a separate unit, still active, still holding 631. Stop the socket, then mask both so nothing can bring them back.
The lesson generalizes. Whenever you stop a service and the port stays open under systemd, pid=1, you are looking at socket activation, and the unit still holding the door ends in .socket. Stopping the .service never closes it. Stop and mask that .socket to shut the port for good. CUPS works this way, and so do a number of systemd's own helpers; the tell is always systemd, pid=1, owning the listener.
Bind to the house, not the street
Some services you cannot remove, but only the machine itself needs to reach them: a database that backs one application, a metrics endpoint scraped by a local agent. Picture an intercom wired only to rooms inside the house versus one wired to a panel out on the street. Same device, wildly different exposure. The wiring here is the listen address. PostgreSQL was sitting on 0.0.0.0:5432, open to the whole network. If only the local app talks to it, bind it to loopback instead.
# Was: listen_addresses = '*' (every interface, reachable from the network)listen_addresses = 'localhost' # loopback only; unreachable from off the box
Change the setting, restart PostgreSQL, then verify. The listen address is read only when the server starts, so a plain reload will not move it, and a config edit you did not check is a hope, not a control.
Loopback now, 127.0.0.1 and its IPv6 twin ::1, not 0.0.0.0. Even if the firewall in front of this box were misconfigured tomorrow, the database still would not answer a remote connection, because it is not listening on any address the network can route to. A local bind and a default-deny firewall are two independent reasons the service is unreachable from outside, and a hardened host wants both.
Take away the attacker's tools
Listeners are only half the job. Think about what an intruder finds lying around once they are inside. Someone who lands a shell goes looking for tools to build a payload, scan the internal network, and move sideways: a compiler like gcc (the GNU Compiler Collection), an interpreter like Python, a network utility like nc (netcat, a Swiss-army knife for opening raw connections), and legacy remote-login clients like telnet and rsh (remote shell). Using what is already installed instead of downloading anything is called living off the land, and it works because nothing new shows up for a detector to catch. A web server that never compiles code does not need gcc sitting in an open shed.
You will not strip a host to bare metal, and you should not try. Removing a compiler can break packages that build a kernel module at install time. The rule is narrower: take away what a server of this role has no business running, and be able to say why every tool that stays is there. Legacy clients like telnet, rsh, and ftp send passwords across the network in the clear anyway, so they are almost always safe to remove and worth removing twice over. This is the bare-metal version of the distroless idea from the container course, where an image ships with no shell and no package manager. The less that is present, the less there is to attack and to abuse.
Baseline it, then watch for drift
The last move turns a one-time cleanup into a standing control. Once a host is minimal and understood, write down the ports it is supposed to have. A night guard who has memorized which cars belong in the lot spots the strange one at 3am immediately. Capture the known-good set once, then compare the live set against it on a schedule.
Here -H drops the header row, awk pulls the Local Address:Port column, and comm -13 prints only the lines that are new (present in the live list, absent from the baseline). The write needs root, which is why the capture pipes into sudo tee rather than a plain redirect; the shell that runs > cannot write to /etc, only the command on the left of the pipe runs under sudo. Empty output means nothing changed. A line means a port appeared. That one is a Redis (an in-memory data store, often used as a cache) instance someone spun up for a test and left running on every interface with no password. On a supposedly known-good server you will turn these up more often than you would like: a leftover from a package's install script, a debug listener, a monitoring agent nobody documented. Baseline the expected ports per host role, alert on anything new, and treat an unexplained open port as something to close or justify, never to accept. What is listening is, exactly, what can be attacked.
Try this
Work through “Baseline it, then watch for drift” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: stopping a service can leave its socket wide open. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.