Every package on a server is code that can have a CVE, and every running service is code that is reachable. A firewall narrows who can reach it, but a service that is not installed cannot be exploited, misconfigured, or forgotten during the next patch cycle. Removing things is the one hardening step that also makes the box simpler to run.
Our server hardening checklist lists "remove unnecessary services" as one line. This guide is that line in full: how to build an inventory of what is listening and what is installed, how to decide what goes, the real difference between disable, mask and purge, and how to stop the clutter from creeping back.
Inventory: What Is Actually Listening
Start with the network, because a listening socket is attack surface in the most literal sense. ss with -tulpn shows TCP and UDP listeners, numeric ports, and the owning process (run it as root to see every process):
$ sudo ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:* users:(("avahi-daemon",pid=811,fd=12))
udp UNCONN 0 0 127.0.0.1:323 0.0.0.0:* users:(("chronyd",pid=846,fd=5))
udp UNCONN 0 0 0.0.0.0:111 0.0.0.0:* users:(("rpcbind",pid=702,fd=5),("systemd",pid=1,fd=38))
tcp LISTEN 0 4096 0.0.0.0:111 0.0.0.0:* users:(("rpcbind",pid=702,fd=4),("systemd",pid=1,fd=37))
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1021,fd=3))
tcp LISTEN 0 100 127.0.0.1:25 0.0.0.0:* users:(("master",pid=1450,fd=13))
tcp LISTEN 0 128 127.0.0.1:631 0.0.0.0:* users:(("cupsd",pid=990,fd=7))
Read the Local Address column first. 0.0.0.0 and [::] mean every interface; 127.0.0.1 means only local processes can connect. Here, rpcbind and avahi are exposed to the network on a server that does not use NFS or mDNS, and systemd appears next to rpcbind because the port is held by a socket unit. That detail matters later: stopping the service alone will not close the port.
# Sockets systemd holds open and the unit each one activates
$ systemctl list-sockets
# Services running right now, and services that start at boot
$ systemctl list-units --type=service --state=running
$ systemctl list-unit-files --type=service --state=enabled
# Timers start work on a schedule even when nothing is "running"
$ systemctl list-timers --all
# Which package owns a binary you found in the process list?
$ dpkg -S /usr/sbin/avahi-daemon # Debian/Ubuntu
$ rpm -qf /usr/sbin/avahi-daemon # RHEL/Fedora
Do not stop at ports β a service with no listener can still be attack surface: a setuid helper, a D-Bus service any local user can call, or a timer that runs a parser on untrusted input. The port list is where you start, not where you finish.
Save the before state β write ss -Htulpn and systemctl list-unit-files --state=enabled to files before you change anything. You will want to diff against them when something breaks next week.
Check containers separately β ports published by Docker or Podman show up as docker-proxy or conmon-owned listeners, and Docker writes its own iptables rules. Review them as part of the same inventory.
Decide: The Usual Suspects on a Default Install
Minimal and cloud images are already lean, but anything installed from a full ISO, or built from a desktop-oriented template, carries services a headless server never uses. These are the ones that turn up most often. Confirm each is truly unused on your host before removing it:
| Service / unit | What it does | Safe to remove when |
|---|---|---|
| avahi-daemon (+ .socket) | mDNS/DNS-SD discovery on UDP 5353 | Nothing resolves .local names or advertises services |
| cups (+ .socket, .path) | Print spooler on TCP 631 | The host never prints. True of almost every server |
| rpcbind (+ .socket) | Port mapper for NFSv3 and other RPC on 111 | No NFSv3 client or server. NFSv4-only setups do not need it |
| bluetooth | BlueZ daemon | No Bluetooth hardware, which is every VM |
| ModemManager | Manages mobile broadband modems | No cellular modem is attached |
| postfix / exim4 | Local MTA on port 25 | Nothing sends local mail (cron, mdadm, logwatch). Otherwise keep it on 127.0.0.1 |
| cockpit.socket | Web console on TCP 9090 (RHEL/Fedora) | You manage the host over SSH or config management only |
| snapd (Ubuntu) | Snap package daemon and its mounts | No snaps are installed: check snap list first |
Ask what depends on it, not what it is β on Debian/Ubuntu apt-cache rdepends --installed avahi-daemon lists installed packages that need it. On RHEL/Fedora the equivalent is dnf repoquery --installed --whatrequires avahi.
Read the logs before you decide β journalctl -u cups --since "30 days ago" showing nothing but start and stop lines is good evidence the service is idle. A daemon that logs real work is used by something, even if nobody remembers what.
Keep the boring essentials β sshd, the time sync daemon (chronyd or systemd-timesyncd), journald, and your monitoring agent stay. Removing time sync breaks TLS, Kerberos and log correlation in ways that take a while to notice.
Disable, Mask, or Remove
These three actions are not interchangeable. Disable removes the boot-time symlinks, but anything can still start the unit, including another unit that pulls it in or a socket that activates it. Mask links the unit to /dev/null, so nothing can start it until it is unmasked. Remove deletes the code, which is the only one that also takes it off the list of packages you need to patch.
# Stop it now and remove it from boot. Include the socket and path units,
# or socket activation will start the service again on the next connection.
$ sudo systemctl disable --now cups.service cups.socket cups.path
# Make it impossible to start, even as a dependency of something else
$ sudo systemctl mask cups.service cups.socket cups.path
Created symlink /etc/systemd/system/cups.service β /dev/null.
Created symlink /etc/systemd/system/cups.socket β /dev/null.
Created symlink /etc/systemd/system/cups.path β /dev/null.
$ systemctl is-enabled cups.service
masked
# Undo when you need it back
$ sudo systemctl unmask cups.service cups.socket cups.path
| Action | Survives reboot | Can be started by deps/sockets | Code still on disk | Still needs patching |
|---|---|---|---|---|
systemctl stop | No | Yes | Yes | Yes |
systemctl disable --now | Yes | Yes | Yes | Yes |
systemctl mask | Yes | No | Yes | Yes |
apt purge / dnf remove | Yes | No | No | No |
When to mask instead of remove β when the package is a hard dependency of something you need. Removing it would take the parent package with it, so mask the unit and keep the files.
Masking is not configuration drift β a mask is a plain symlink under /etc/systemd/system, so it is easy to audit with systemctl list-unit-files --state=masked and easy to manage in Ansible with the systemd module's masked: true.
Verify the port is gone β rerun sudo ss -tulpn. If the port is still open and systemd owns it, a socket unit you missed is still listening.
Removing Packages Without Collateral Damage
Package removal is where people break servers, because both apt and dnf remove whatever depends on the package you name. Always run a dry run first and read the full list of packages that would go.
# --- Debian / Ubuntu ---
# Simulate: shows every package that would be removed, changes nothing
$ apt-get -s purge avahi-daemon cups-daemon
# purge also deletes config files under /etc; remove leaves them behind
$ sudo apt purge avahi-daemon cups-daemon
$ sudo apt autoremove --purge
# Packages someone installed on purpose (the rest came in as dependencies)
$ apt-mark showmanual | sort
# Leftover config from packages removed without purge ("rc" state)
$ dpkg -l | awk '/^rc/ {print $2}'
# --- RHEL / Fedora ---
# Answer "no" automatically to see the transaction without running it
$ sudo dnf remove --assumeno avahi cups
$ sudo dnf remove avahi cups
$ sudo dnf autoremove
# Packages installed explicitly by a user rather than as dependencies
$ dnf repoquery --userinstalled
Stop new dependencies from arriving in the first place. Both package managers pull in "recommended" or "weak" dependencies by default, which is how a single command-line tool install can bring a dozen extra packages with it:
# Debian/Ubuntu: /etc/apt/apt.conf.d/99-no-recommends
APT::Install-Recommends "false";
APT::Install-Suggests "false";
# RHEL/Fedora: add under [main] in /etc/dnf/dnf.conf
install_weak_deps=False
Read the removal list line by line β if a request to remove one daemon lists ubuntu-server, a desktop metapackage, or anything related to the kernel or network, stop. Removing a metapackage is usually harmless, but autoremove will then treat everything it pulled in as unneeded.
Mark what you rely on β if autoremove wants to remove something you use, run sudo apt-mark manual <pkg> on Debian/Ubuntu so it is kept. On dnf, sudo dnf mark install <pkg> does the same (on dnf5 the subcommand is dnf mark user).
Attack Surface Beyond Services
Once the obvious daemons are gone, look at the tools an attacker would use after getting a foothold, and at kernel code that loads on demand.
# setuid and setgid binaries: each one is a privilege boundary
$ sudo find / -xdev \( -perm -4000 -o -perm -2000 \) -type f -exec ls -l {} +
# Compilers and network tools on a production box help attackers more than you
$ dpkg -l gcc make netcat-openbsd telnet 2>/dev/null | grep '^ii' # Debian/Ubuntu
$ rpm -q gcc make nmap-ncat telnet # RHEL/Fedora
# Kernel modules for protocols and filesystems you never use
$ cat /etc/modprobe.d/disable-unused.conf
install dccp /bin/false
install sctp /bin/false
install cramfs /bin/false
install freevxfs /bin/false
blacklist dccp
blacklist sctp
# Verify: a dry run shows the install override instead of loading the module
$ modprobe -n -v dccp
install /bin/false
blacklist alone is not enough β blacklist only stops automatic loading by alias. A direct modprobe dccp still works. The install ... /bin/false line is what makes explicit loads fail too.
Remove build tools from runtime hosts β build software in CI or in a container image, not on the production server. If you package your own services, a multi-stage Dockerfile keeps compilers out of the final image entirely.
Setuid review is a judgment call β do not strip the setuid bit from passwd, sudo or su. Do question anything unfamiliar, and check its permissions with the file permissions calculator if the octal mode is unclear.
Keep It From Creeping Back
A server you cleaned once drifts back over time: someone installs a debugging tool, a package update adds a new socket unit, a dependency pulls in a daemon. A small baseline check turns that drift into an alert instead of a surprise in the next audit.
#!/bin/bash
# /usr/local/sbin/surface-check: compare current listeners and enabled units to a baseline
set -euo pipefail
base=/var/lib/surface-baseline
mkdir -p "$base"
current_ports=$(ss -Htuln | awk '{print $1, $5}' | sort -u)
current_units=$(systemctl list-unit-files --state=enabled --no-legend | awk '{print $1}' | sort)
if [ ! -f "$base/ports" ]; then
echo "$current_ports" > "$base/ports"
echo "$current_units" > "$base/units"
echo "baseline created"
exit 0
fi
diff "$base/ports" <(echo "$current_ports") || echo "ALERT: listening sockets changed"
diff "$base/units" <(echo "$current_units") || echo "ALERT: enabled units changed"
Run it on a schedule β a daily run from cron or a systemd timer is enough. The cron generator builds the schedule line, and output sent to root's mail or your log pipeline becomes the alert.
Close what you keep β services that must stay, but only for some clients, still belong behind host firewall rules. The firewall rules generator produces nftables, iptables or ufw rules for an allowlist.
Frequently Asked Questions
Is disabling a service enough to secure it?
Not always. Disable only removes the boot-time links. A socket unit, a path unit, or another service that depends on it can still start it. Disable the matching .socket and .path units as well, mask the service if nothing should ever start it, or remove the package so the code is gone and no longer needs patching.
What is the difference between systemctl disable and systemctl mask?
Disable removes the symlinks that start a unit at boot, but the unit can still be started manually or as a dependency. Mask links the unit file to /dev/null, so any attempt to start it fails until you run systemctl unmask. Use mask for services that must never run but whose package you cannot remove.
How do I find which services are listening on network ports?
Run sudo ss -tulpn. It lists TCP and UDP listeners with numeric ports and the owning process. Addresses of 0.0.0.0 or [::] are reachable from every interface, while 127.0.0.1 is local only. If systemd is listed as the owner, a socket unit holds the port, and systemctl list-sockets shows which one.
Is apt autoremove safe on a production server?
Usually, but read the list first. autoremove removes packages that were installed as dependencies and are no longer needed by anything marked manual. If you removed a metapackage, many packages you rely on can appear in that list. Run apt-get -s autoremove to preview, and mark anything you need with apt-mark manual.
Should I remove compilers like gcc from production servers?
Yes, if nothing on the host builds software at runtime. An attacker with a shell can compile exploits or tools locally when a compiler is present. Build in CI or in a multi-stage container image instead. Some packages, such as DKMS kernel modules, need a compiler on the host, so check dependencies before removing it.