SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

curl commands you actually need on a server

Check status codes, follow redirects, time requests, send JSON, upload, resume downloads and fail properly in scripts, all tested against a local lab server.

The curl commands that matter on a server

The curl commands you actually need on a server do a small number of jobs. They show a status code, follow a redirect, time a slow request, send JSON to an API, and make a script stop when a request fails. This guide is organised by those jobs, not by flag. Every example runs against a small lab server on 127.0.0.1 that you start yourself, so you can try each flag, break it on purpose, and read the result without touching a live site.

Check your version first, because some flags depend on it.

curl --version

The first line shows the curl version. As of November 2026, packages.ubuntu.com lists curl 7.81.0 for Ubuntu 22.04, 8.5.0 for Ubuntu 24.04 and 8.18.0 for Ubuntu 26.04. The man page on curl.se describes the newest upstream curl, not the one your distribution ships. So when a flag from that page fails on your server, compare the "Added in" line with your own version before anything else. Two flags in this guide depend on the version: --json and the long name of -i.

Start a local lab server to practise on

Python ships a file server, python3 -m http.server. The Python docs say it is not recommended for production, and by default it binds to all interfaces. More importantly, it serves files from a directory. This guide needs routes that return a chosen status code, redirect twice, set a cookie and echo a request body back. So it uses a small handler instead, bound to 127.0.0.1 only.

sudo apt update && sudo apt install -y curl python3 openssl
mkdir -p ~/curl-lab && cd ~/curl-lab
cat > lab.py <<'EOF'
#!/usr/bin/env python3
# A tiny HTTP lab for practising curl. It listens on 127.0.0.1 only.
import json, ssl, sys, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

BIG = bytes(range(256)) * 4096          # 1 MiB of test data
BASIC = 'Basic dXNlcjpzZWNyZXQ='        # user:secret
BEARER = 'Bearer lab-token-123'
HOPS = {'/old': (301, '/middle'), '/middle': (302, '/new'), '/loop': (302, '/loop')}


class Lab(BaseHTTPRequestHandler):
    def reply(self, code, body, ctype='application/json', extra=None):
        if not isinstance(body, bytes):
            body = (json.dumps(body) + '\n').encode()
        self.send_response(code)
        self.send_header('Content-Type', ctype)
        self.send_header('Content-Length', str(len(body)))
        for name, value in (extra or {}).items():
            self.send_header(name, value)
        self.end_headers()
        if self.command != 'HEAD':
            self.wfile.write(body)

    def do_GET(self):
        p, host = self.path, self.headers.get('Host', '')
        if p == '/':
            site = 'app' if host.startswith('app.example.com') else 'default'
            self.reply(200, {'site': site, 'host': host})
        elif p in HOPS:
            code, target = HOPS[p]
            self.reply(code, b'', 'text/plain', {'Location': target})
        elif p == '/new':
            self.reply(200, {'page': 'new'})
        elif p == '/slow':
            time.sleep(1)
            self.reply(200, {'slow': True})
        elif p.startswith('/status/'):
            code = int(p.split('/')[2])
            self.reply(code, {'error': code})
        elif p == '/private':
            auth = self.headers.get('Authorization', '')
            if auth in (BASIC, BEARER):
                self.reply(200, {'auth': auth.split()[0]})
            else:
                self.reply(401, {'error': 'login required'},
                           extra={'WWW-Authenticate': 'Basic realm="lab"'})
        elif p == '/login':
            self.reply(200, {'login': 'ok'},
                       extra={'Set-Cookie': 'session=abc123; Path=/'})
        elif p == '/dashboard':
            if 'session=abc123' in self.headers.get('Cookie', ''):
                self.reply(200, {'dashboard': 'welcome'})
            else:
                self.reply(401, {'error': 'no session'})
        elif p == '/files/big.bin':
            rng = self.headers.get('Range', '')
            if not (rng.startswith('bytes=') and rng.endswith('-')):
                self.reply(200, BIG, 'application/octet-stream', {'Accept-Ranges': 'bytes'})
            elif int(rng[6:-1]) >= len(BIG):
                self.reply(416, b'', 'text/plain', {'Content-Range': f'bytes */{len(BIG)}'})
            else:
                start = int(rng[6:-1])
                self.reply(206, BIG[start:], 'application/octet-stream',
                           {'Content-Range': f'bytes {start}-{len(BIG) - 1}/{len(BIG)}'})
        else:
            self.reply(404, {'error': 'not found'})

    do_HEAD = do_GET

    def do_POST(self):
        body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
        self.reply(200, {'method': self.command, 'path': self.path,
                         'content_type': self.headers.get('Content-Type'),
                         'accept': self.headers.get('Accept'),
                         'bytes': len(body),
                         'body': body[:300].decode('utf-8', 'replace')})

    do_PUT = do_POST


