All posts
bashshelldockernetworking

HTTP Without curl: Bash’s Hidden Superpower

Stripped-down container images ship without curl, wget, or any HTTP client at all. But bash can open a raw TCP socket through /dev/tcp — and that's enough to fire off an HTTP request by hand.

You need to check that one container can reach another over an internal Docker network. The obvious move is curl http://service:8642/health. But the image was stripped down — no curl, no wget, nothing else around that could open a socket. This is a surprisingly common situation with scratch-based or distroless images.

As it turns out, bash can speak HTTP by itself. Bash can open a TCP socket, and you can write a small HTTP/1.1 request to it by hand. You need nothing beyond the shell that's already there.

The Core Technique

Opening a connection to a host and port looks like this:

BASH
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

service here is just the hostname of whatever you're talking to. It needs to resolve and be reachable from wherever you run this — a container or service name on a Docker network, or any DNS name that resolves. Swap in your own host and port.

That prints the whole response: the status line, the headers, the blank line, and the body. To add a header — say, an Authorization: Bearer token — just put another \r\n-terminated line before the blank line that ends the request:

BASH
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

Why /dev/tcp Isn't a Real File

The first thing that trips people up: /dev/tcp is not a real device file. There's no such path on disk. ls /dev/tcp finds nothing, and cat /dev/tcp/... from another shell just errors. It's a redirection that bash handles internally.

/dev/tcp/host/port — If host is a valid hostname or Internet address, and port is an integer port number or service name, bash attempts to open the corresponding TCP socket.

— The Bash Manual

The names were chosen because no real Unix has a /dev/tcp or /dev/udp hierarchy, so there's nothing to collide with. Bash does the DNS lookup and the connect(2) for you, and exec 3<> hands the socket a file descriptor (3) you read from and write to like any other.

Things Worth Knowing Before You Rely On This

  • This is not a real HTTP client. It does not parse HTTP properly, handle redirects, chunked responses, compression, retries, TLS, or any of the other things curl quietly does for you. It's a quick connectivity and debugging trick.
  • The Connection: close header matters. Without it, the server keeps the connection open after it responds (HTTP/1.1 default), and cat <&3 waits forever for bytes that never arrive. Asking the server to close means cat reaches EOF and returns. Wrapping the call in timeout 6 bash -c '...' covers you either way.
  • No TLS. /dev/tcp opens a raw socket, so this only works for plaintext HTTP. For https you'd need openssl s_client, and by then you may as well have the proper tools.
  • This is a bash feature, not POSIX. dash (Debian's /bin/sh) and zsh don't have it, so a #!/bin/sh script can't use it. Call bash directly.
  • It's a compile-time option, switched on when bash is built with --enable-net-redirections. Most mainstream builds enable it, but Debian shipped it disabled for years — on an old or very minimal system it's worth checking first.

The Main Takeaway

For day-to-day work, curl is still the right tool. But inside a deliberately small container where you can't install anything, this gets a quick health check done without adding a package, changing the image, or breaking the whole point of keeping the image minimal.

The larger lesson: bash is quietly more capable than its reputation suggests. /dev/tcp is one of those features that sits hidden in the manual until the one moment you genuinely need it — and then it's exactly what you need.