Skip to content

09 · Networking from the Shell

Shell scripts routinely need to talk to the network: fetching data, checking whether a service is up, or scripting an API call. This module covers curl, wget, and bash's lesser-known built-in socket support via /dev/tcp.

curl: the basics

curl https://example.com                       # GET request, prints body to stdout
curl -o page.html https://example.com            # save response to a file
curl -O https://example.com/file.zip              # save using the remote filename
curl -s https://example.com                        # silent: suppress the progress meter
curl -sS https://example.com                        # silent but still show errors
curl -I https://example.com                # HEAD request: headers only, no body
curl -w '%{http_code}\n' -o /dev/null -s https://example.com   # print just the status code

curl: making requests with data

# GET with query parameters
curl -G --data-urlencode "q=shell scripting" https://example.com/search

# POST a JSON body
curl -X POST https://api.example.com/items \
    -H "Content-Type: application/json" \
    -d '{"name": "widget", "qty": 5}'

# send a custom header (e.g. an API token)
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/me
# fail (nonzero exit) on HTTP error codes instead of printing the error body as "success"
curl -fsS https://api.example.com/status
echo "exit code: $?"

-f is important in scripts: without it, curl exits 0 even on a 404 or 500, because as far as curl is concerned the transfer succeeded — only -f treats an HTTP error status as a curl failure.

curl: retries and timeouts

curl --retry 3 --retry-delay 2 --max-time 10 https://api.example.com/data
# --retry 3: retry up to 3 times on transient failures
# --retry-delay 2: wait 2s between retries
# --max-time 10: give up entirely after 10s total

Parsing JSON responses with jq

curl -s https://api.example.com/users/1 | jq '.name'
curl -s https://api.example.com/users | jq -r '.[] | "\(.id): \(.name)"'

jq -r outputs raw strings (no surrounding quotes) — the right choice when piping the result into further shell commands.

wget: an alternative for downloads

wget https://example.com/file.zip                 # download, keep the remote filename
wget -O out.zip https://example.com/file.zip         # download to a specific filename
wget -q https://example.com/file.zip                   # quiet mode
wget -c https://example.com/bigfile.iso                 # resume a partial download
wget --mirror --no-parent https://example.com/docs/       # mirror a whole directory tree

curl and wget overlap heavily for simple downloads; wget tends to be preferred for recursive/mirroring jobs, curl for scripting APIs (richer control over headers, methods, and request bodies).

Waiting for a service to become available

A very common script pattern — polling until a port responds, e.g. before starting a dependent process in CI or a deploy script:

wait_for_port() {
    local host="$1" port="$2" timeout="${3:-30}"
    local waited=0
    until (echo > "/dev/tcp/$host/$port") 2>/dev/null; do
        sleep 1
        waited=$((waited + 1))
        if (( waited >= timeout )); then
            echo "timed out waiting for $host:$port" >&2
            return 1
        fi
    done
    echo "$host:$port is up (after ${waited}s)"
}

wait_for_port localhost 5432 30

bash's built-in /dev/tcp pseudo-device

Bash (not sh/dash) can open raw TCP connections without curl, nc, or any external tool at all, using /dev/tcp/HOST/PORT:

# check if a port is open (no data sent)
if (echo > /dev/tcp/example.com/443) 2>/dev/null; then
    echo "port 443 is reachable"
else
    echo "port 443 is closed or unreachable"
fi
# a minimal raw HTTP GET request, no curl/wget needed
exec 3<>/dev/tcp/example.com/80
echo -e "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n" >&3
cat <&3
exec 3<&-        # close the file descriptor when done

This is useful for quick reachability checks in minimal environments, but for anything beyond a raw health check, curl is far more robust (handles TLS, redirects, compression, retries) — reach for /dev/tcp mainly when no other tool is guaranteed to be installed.

How It Actually Works

Bash's /dev/tcp/host/port isn't a real device file on disk — it's a special path bash's own I/O redirection code intercepts and recognizes syntactically, and instead of calling open(2) on a filesystem path, bash calls socket(2)/connect(2) directly to establish a TCP connection, then hands you a file descriptor that behaves like any other for read/write redirection purposes. This is entirely a bash built-in feature (compiled in optionally) — there's no actual inode or filesystem entry backing it, which is why it doesn't appear if you ls /dev/tcp.

curl/wget, by contrast, are full separate processes that build and parse entire HTTP request/response messages on top of their own socket calls — they handle TLS handshakes, redirects, and header parsing entirely in their own userspace code, whereas bash's /dev/tcp gives you nothing but a raw byte stream over a connected socket; anything above the TCP layer (like constructing valid HTTP) is something you have to write into the bytes sent over that descriptor yourself.

nc (netcat) sits in between: it's a small dedicated process whose whole job is proxying a TCP or UDP socket to its own stdin/stdout, which is why nc host port < file and nc -l port > file compose naturally with shell pipes — netcat is designed from the ground up to make a socket look, to the rest of the shell, like just another file descriptor.

Cheat sheet

Task Command
GET and print body curl https://host/path
Fail script on HTTP error curl -fsS https://host/path
POST JSON curl -X POST -H "Content-Type: application/json" -d '{...}' url
Just the HTTP status code curl -w '%{http_code}' -o /dev/null -s url
Download, resume-capable wget -c url
Extract a JSON field curl -s url \| jq -r '.field'
Check if a port is open (echo > /dev/tcp/host/port) 2>/dev/null

Exercise

Write a script wait-for-http.sh HOST PORT PATH that polls http://HOST:PORT/PATH every 2 seconds using curl -fsS, printing a dot each attempt, until it gets a successful (2xx) response or 60 seconds pass (whichever first) — then exits 0 on success or 1 on timeout. Test it against a path that becomes available only after a short delay (e.g. sleep 5 && python3 -m http.server 8000 in another terminal).