if __name__ == '__main__':
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
    server = ThreadingHTTPServer(('127.0.0.1', port), Lab)
    if len(sys.argv) > 3:  # python3 lab.py 8443 cert.pem key.pem
        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(sys.argv[2], sys.argv[3])
        server.socket = ctx.wrap_socket(server.socket, server_side=True)
    print(f'lab listening on 127.0.0.1:{port}', flush=True)
    server.serve_forever()
EOF
python3 lab.py 8000 > lab.log 2>&1 &
echo $! > lab.pid
sleep 1
curl -s http://127.0.0.1:8000/

The last command should print one line of JSON with "site": "default". Every request the lab answers is written to lab.log, one line per request, and you will read that file several times below. If curl instead exits with nothing printed, run echo $?. Exit code 7 is CURLE_COULDNT_CONNECT, which means nothing is listening on that port. Check that the lab is still running, and see how to check if a port is open on Linux for the tools that show which process holds a port.

How do I see the status code and headers with curl?

Three forms cover almost every case.

curl -i http://127.0.0.1:8000/
curl -I http://127.0.0.1:8000/
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/status/503

-i prints the response headers, then the body. Since curl 8.10.0 its long name is --show-headers. Before that it was --include, and the old name still works. Ubuntu 24.04's curl 8.5.0 is older than the rename, so use the short -i and it works everywhere.

The first line of that output is the status line: the protocol version, then the code. The lab answers with HTTP/1.0, because that is the default protocol_version of Python's handler. A real site behind nginx usually shows a newer version, and the differences between HTTP/1.1, HTTP/2 and HTTP/3 explain what changes on the wire. If status codes and headers are new to you, what HTTP is and how a request and response fit together covers the basics.

-I fetches the headers only. Run tail -n 1 lab.log after it. The log line contains "HEAD / HTTP/1.1", so -I sent a HEAD request, not a GET. That matters on real servers. Some applications answer HEAD differently from GET, so a clean -I result does not prove the page itself works.

The third form is the one for scripts. -s is silent mode. It hides the progress meter and also hides error messages. -o /dev/null throws the body away. -w (--write-out) prints text after the transfer, and %{http_code} is the numeric response code. The result is the number 503 and nothing else.

When you need to see everything, use -v.

curl -v http://127.0.0.1:8000/ 2>&1 | head -n 20

Lines starting with > are headers curl sent. Lines starting with < are headers curl received. Lines starting with * are curl explaining what it is doing, such as which address it connected to.

How do I follow redirects with curl and see the chain?

By default curl does not follow a redirect. It prints the redirect response and stops.

curl -sI http://127.0.0.1:8000/old
curl -sIL http://127.0.0.1:8000/old
curl -sL -o /dev/null -w '%{num_redirects} %{url_effective}\n' http://127.0.0.1:8000/old

