Every team we have worked with has a document that says which ports are open on which hosts. Almost none of those documents are correct. Security groups get a temporary rule during an incident, a container publishes a port on all interfaces, someone installs a monitoring agent that listens on 0.0.0.0. The only reliable way to know what a host exposes is to ask the network, and nmap is the tool we use for that.

This post is the routine we run against our own infrastructure: how we scope it, the scans we actually use, how we read the results, and how we turn a one-off scan into a weekly diff that pages someone when exposure changes. It is written for engineers who own servers, not for testing other people's.

Get permission and write down the scope first

Scanning hosts you do not own or are not explicitly authorised to test can be illegal and will at minimum get your source IP reported and blocked. Before any scan we confirm three things in writing, even inside our own company:

  • The exact targets: IP ranges and hostnames we own, listed in a file, not typed ad hoc.
  • Who approved it and the time window.
  • Provider rules. Major cloud providers allow customers to scan their own resources without prior approval for most services, but they publish policies with exceptions. Read yours before scanning anything hosted.

We keep the scope in a plain text file and always pass it with -iL, so a typo on the command line cannot widen the scan:

# scope-prod.txt - approved 2025-09-01, owner: platform team
203.0.113.10
203.0.113.11
203.0.113.0/28
api.example.com

We also scan from two vantage points: a small VM outside our network (what the internet sees) and a host inside the VPC (what a compromised neighbour sees). Both views matter, and they rarely match.

Host discovery without fooling yourself

Cloud hosts usually drop ICMP echo, so nmap's default ping step marks them as down and skips them. That is the first false sense of safety. For a known list of our own addresses we skip discovery entirely:

# Treat every target as up; list what nmap resolved before scanning anything
nmap -sL -iL scope-prod.txt
nmap -Pn -iL scope-prod.txt --top-ports 100 -oA scans/quick-$(date +%F)

The -sL list scan sends no probes; it only shows the targets nmap will use after DNS resolution. We run it first to confirm a hostname has not started resolving somewhere unexpected.

The scans we actually run

Full TCP port sweep

The top 1,000 ports are a sample, not an audit. Services we have found exposed in the past lived on 2375, 6443, 9200, 10250 and 27017, and odd ones on 8xxx ports chosen by whoever deployed them. We scan all 65,535 TCP ports:

sudo nmap -sS -Pn -p- --min-rate 500 --max-retries 2 \
  -iL scope-prod.txt -oA scans/tcp-full-$(date +%F)

-sS is a SYN scan and needs root. Without root, nmap falls back to -sT (full connect), which works but leaves more noise in the target's logs. --min-rate keeps the run inside our maintenance window; we keep it modest so we do not trip our own rate limiting or IDS and spend the morning chasing ourselves.

Service and version detection on what is open

Once we know the open ports, we ask what is behind them, only on those ports:

sudo nmap -sS -sV --version-intensity 5 -Pn -p 22,80,443,6379,9100 \
  203.0.113.10 -oA scans/svc-203.0.113.10-$(date +%F)

A port number tells you very little. Version detection is what reveals that 443 on one host is nginx and on another is an old admin console someone forgot about.

UDP, the part everyone skips

UDP scanning is slow because closed ports often do not answer and the kernel rate-limits ICMP unreachable replies. We do not scan all UDP ports; we scan the ones that cause real incidents:

sudo nmap -sU -Pn -p 53,123,161,500,1900,4500,5353,11211 \
  -iL scope-prod.txt -oA scans/udp-$(date +%F)

An open|filtered result means nmap got no answer, which is ambiguous. Combine it with -sV on that port to get a definite answer when it matters, for example for SNMP on 161 or memcached on 11211.

IPv6

If your hosts have IPv6 addresses, scan them too. Several times we found a firewall that was strict on IPv4 and completely open on IPv6 because the rules were written for one family only:

sudo nmap -6 -sS -Pn -p- 2001:db8::10 -oA scans/tcp6-$(date +%F)

Safe NSE scripts for configuration checks

The Nmap Scripting Engine has categories. For audits we restrict ourselves to scripts in the safe category, and name them explicitly:

