HTTP Requests Without curl: Bash /dev/TCP Explained
Meta Description: TIL you can make HTTP requests without curl using Bash /dev/TCP — a built-in trick that works on any Linux system, no tools required. Here's exactly how it works.
TL;DR: Bash has a hidden superpower —
/dev/tcp/host/port— that lets you open raw TCP connections and send HTTP requests without installing curl, wget, or any external tool. It's not a replacement for curl in production, but it's an invaluable trick for debugging, minimal environments, and impressing your teammates.
Why This Matters More Than You Think
Picture this: you're SSH'd into a stripped-down Docker container, a minimal Alpine Linux instance, or a locked-down server where you have zero ability to install packages. You need to check if an API endpoint is alive, or quickly grab a configuration file from a URL. You type curl and get command not found. You try wget — same result. Panic sets in.
This is exactly where making HTTP requests without curl using Bash's /dev/TCP saves the day. This technique has been baked into Bash since version 2.04, which means it's been hiding in plain sight for over two decades. Most developers never learn about it — until the moment they desperately need it.
Let's fix that right now.
What Is Bash /dev/TCP?
Bash includes a special pseudo-device filesystem feature that isn't actually part of the Linux kernel at all. When you reference /dev/tcp/hostname/port in a Bash script, Bash intercepts that path and opens a TCP socket connection to the specified host and port — entirely in userspace.
This is a Bash-specific feature, not a standard Unix feature. You won't find it in sh, dash, or zsh by default. But since Bash is the default shell on the vast majority of Linux systems (and available on macOS), it's almost universally accessible.
The Basic Syntax
exec 3<>/dev/tcp/hostname/port
This single line does something remarkable:
- Opens a bidirectional file descriptor (file descriptor 3, though you can use any number from 3-9)
-
<>means both readable and writable - Connects to
hostnameon the specifiedport
From there, you can write HTTP request data to that file descriptor and read the response back — just like any other file in Unix.
Your First HTTP Request Without curl
Let's start with the simplest possible example: a basic GET request.
#!/bin/bash
# Open a TCP connection to example.com on port 80
exec 3<>/dev/tcp/example.com/80
# Send an HTTP GET request
printf "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" >&3
# Read and print the response
cat <&3
# Close the file descriptor
exec 3>&-
Run this and you'll see the full HTTP response — headers and body — printed to your terminal. That's a complete HTTP transaction with zero external dependencies.
Breaking Down the HTTP Request
The printf line is doing the heavy lifting here. Let's dissect it:
-
GET / HTTP/1.1— The request line: method, path, HTTP version -
\r\n— Carriage return + newline (required by HTTP spec, not just\n) -
Host: example.com— Required header in HTTP/1.1 -
Connection: close— Tells the server to close after responding (critical for scripts) -
\r\n\r\n— The blank line that signals end of headers
Miss any of these and many servers will simply hang waiting for a valid request.
Practical Examples You Can Use Right Now
Checking if a Service is Up
#!/bin/bash
HOST="api.myservice.com"
PORT=80
if (echo >/dev/tcp/$HOST/$PORT) 2>/dev/null; then
echo "✅ $HOST:$PORT is reachable"
else
echo "❌ $HOST:$PORT is unreachable"
fi
This is one of the most practical uses — a lightweight health check that works even in minimal containers. No curl, no netcat, no nothing.
Grabbing Just the HTTP Status Code
#!/bin/bash
exec 3<>/dev/tcp/httpbin.org/80
printf "GET /status/200 HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n" >&3
# Read just the first line (status line)
read -r status_line <&3
echo "Status: $status_line"
exec 3>&-
Output: Status: HTTP/1.1 200 OK
Sending a POST Request with Data
#!/bin/bash
HOST="httpbin.org"
DATA='{"name":"bash","version":"5.2"}'
CONTENT_LENGTH=${#DATA}
exec 3<>/dev/tcp/$HOST/80
printf "POST /post HTTP/1.1\r\n" >&3
printf "Host: %s\r\n" "$HOST" >&3
printf "Content-Type: application/json\r\n" >&3
printf "Content-Length: %d\r\n" "$CONTENT_LENGTH" >&3
printf "Connection: close\r\n" >&3
printf "\r\n" >&3
printf "%s" "$DATA" >&3
cat <&3
exec 3>&-
POST requests require the Content-Length header — get it wrong and the server will either hang or reject your request.
Extracting Just the Response Body
#!/bin/bash
exec 3<>/dev/tcp/example.com/80
printf "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" >&3
# Skip headers, print only body
body=false
while IFS= read -r line <&3; do
# Headers end at the blank line
[[ "$line" == $'\r' ]] && body=true && continue
$body && echo "$line"
done
exec 3>&-
HTTPS Requests: The Elephant in the Room
Here's where we need to be honest: raw /dev/tcp cannot handle HTTPS. TLS/SSL encryption happens at a layer above raw TCP, and Bash has no built-in TLS support.
Your options for HTTPS in minimal environments:
| Approach | Works Without curl? | Handles HTTPS? | Complexity |
|---|---|---|---|
/dev/tcp (Bash) |
✅ Yes | ❌ No | Low |
openssl s_client |
Needs openssl | ✅ Yes | Medium |
ncat --ssl |
Needs ncat | ✅ Yes | Medium |
curl |
Needs curl | ✅ Yes | Low |
wget |
Needs wget | ✅ Yes | Low |
For HTTPS in environments where openssl is available (it usually is, even when curl isn't), you can pipe through openssl s_client:
openssl s_client -quiet -connect api.example.com:443 2>/dev/null <<EOF
GET /endpoint HTTP/1.1
Host: api.example.com
Connection: close
EOF
It's clunkier than curl, but it works. For serious HTTPS work in scripts, curl remains the gold standard — it handles TLS, redirects, authentication, and a hundred other edge cases that raw socket scripting gets wrong.
/dev/UDP: The Forgotten Sibling
While we're here, Bash also supports /dev/udp/host/port for UDP connections. This is useful for:
- Sending syslog messages
- DNS queries (though parsing responses is painful)
- Simple UDP service checks
# Send a UDP packet (fire and forget)
echo "log message" > /dev/udp/syslog-server/514
UDP is connectionless, so there's no response to read — you're just firing data at an address and hoping for the best.
When Should You Actually Use This?
Let's be real about the use cases. This is a niche tool, but it shines in specific scenarios:
✅ Good Use Cases
- Debugging in minimal containers — Docker images with no package manager access
- CI/CD pipeline health checks — When you want zero additional dependencies
- Bash-only environments — Embedded systems, minimal VMs, rescue shells
- Quick port connectivity tests — Faster than installing netcat
- Learning how HTTP works — Nothing teaches you HTTP like building requests by hand
- Interview demonstrations — Genuinely impresses people who haven't seen it
❌ Bad Use Cases
- Production HTTP clients — Use curl or a proper HTTP library
- HTTPS endpoints — No TLS support
- Handling redirects — You'd have to implement this yourself
- Complex authentication — OAuth, bearer tokens, etc. get messy fast
-
Parsing JSON responses — You still need
jqor similar [INTERNAL_LINK: parsing JSON in bash]
Comparison: /dev/TCP vs. Real HTTP Tools
| Feature | /dev/tcp | curl | wget |
|---|---|---|---|
| Requires installation | ❌ No | ✅ Yes | ✅ Yes |
| HTTPS support | ❌ No | ✅ Yes | ✅ Yes |
| Redirect following | ❌ Manual | ✅ Auto | ✅ Auto |
| Cookie handling | ❌ No | ✅ Yes | ✅ Limited |
| Timeout control | ⚠️ Limited | ✅ Full | ✅ Full |
| HTTP/2 support | ❌ No | ✅ Yes | ❌ Limited |
| Learning curve | Medium | Low | Low |
| Script portability | Bash only | Universal | Universal |
For anything beyond basic GET/POST in controlled environments, curl is the right tool. The /dev/tcp trick is a survival skill, not a daily driver.
Troubleshooting Common Issues
The Script Hangs Forever
Cause: Missing Connection: close header, or the server is using chunked transfer encoding.
Fix: Always include Connection: close in your headers. For reading, add a timeout:
# Set a read timeout using Bash's built-in timeout
read -t 5 -r response <&3
"Bad file descriptor" Error
Cause: Trying to use /dev/tcp in a non-Bash shell (sh, dash, etc.).
Fix: Make sure your shebang is #!/bin/bash, not #!/bin/sh.
Connection Refused
Cause: The port is closed or the host is unreachable.
Fix: Wrap in error handling:
exec 3<>/dev/tcp/myhost/80 2>/dev/null || { echo "Connection failed"; exit 1; }
Feature Disabled on Your System
Some hardened Linux distributions compile Bash with --disable-net-redirections for security reasons. If /dev/tcp silently fails, check:
bash --version # Verify it's actually Bash
ls /dev/tcp # This will show "No such file or directory" — that's normal
echo test > /dev/tcp/localhost/80 # Test if the feature works
Key Takeaways
-
Bash's
/dev/tcp/host/portis a built-in feature that opens raw TCP connections without any external tools - The syntax
exec 3<>/dev/tcp/hostname/portopens a bidirectional file descriptor for reading and writing - You can send complete HTTP/1.1 requests — GET, POST, and others — by writing properly formatted HTTP to the file descriptor
- HTTPS is not supported — you need openssl or an actual HTTP client for encrypted connections
- Best used for health checks, minimal environments, and learning — not production HTTP clients
- Always include
Connection: closein your headers to prevent hanging scripts - This feature is Bash-specific and won't work in sh, dash, or other shells
- For serious HTTP work, curl and HTTPie are far more capable and reliable
Further Reading and Related Skills
Once you've mastered /dev/tcp, these related topics will round out your shell networking skills:
- [INTERNAL_LINK: netcat for port scanning and debugging]
- [INTERNAL_LINK: bash scripting best practices]
- [INTERNAL_LINK: docker container debugging techniques]
- [INTERNAL_LINK: HTTP headers every developer should know]
Start Using It Today
Next time you're in a minimal environment and need a quick connectivity test, skip the panic and type:
(echo >/dev/tcp/google.com/80) 2>/dev/null && echo "Connected!" || echo "Failed"
Bookmark this article. The one time you need this trick, you'll really need it — and you'll look like a wizard to anyone watching.
Frequently Asked Questions
Q: Does /dev/tcp work on macOS?
Yes, macOS ships with Bash (though newer versions default to zsh). As long as you invoke the script with #!/bin/bash, /dev/tcp works fine on macOS. Note that macOS ships Bash 3.2 due to licensing — the feature still works, but upgrade to Bash 5.x via Homebrew for the best experience.
Q: Is using /dev/tcp a security risk?
The feature itself isn't inherently dangerous, but like any network capability in scripts, it can be misused. Some security-hardened systems (SELinux, certain container policies) disable it intentionally. For scripts running in untrusted environments, validate any hostnames or ports you're connecting to.
Q: Can I use /dev/tcp to connect to databases?
Technically yes — you can open a TCP connection to any port, including MySQL (3306), PostgreSQL (5432), or Redis (6379). However, these protocols have their own binary or text-based handshakes that are far more complex than HTTP. Stick to purpose-built CLI clients for databases.
Q: Why doesn't this work in my shell script?
The most common culprit is using #!/bin/sh instead of #!/bin/bash. The /dev/tcp feature is Bash-specific. Check your shebang line and verify with echo $BASH_VERSION that you're actually running Bash.
Q: What's the difference between /dev/tcp and netcat?
Both open TCP connections, but netcat (nc) is an external binary that must be installed, while /dev/tcp is built into Bash itself. Netcat is more flexible (supports UDP, listening mode, port scanning) while /dev/tcp has zero dependencies. In practice, if netcat is available, it's often easier to use — but /dev/tcp wins when you have absolutely nothing else.
Top comments (0)