I built a tiny Docker image. Stripped it down to almost nothing. Felt good about the 12 MB footprint. Pushed it to my registry, deployed it to my test environment, and then realized I needed to hit a health endpoint on another service to verify the network setup was right.
I typed curl http://service:8642/health and got:
bash: curl: command not found
Fine, wget? Nope. nc? Gone. telnet? Who do you think I am, a sysadmin from 1995? Nothing. I had a container that could run my app and do basically nothing else. And I was staring at it, feeling stupid, wondering if I was about to install curl in my beautiful 12 MB image and ruin the whole bit.
Then I remembered something I had read once and never used: bash can open raw TCP sockets, and you can write HTTP by hand into them.
The One Weird Redirection That Saved My Afternoon
The trick is /dev/tcp. It is not a real directory on your filesystem. If you ls /dev/tcp you get nothing. If you cat /dev/tcp/something from another shell it just errors. It is a fake path that bash handles internally during redirection. You use it like this:
exec 3<>/dev/tcp/service/8642
printf 'GET /health HTTP/1.1\r\nHost: service\r\nConnection: close\r\n\r\n' >&3
cat <&3
Line by line, here is what is happening:
exec 3<>/dev/tcp/service/8642opens a TCP connection toserviceon port8642and attaches it to file descriptor 3 for both reading and writing. The<>means bidirectional. Bash does the DNS lookup and theconnect()call for you. You never see the socket.printf '...' >&3writes a raw HTTP request to that socket. It is just text. GET line, Host header, Connection header, and a blank line (the\r\n\r\nat the end) to signal the end of the request.cat <&3reads the response back from the socket and prints it to stdout. Status line, headers, body, everything.
The output looks like any HTTP response you have ever seen:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 20
Date: ...
{"status":"healthy"}
If you need an auth header, just add another line before the blank line:
exec 3<>/dev/tcp/service/8642
printf 'GET /v1/models HTTP/1.1\r\nHost: service\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$API_KEY" >&3
cat <&3
I put this in a script called health-check.sh and called it a day. The container stayed at 12 MB.
The Gotchas That Bit Me So You Do Not Have to Learn Them
I made mistakes. You should benefit from them.
The Server Will Hang Forever If You Forget Close
HTTP/1.1 keeps connections alive by default. That means after the server sends its response, it keeps the socket open waiting for your next request. If you do not tell it to close, cat <&3 sits there forever waiting for bytes that never come. Your script looks like it crashed but it is just patiently waiting for the heat death of the universe.
The fix is the Connection: close header in the request. That tells the server to close after responding, so cat sees EOF and returns. I also learned to wrap the whole thing in timeout 6 bash -c '...' as a safety net. When you are debugging a network issue at 11 PM, you want the thing to fail fast, not sit there silently.
No TLS. No Security. Not Even a Little Bit.
/dev/tcp opens a raw TCP socket. There is no TLS handshake. No certificate verification. No encryption. You are sending plaintext over the wire. This works fine for http:// calls on an internal Docker network where you trust everything. It does not work for https:// at all. If you need TLS you need openssl s_client, and at that point you have already added more weight than curl would have cost you.
Not POSIX, and Not Every Shell Has It
This is not a POSIX feature, and it is not bash's invention either. /dev/tcp came from ksh93 (along with /dev/udp and /dev/sctp), and bash copied it. So "bash only" is not quite true; it is just that ksh93 is almost never in a slim container image and bash usually is. What actually matters is the shells that do not have it: dash (which is /bin/sh on Debian) and zsh both fail on it, so a #!/bin/sh script will not work. Call bash explicitly.
It is also a compile-time option: bash has to be built with --enable-net-redirections. Most mainstream builds enable it (it worked in the Debian-based image I was using), but old or extremely minimal systems may not have it. I learned to test for it first, and the obvious test is a trap twice over. Connecting to a dead port always fails, so a successful exec is never the signal, the error string is. And if bash is missing entirely, the failed bash -c produces an error that matches nothing and the test happily reports success. So check that bash exists first, then read the message. A bash built without net redirections says "No such file or directory"; one that has them reports a connection failure instead. LC_ALL=C is there because that message gets translated on systems with localised errors:
if ! command -v bash >/dev/null 2>&1; then
echo "no bash here at all"
elif LC_ALL=C bash -c 'exec 3<>/dev/tcp/127.0.0.1/0' 2>&1 | grep -q 'No such file'; then
echo "not supported"
else
echo "supported"
fi
This Is Not a Real HTTP Client
Let me be extremely clear about what this is. It is a debugging trick. It does not parse HTTP. It does not handle redirects. It does not handle chunked transfer encoding. It does not retry on failure. It does not compress. It does not do any of the thousand things curl quietly handles for you every time you type a URL.
It is a TCP socket with text written into it. If the response is chunked you get raw chunk boundaries in your output. If the server redirects you get the 302 text and that is it. If the connection drops halfway through, you get half a response.
For day to day work, curl is still the right tool. But when you are inside a container that has no package manager, no network access to install one, and you just need to check if a port is alive, this trick works.
The Full Pattern (One Script to Rule One Check)
Here is the version I ended up using. It is ugly, it is limited, and it lives in my dotfiles under the name ghetto-curl because I have no shame.
#!/bin/bash
# ghetto-curl: the HTTP client that is not one
host="$1"
port="$2"
path="${3:-/}"
timeout 5 bash -c "
exec 3<>/dev/tcp/$host/$port
printf 'GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n' '$path' '$host' >&3
cat <&3
" 2>/dev/null || echo "FAILED: $host:$port$path"
I use it exactly once per month and every time it works I am slightly surprised. 12 MB per image, no curl, and three lines of bash. It feels like cheating, which means it is probably the right kind of wrong.