The first command shows 301 and a Location: /middle header. -L (--location) tells curl to repeat the request at the address in Location whenever the server answers with a 3XX code. Combined with -I, curl prints one header block per hop. The lab sends /old to /middle with a 301, then /middle to /new with a 302, so you see three blocks and the last one is a 200.

curl has no single flag that prints the chain. The three tools are -IL for every hop, -v for the full conversation, and -w. In the third command, %{num_redirects} is the number of redirects curl followed, and %{url_effective} is the URL it fetched last. Here that is 2 and the /new URL.

A redirect loop does not run forever. --max-redirs sets the limit, and the default is 50.

curl -sS -L --max-redirs 5 http://127.0.0.1:8000/loop; echo "exit $?"

The lab redirects /loop to itself, so curl stops after five hops with exit code 47, CURLE_TOO_MANY_REDIRECTS. -S makes curl show the error message even though -s is set.

Two redirect rules surprise people. First, when a POST gets a 301, 302 or 303, curl sends the next request as a GET, so the body is lost. --post301, --post302 and --post303 keep the POST. Second, credentials given with -u go only to the first host. If a redirect sends curl to another host, the password is not sent there, unless you add --location-trusted. Headers you set with -H are sent on every request, but curl does not forward Authorization: or Cookie: headers to a different origin unless you use --location-trusted.

How do I time a request with curl?

-w also prints timing variables. All of them are in seconds, measured from the start of the request.

for path in / /slow; do
  curl -s -o /dev/null -w "$path namelookup %{time_namelookup} connect %{time_connect} starttransfer %{time_starttransfer} total %{time_total}\n" "http://127.0.0.1:8000$path"
done

Each variable marks one point in the request. time_namelookup is when name resolution finished. time_connect is when the TCP connection was open. time_starttransfer is when the first byte of the response arrived. time_total is when the whole operation ended.

Read the gaps between them, not the raw numbers. The gap from time_connect to time_starttransfer is the time the server took to start answering on a plain HTTP request. On /slow that gap grows by about one second, because the lab's handler sleeps for one second before it replies. Against 127.0.0.1 the other values are close to zero. The URL is an IP address, so there is no name to look up, and the connection never leaves the machine.

On a real site, a large time_namelookup points at DNS. A large gap before time_connect points at the network path. A large gap before time_starttransfer points at the application. Run the command several times before you draw a conclusion, because a single request can be slow for reasons that do not repeat.

How do I test a virtual host before DNS points at it?

A virtual host is one of several sites that share one IP address. The server picks the site from the Host header. For plain HTTP, you can set that header yourself.

curl -s -H 'Host: app.example.com' http://127.0.0.1:8000/
curl -s --resolve app.example.com:8000:127.0.0.1 http://app.example.com:8000/

Both print "site": "app". The first replaces curl's own Host header. The second uses --resolve HOST:PORT:ADDRESS, which puts that address into curl's DNS cache for that host and port only. The URL then carries the real name, so the Host header matches it on its own.

For HTTPS, --resolve is the one to use. The Host header is not enough, because TLS (transport layer security) has its own field for the site name, called SNI (server name indication). curl takes the SNI name from the URL, and it checks that the certificate is valid for the name in the URL. Create a self-signed certificate and start a second lab with TLS on port 8443 to see this.

openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 7 \
  -subj '/CN=app.example.com' -addext 'subjectAltName=DNS:app.example.com'
python3 lab.py 8443 cert.pem key.pem > tls.log 2>&1 &
echo $! > tls.pid
sleep 1
curl -sS --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/; echo "exit $?"
curl -sS --cacert cert.pem --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/; echo "exit $?"
curl -sS --cacert cert.pem -H 'Host: app.example.com' https://127.0.0.1:8443/; echo "exit $?"

The first request fails with exit code 60, CURLE_PEER_FAILED_VERIFICATION, because no trusted authority signed the certificate. The second succeeds. --cacert tells curl to verify the server with that certificate file, so the self-signed certificate is now trusted. The third fails the certificate check even with --cacert, because the URL says 127.0.0.1 and the certificate only covers app.example.com. This is the method to check a new nginx reverse proxy config and its server_name blocks before you move the DNS record.

