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/tcponly exists inside a bash redirection.cat /dev/tcp/example.com/80fails withNo such file or directory, becausecatasks the real filesystem. Running the redirection undershfails the same way on Debian, whereshis dash. Alpine does not ship bash at all (apk add bash), and BusyBoxashhas no equivalent.- macOS has
/dev/tcpin its bash 3.2, but notimeoutcommand.brew install coreutilsprovides it asgtimeout. 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
timeoutkilled it, so the packets were dropped. 1 means refused or unresolvable, and the message on standard error says which:Connection refusedorName or service not known. Collapsing both intoclosedis 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
openproves 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