nmap -Pn -p 443 --script ssl-enum-ciphers,ssl-cert,http-security-headers api.example.com
nmap -Pn -p 22 --script ssh2-enum-algos,ssh-hostkey 203.0.113.10

ssl-enum-ciphers grades the TLS versions and cipher suites the server offers, ssh2-enum-algos shows whether old key exchange and MAC algorithms are still offered, ssh-hostkey records host key fingerprints so we notice when one changes unexpectedly, and http-security-headers gives a quick view of HSTS and related headers. We never run the intrusive, brute or exploit categories as part of routine auditing.

Reading results: the finding that justified the whole exercise

On one audit, a host that ufw said only allowed 22 and 443 showed this:

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.13
443/tcp  open  ssl/http nginx 1.24.0 (Ubuntu)
6379/tcp open  redis   Redis key-value store 7.2.4

ufw was configured correctly. The problem was a Compose file with ports: - "6379:6379". Docker publishes container ports by writing its own iptables rules in the DOCKER chain, which are evaluated before the ufw chains for forwarded traffic. The result is a port that is open to the world while ufw status insists it is not. The fix was to bind to loopback, or drop the published port and use the Compose network:

services:
  redis:
    image: redis:7.2
    ports:
      - "127.0.0.1:6379:6379"

If you need filtering for published container ports, Docker documents the DOCKER-USER chain as the place for your own rules. Then rescan: the only proof that matters is the port showing closed or filtered from outside.

How we triage each open port:

State What it means What we do
open, expected Matches the inventory Check version against patch level
open, unexpected Something listens and the path is allowed Owner identified within a day, closed or justified
filtered A firewall drops the probe Usually fine; confirm it is the firewall we expect
closed Host answered with RST, nothing listening Fine, but the host is reachable on that port if something starts
open, filtered (UDP) No reply Follow up with version detection on that port

Turning it into a weekly diff

A single scan is a snapshot. Exposure problems come from change, so we diff. -oA writes normal, grepable and XML output; the XML is what ndiff (shipped with nmap) compares:

#!/usr/bin/env bash
set -euo pipefail
cd /opt/exposure-audit
today=scans/tcp-full-$(date +%F)
sudo nmap -sS -sV -Pn -p- --min-rate 500 --max-retries 2 \
  -iL scope-prod.txt -oA "$today"

prev=$(ls -1 scans/tcp-full-*.xml | sort | tail -n 2 | head -n 1)
if [ "$prev" != "$today.xml" ]; then
  ndiff "$prev" "$today.xml" > diff-latest.txt || true
  if grep -qE '^\+.*open' diff-latest.txt; then
    ./notify.sh "New open port detected" diff-latest.txt
  fi
fi

ndiff exits non-zero when it finds differences, which is why the || true is there. Lines starting with + are new state; we alert only on newly open ports, and review the rest in the weekly ticket. The schedule lives in a systemd timer on the external scanning VM, and scan output is kept for 90 days so we can answer "since when was this open?" during an incident.

Common mistakes

Mistake Why it hurts Do this instead
Relying on the default ping Cloud hosts drop ICMP, so they are skipped and look clean Use -Pn for known targets
Scanning only the top ports Misconfigured services love high ports Run -p- at least weekly
Only scanning from inside Internal view hides what the internet sees Scan from an external vantage point too
Trusting ufw status with Docker Published ports bypass ufw rules Bind to 127.0.0.1 or filter in DOCKER-USER, then rescan
Forgetting IPv6 Rules often exist for IPv4 only Scan with -6 for every dual-stack host
Running intrusive scripts in production Can crash fragile services or lock accounts Stick to named safe scripts

Key takeaways

  • Only scan systems you own or are explicitly authorised to test, with a written scope file passed via -iL.
  • Use -Pn and a full -p- sweep; default discovery and top-ports sampling both hide real exposure.
  • Scan from outside and inside, and over IPv6 as well as IPv4.
  • Treat every unexpected open port as a ticket with an owner, then verify the fix with a rescan.
  • Automate with -oA and ndiff so a new open port triggers an alert instead of waiting for the next audit.

References