How do I send JSON with curl?

Use --json on curl 7.82.0 or later, which includes Ubuntu 24.04 and 26.04.

curl -s --json '{"name": "web1", "port": 8080}' http://127.0.0.1:8000/api/servers
printf '{"name": "web2"}\n' > server.json
curl -s --json @server.json http://127.0.0.1:8000/api/servers
printf '{"name": "web3"}\n' | curl -s --json @- http://127.0.0.1:8000/api/servers

The lab echoes the request back. Each reply shows "method": "POST", a content_type of application/json and an accept of application/json. --json is a shortcut. It sends the data as-is, like --data-binary, and sets both headers. @file reads the body from a file, and @- reads it from standard input. curl does not check that the data is valid JSON, so a typo reaches the server unchanged.

Ubuntu 22.04 ships curl 7.81.0, one release before --json was added, so there you need -d and a header.

curl -s -d '{"name": "web1"}' http://127.0.0.1:8000/api/servers
curl -s -H 'Content-Type: application/json' -d '{"name": "web1"}' http://127.0.0.1:8000/api/servers

Compare the two replies. Without the header, content_type is application/x-www-form-urlencoded, because that is what -d sends by default. Many APIs reject that body, or parse it as an empty form. One more trap: -d @file strips carriage returns, newlines and null bytes from the file. For JSON that is usually harmless. For any other file, use --data-binary @file, which sends the bytes unchanged.

How do I send a username, password or API token?

curl -s http://127.0.0.1:8000/private
curl -s -u user:secret http://127.0.0.1:8000/private
curl -sv -u user:secret http://127.0.0.1:8000/private 2>&1 | grep Authorization

The first request gets a 401 with login required. The second succeeds with "auth": "Basic". -u sends the credentials, and curl splits them at the first colon. Basic is curl's default method. The third command shows what was sent: an Authorization: Basic header holding the user name and password in Base64. Base64 is an encoding, not encryption, so anyone who can read the traffic can read the password. Use Basic authentication over HTTPS only.

Typing a password on the command line is a risk on a shared server. curl hides the argument from process listings on systems where that works, but it is visible for a moment first. It also stays in your shell history. Give only the user name, as in -u user, and curl asks for the password instead.

Most APIs want a bearer token in a header.

curl -s -H 'Authorization: Bearer lab-token-123' http://127.0.0.1:8000/private
printf 'Authorization: Bearer lab-token-123\n' > token.hdr
chmod 600 token.hdr
curl -s -H @token.hdr http://127.0.0.1:8000/private

Both succeed with "auth": "Bearer". The second form keeps the token off the command line. -H @file (curl 7.55.0 and later) adds one header for each line of the file, so the token lives in a file only you can read. curl also has --oauth2-bearer, but it takes the token as an argument, which puts it back on the command line.

How do I upload a file with curl?

There are two upload styles, and the server decides which one it expects.

printf 'disk check ok\n' > notes.txt
curl -s -F 'file=@notes.txt' http://127.0.0.1:8000/upload
curl -s -F 'file=@notes.txt;type=text/plain' -F 'host=web1' http://127.0.0.1:8000/upload
curl -s -T notes.txt http://127.0.0.1:8000/upload/

-F imitates an HTML form where someone pressed the submit button. It sends a POST with multipart/form-data, and the echoed content_type shows that type plus a boundary string. -F name=@file attaches a file. ;type= sets the content type of that part. -F 'name=<file' sends the file's contents as an ordinary text field instead of an attachment.

-T (--upload-file) uploads the raw file. With an HTTP URL it uses PUT, so the echo shows "method": "PUT". When the URL ends without a file name, curl appends the local file name, and the echo shows the path /upload/notes.txt. That only works when the URL ends with a slash. Without the slash, curl treats upload as the file name.

