Bash and Linux

Check If a Port Is Open

mediumNetworking and connectivity

Problem statement

Check whether TCP ports answer, using only bash and timeout, against a small web server the script starts on 127.0.0.1. This is how you tell "the service is down" from "the network is blocked" when tools like nc, telnet or curl are not installed, and it is the core of every port health check.

The script picks two free ports on 127.0.0.1: it starts a test web server (python3 -m http.server) on the first and leaves the second unused.

  1. Wait until the web server is ready.
  2. Check both ports and print open or closed.
  3. Stop the server and check its port again.

Expected output:

TEXT
== check each port ==
web port: open
unused port: closed
== after stopping the server ==
web port: closed

Hints

Hint 1: In bash, opening /dev/tcp/HOST/PORT makes a real TCP connection: exec 3<>/dev/tcp/127.0.0.1/80 succeeds only if something accepts the connection.

Approach

Optimal: bash /dev/tcp with timeout

Covers: TCP connect checks, bash /dev/tcp/host/port, timeout, exit codes, open vs closed vs filtered, waiting for a service to start, nc -z, $!, ${!name}.

Three possible answers. When you try to connect to a port, one of three things happens:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph R["connect to a port"] direction TB O["open
accepted"]:::green ~~~ C["closed
refused at once"]:::yellow ~~~ F["filtered
no answer, timeout"]:::red end classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style R fill:transparent,stroke:#6b7280,stroke-width:2px
Result What happened Means
open the connection is accepted a program is listening
closed an instant "connection refused" the host is up, but nothing listens there
filtered no answer at all, until a timeout a firewall is dropping the packets, or the host is down

"Closed" and "filtered" lead to different fixes: start the service, or fix the firewall. That is why the timeout matters: without one, a filtered port makes your check hang for a minute or more.

Bash can connect by itself. /dev/tcp/HOST/PORT is not a real file. Bash treats a redirect to that path as "open a TCP connection". exec 3<>/dev/tcp/127.0.0.1/8080 connects and keeps the connection on descriptor 3. It succeeds (exit code 0) only if the connection was accepted. This works in bash on any Linux box, even without nc, telnet or curl.

timeout stops it hanging. timeout 1 cmd kills cmd if it is still running after one second, and returns 124. Running the check in timeout 1 bash -c "..." gives every port at most one second. The 2>/dev/null hides bash's "Connection refused" message.

Waiting for a service to start. A freshly started server needs a moment before it listens. The loop tries up to 50 times, a tenth of a second apart, and stops as soon as the port answers. Deploy scripts use the same loop to wait for an app before sending it traffic.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB S(["start the server"]):::purple --> T{{"port answers?"}}:::yellow T --> W["wait 0.1 s, try again
up to 50 times"]:::gray W --> T T --> R["ready: run the checks"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Walking through the code. The # Setup: lines only start the test server on a free port, so skip past them. free_port asks Python for a port number nobody is using, so the script never clashes with real services. Because the numbers change every run, the output uses names instead.

  1. is_open runs the /dev/tcp check with a one-second timeout.
  2. The loop waits for the server, then checks web and unused. ${!name} reads the variable whose name is in name, so the same loop line checks $web and then $unused.
  3. kill stops the server and wait waits for it to exit; the check now fails, because nothing listens any more.

Edge cases. An open port only proves something accepted the connection, not that the service works; a health check should also send a real request (next pages). Checking another machine's ports from a script counts as scanning, so only do it on systems you are allowed to test. This page needs python3 for the test server.

# Setup: start a test web server on a free port on 127.0.0.1, and pick a second free port
cd "$(mktemp -d)"
free_port() { python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])'; }
web=$(free_port)
python3 -m http.server "$web" --bind 127.0.0.1 > server.log 2>&1 &
server=$!
unused=$(free_port)

# Open a TCP connection with bash itself; give up after 1 second
is_open() { timeout 1 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null; }

for _ in $(seq 1 50); do is_open 127.0.0.1 "$web" && break; sleep 0.1; done

echo "== check each port =="
for name in web unused; do
  port=${!name}
  if is_open 127.0.0.1 "$port"; then echo "$name port: open"; else echo "$name port: closed"; fi
done

kill "$server"; wait "$server" 2>/dev/null
echo "== after stopping the server =="
if is_open 127.0.0.1 "$web"; then echo "web port: open"; else echo "web port: closed"; fi

Interview follow-ups

  • Check a list of host:port pairs and exit 1 if any is not open.

    Read the list line by line, split on the colon, and keep a failure count: while IFS=: read -r h p; do is_open "$h" "$p" && echo "OK $h:$p" || { echo "FAIL $h:$p"; fails=$((fails+1)); }; done < targets.txt, then exit $(( fails > 0 )). With many targets, run the checks in the background with & and wait, as on the background jobs page, so a few dead hosts do not each cost a full timeout in a row.

Frequently asked questions

If it is installed, nc -zv -w 1 host 80 does the same check and prints a clear message; -z means "just test, send nothing" and -w 1 is the timeout. The catch is that there are several different nc versions with different flags, and minimal images often have none. /dev/tcp works wherever bash is, which makes it the safe choice inside scripts and containers. Note that /dev/tcp is a bash feature: it does not work in sh or dash.

The program is running, so look at the path in between. Check what address it binds to with ss -tlnp: 127.0.0.1 means local only. If it binds to 0.0.0.0, check the host firewall and, in the cloud, the security group or network ACL. If the check from outside hangs until the timeout instead of failing at once, packets are being dropped, which points at a firewall rather than the app.

UDP has no handshake, so there is no clean "open" answer. A closed UDP port may send back an ICMP "port unreachable", but a firewall can drop that too, so silence means "open or filtered". The useful test is protocol-specific: dig @host example.com for DNS on 53, or ntpdate -q host for NTP. Bash's /dev/udp/host/port can send a packet, but cannot tell you whether anything received it.