← back to latest

Check if a remote port is open when netcat is not installed

timeout 3 bash -c '</dev/tcp/bash-hacks.com/443' && echo open || echo closed

anatomy

/dev/tcp/host/port
Not a file. There is no `/dev/tcp` directory on the disk, and `ls /dev/tcp` says so. Bash recognizes the path inside a redirection and, instead of calling `open()`, resolves the host and makes a TCP connection to the port. The socket becomes the file descriptor. The port can be a number or a service name from `/etc/services`, so `https` works as well as `443`.
<
A redirection with no command attached. Bash performs the redirection, which means completing the TCP handshake, then has nothing to run and closes the socket. The exit status is the exit status of the connect: 0 if the handshake finished, 1 if it was refused or the name did not resolve. No bytes are sent, so the server sees a connection open and close and nothing else.
bash -c '...'
A child bash to hold the redirection. `timeout` runs programs, and `/dev/tcp` is a shell feature rather than a program, so it needs a shell to execute it. Naming `bash` explicitly also means the line works when your login shell is zsh or dash, neither of which knows about `/dev/tcp`. The single quotes keep your current shell from trying to interpret the redirection itself.
timeout 3
Kills the child after three seconds and exits 124. Bash has no connect timeout of its own, so a port behind a firewall that silently drops packets leaves the connect waiting on the kernel's SYN retries, which on Linux is over two minutes. This is the flag that turns an apparent hang into an answer.
&& echo open || echo closed
Turns the exit status into a word. It is not a true if/else: the `||` branch runs if anything before it fails. Here `echo open` cannot realistically fail, so the shortcut is safe, but the pattern bites when the middle command can.

Sample output

$ timeout 3 bash -c '</dev/tcp/bash-hacks.com/443' && echo open || echo closed
open
$ timeout 3 bash -c '</dev/tcp/bash-hacks.com/22' && echo open || echo closed
closed
$ timeout 3 bash -c '</dev/tcp/localhost/5432' && echo open || echo closed
bash: connect: Connection refused
bash: line 1: /dev/tcp/localhost/5432: Connection refused
closed

Three closeds that are not the same. Port 443 on bash-hacks.com answered in about a tenth of a second. Port 22 printed nothing and took the full three seconds, which means something between you and the host dropped the packets: a firewall, a security group, a network ACL. Port 5432 on localhost failed instantly with Connection refused, which means the machine is reachable and nothing is listening, so the problem is the service, not the network.

When you would reach for it

A minimal container image, a locked-down jump host, or a fresh VM where nc, telnet, and nmap are all missing and installing them means a ticket. Bash is almost always there. It is also the quickest way to split “the database is down” from “the database is unreachable” when an app logs a vague connection error, because the timing and the error message tell you which side of the firewall to look at.

Gotchas

  • /dev/tcp only exists inside a bash redirection. cat /dev/tcp/example.com/80 fails with No such file or directory, because cat asks the real filesystem. Running the redirection under sh fails the same way on Debian, where sh is dash. Alpine does not ship bash at all (apk add bash), and BusyBox ash has no equivalent.
  • macOS has /dev/tcp in its bash 3.2, but no timeout command. brew install coreutils provides it as gtimeout. Without any timeout, a filtered port hangs the line for a minute or more, which reads as a frozen terminal rather than an answer.
  • Read the exit status, not only the word. 124 means timeout killed it, so the packets were dropped. 1 means refused or unresolvable, and the message on standard error says which: Connection refused or Name or service not known. Collapsing both into closed is fine at a glance and wrong in a runbook.
  • Open means the TCP handshake completed, nothing more. A load balancer, a proxy, or a Kubernetes service accepts connections whether the backend behind it is healthy or not, so open proves the path is clear, not that the application works.

Variants

$ for p in 22 80 443; do timeout 2 bash -c "</dev/tcp/bash-hacks.com/$p" 2>/dev/null && echo "$p open" || echo "$p closed"; done

A handful of ports in one pass. The inner quotes switch to double so the outer shell expands $p before the child bash sees it. The 2>/dev/null hides the refusal messages here because the loop prints its own verdict, so drop it when you need the reason

$ until timeout 1 bash -c '</dev/tcp/localhost/5432' 2>/dev/null; do sleep 1; done; echo ready

Wait for a service to come up before running the next step, the job usually handed to a wait-for-it script in a container entrypoint. until loops while the command fails, so this polls once a second and falls through the moment the port accepts. It waits forever if the service never starts, so wrap the whole loop in timeout 60 bash -c '...' in anything unattended

$ exec 3<>/dev/tcp/www.debian.org/80; printf 'HEAD / HTTP/1.1\r\nHost: www.debian.org\r\nConnection: close\r\n\r\n' >&3; head -1 <&3; exec 3<&-

Talk to the port instead of only knocking. exec 3<> opens the socket read-write as file descriptor 3 in your current shell, printf writes a raw HTTP request into it, head reads the status line back, and exec 3<&- closes it. Plain HTTP only: there is no TLS here, so port 443 answers with nothing you can read

lineage

Bash 2.04, released in 2000, taught the redirection code to recognize `/dev/tcp/host/port` and `/dev/udp/host/port` and to open a socket instead of a file. ksh93 already treated the same paths as special, and bash added them in the same release that borrowed ksh93's `${!prefix*}` expansion. The feature is a compile-time option, and distributions have not always agreed on whether a shell should be able to open network connections. Restricted bash refuses it outright: a later release made `/dev/tcp` and `/dev/udp` redirections illegal under `rbash`, on the reasoning that a shell meant to confine a user should not hand them a socket.