How do I download a file and resume a broken download?

curl -s -o copy.bin http://127.0.0.1:8000/files/big.bin
curl -sO http://127.0.0.1:8000/files/big.bin
ls -l copy.bin big.bin

-o writes the output to the file you name. -O names the file after the last part of the URL, here big.bin, and saves it in the current directory. It overwrites a file of the same name without asking. Give -O a URL that ends in a file name. What curl does with a URL ending in a slash changed in 8.10.0. Older versions returned an error, and newer ones use the last directory name.

To see a resume, interrupt a download on purpose. --limit-rate slows the transfer, and --max-time stops it.

rm -f big.bin
curl -s --limit-rate 200k --max-time 2 -o big.bin http://127.0.0.1:8000/files/big.bin; echo "exit $?"
ls -l big.bin
curl -s -C - -o big.bin http://127.0.0.1:8000/files/big.bin; echo "exit $?"
sha256sum big.bin copy.bin
tail -n 2 lab.log

The first download stops with exit code 28, CURLE_OPERATION_TIMEDOUT, and leaves a partial file behind. -C - tells curl to work out the resume point from the size of the existing output file and ask only for the missing bytes. The last line of lab.log shows status 206, which is the code for a partial response. The two checksums must match. If they differ, the file is damaged, and verifying downloads with checksums explains how to check a download against the hash its publisher gives. Resume only works for downloads. It does not work for POST or PUT uploads, and it cannot be combined with --range.

The same post matters for one install pattern you will see often: curl ... | sh. That pipe runs whatever the server sends, immediately, with your permissions. If the download is cut short or the server is compromised, you run that too. Download the script to a file, read it, check its checksum, and then run it.

How do I make curl retry a request that fails?

Retries are off by default. --retry N turns them on for transient errors: timeouts, and the HTTP codes 408, 429, 500, 502, 503, 504, 522 and 524. The first wait is one second, and each wait doubles, up to 10 minutes. --retry-delay sets a fixed wait instead. If the server sends a Retry-After header, curl waits as long as it asks.

curl -sS -f --retry 3 http://127.0.0.1:8000/status/503; echo "exit $?"
grep -c 'status/503' lab.log

This takes about seven seconds, because curl waits one, then two, then four seconds between attempts. The log then shows four requests for /status/503: the first try and three retries. The final exit code is 22, from -f, which the next section explains.

A refused connection is not a transient error, so --retry alone gives up at once.

curl -sS --retry 3 http://127.0.0.1:8001/; echo "exit $?"
curl -sS --retry 3 --retry-connrefused http://127.0.0.1:8001/; echo "exit $?"

Nothing listens on port 8001. The first command exits with 7 immediately. The second waits through its three retries first, because --retry-connrefused (curl 7.52.0 and later) counts a refused connection as transient. This is the flag you want when a script waits for a service that is still starting.

--retry-all-errors (curl 7.71.0 and later) retries on any error. curl's own docs call it the sledgehammer of retrying and say not to use it by default. It also retries 4xx codes only when combined with --fail. Do not use it while writing output through a pipe or a > redirect, because curl cannot take back data it already wrote, and a retry can leave duplicate data in the output.

How do I make curl fail properly in a script?

By default, curl does not treat an HTTP error code as a failure. A 404 or a 500 is a completed transfer, so the exit code is 0.

curl -s http://127.0.0.1:8000/status/404; echo "exit $?"
curl -sS -f http://127.0.0.1:8000/status/404; echo "exit $?"
curl -sS --fail-with-body http://127.0.0.1:8000/status/404; echo "exit $?"

The first prints the error body and exits 0, so a script carries on as if everything worked. -f (--fail) exits with code 22 and prints no body for any code of 400 or more. --fail-with-body (curl 7.76.0 and later) also exits with 22, but keeps the body, which often says why the request failed. You cannot use -f and --fail-with-body together. The curl docs also warn that -f is not fail-safe. Some failed responses slip through, especially with authentication codes 401 and 407.

A safe default for scripts combines four flags.

if ! body=$(curl -sS --fail-with-body --max-time 10 http://127.0.0.1:8000/status/500); then
  echo "request failed: $body" >&2
fi

-s hides the progress meter. -S brings back the error messages that -s hides. --fail-with-body turns an HTTP error into a non-zero exit code. --max-time sets the most time, in seconds, that the transfer may take, so a server that hangs cannot hang your script.

How do I keep a login session with cookies?

curl -s http://127.0.0.1:8000/dashboard
curl -s -c jar.txt http://127.0.0.1:8000/login
cat jar.txt
curl -s -b jar.txt http://127.0.0.1:8000/dashboard
curl -s -b 'session=abc123' http://127.0.0.1:8000/dashboard

The first request gets no session. -c jar.txt writes every cookie curl received to that file when the request ends, and cat shows the session cookie the lab set. -b works in two ways. If its argument contains =, curl sends it as a cookie. Otherwise curl reads cookies from that file, so the last two requests both reach the dashboard. An empty argument, -b "", turns on the cookie engine without loading any cookies, which is useful when one curl command follows several redirects that set cookies. The jar holds live session tokens, so treat it like a password file and delete it when you are done.

Why must curl -k never stay in a script?

-k (--insecure) makes curl skip certificate verification and continue. Try it on the TLS lab.

curl -sk --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/; echo "exit $?"

It succeeds, but curl no longer knows who it is talking to. Anyone who can answer at that address can read the request, including any password or token in it, and send back whatever they like. The curl docs also say that with -k, curl can trust and store HSTS and Alt-Svc information from a malicious server, so the damage can outlast the one request. -k is fine for one manual test against a box you control. In a script, it turns a loud failure into a silent one. When a server uses a private or self-signed certificate, pass that certificate with --cacert, as shown above, so verification stays on.

Stop the lab

cd ~/curl-lab
kill "$(cat lab.pid)" "$(cat tls.pid)"
cd ~ && rm -rf ~/curl-lab

The directory holds a private key, a cookie jar and a token file. Delete it rather than leaving it behind.

FAQ

Why does curl return exit code 0 for a 404 or 500 response?

By default curl does not treat HTTP error codes as failures. The server answered, so the transfer completed. Add -f to exit with code 22 and print no body for any code of 400 or more, or --fail-with-body (curl 7.76.0 and later) to exit with 22 and still keep the error body. -f is not fail-safe for authentication codes 401 and 407, so check those cases yourself.

Why does curl say the --json option is unknown?

--json was added in curl 7.82.0. Ubuntu 22.04 ships curl 7.81.0, so it does not have the option. Use -d with an explicit header instead, as in curl -H 'Content-Type: application/json' -d '{"name": "web1"}' URL. Without the header, -d sends application/x-www-form-urlencoded, which many APIs reject.

What is the difference between curl -I and curl -i?

-I fetches the headers only, by sending a HEAD request, so no body comes back. -i sends the normal request and prints the response headers before the body. Some applications answer HEAD differently from GET, so when -I and the browser disagree, check the real request with -i or -v.

How do I see only the HTTP status code with curl?

Run curl -s -o /dev/null -w '%{http_code}\n' URL. -s hides the progress meter, -o /dev/null discards the body, and -w '%{http_code}' prints the numeric response code after the transfer. Add -L if you want the code of the final page after redirects instead of the first response.

Why does curl -H 'Host:' not work for an HTTPS site?

For HTTPS, the site name also travels in the TLS handshake as SNI (server name indication), and curl takes that name from the URL, not from your Host header. curl also checks the certificate against the name in the URL. Use --resolve name:443:address https://name/ instead, which points the real name at the address you choose while SNI, the Host header and the certificate check all stay correct.

Was this article useful?