Back to Blog

Zsh Tutorial: curl, SSH, rsync — Network Like a Pro #23

Sandy LaneSandy Lane

Video: Zsh Tutorial: curl, SSH, rsync — Network Like a Pro #23 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 23: curl, SSH, rsync — Network Like a Pro

curl url GET, -X POST -d data POST, -H "Header" custom headers, -o file save, -L follow redirects, -I headers only. ssh user@host, ~/.ssh/config for shortcuts. rsync -av src/ dst/ for fast incremental sync; scp file user@host: for one-off copies.

Three of the most-used network tools. Each does one thing well.

curl: GET request

curl -s https://httpbin.org/get | jq

curl url fetches and prints to stdout. -s suppresses progress meter; -S keeps errors visible.

Save to file

curl -s -o page.html https://example.com
curl -s -O https://example.com/file.zip    # use remote filename

-o file saves to a specific name. -O (capital) uses the URL's basename.

Follow redirects

curl -sL https://httpbin.org/redirect/2

By default, curl shows the redirect response (302) and stops. -L follows.

Headers only

curl -sI https://example.com
# HTTP/2 200
# content-type: text/html
# ...

-I issues a HEAD request. Useful for checking content-type, size, or status.

Custom headers

curl -s -H "User-Agent: MyApp/1.0" -H "Accept: application/json" https://api.example.com

Each -H adds one header. Common ones:

  • User-Agent — identify your client.
  • Accept — tell server what format you want.
  • Authorization: Bearer TOKEN — auth.
  • Content-Type: application/json — for POST bodies.

POST with JSON

curl -s -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"Alice","role":"admin"}' \
  https://httpbin.org/post | jq '.json'

-X METHOD sets the HTTP method (default GET). -d data is the body — sets Content-Type: application/x-www-form-urlencoded by default, override with -H.

For data from a file:

curl -X POST -H "Content-Type: application/json" \
  --data @payload.json \
  https://api.example.com/items

@filename reads the body from a file.

Form data (multipart)

curl -X POST \
  -F "name=Alice" \
  -F "avatar=@photo.jpg" \
  https://api.example.com/upload

-F field=value sends multipart/form-data. @filename for file uploads.

Authentication

# Basic auth
curl -u user:pass https://api.example.com

# Bearer token
curl -H "Authorization: Bearer $TOKEN" https://api.example.com

# Cookie auth
curl -b "session=abc123" https://example.com

For OAuth or complex flows, httpie (http) is friendlier; curl is the everywhere-tool.

Query parameters

curl -s "https://httpbin.org/get?search=zsh&limit=5" | jq '.args'

Build into the URL. For dynamic values, use --data-urlencode:

curl -G --data-urlencode "q=hello world" "https://api.example.com/search"

-G (capital) sends the data as query string instead of body.

Response info: -w

curl -s -o /dev/null -w "Status: %{http_code}  Time: %{time_total}s\n" https://example.com
# Status: 200  Time: 0.12s

-w writes formatted info after the request. Useful variables:

  • %{http_code} — status.
  • %{time_total} — total seconds.
  • %{size_download} — bytes received.
  • %{url_effective} — final URL after redirects.

For health checks and monitoring scripts.

Common API workflows

# Pretty-print API response
curl -s https://jsonplaceholder.typicode.com/users/1 | jq

# Just a few fields
curl -s https://jsonplaceholder.typicode.com/users/1 | jq '{name, email}'

# Check status
curl -fsS https://api.example.com/health || echo "Health check failed"

# Save with auth
curl -fsS -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/data \
  -o data.json

-f causes curl to fail (non-zero exit) on HTTP errors. Combined with -S, you get a clean error message and a usable exit code.

SSH: log in to remote machines

ssh user@host
ssh -p 2222 user@host
ssh -i ~/.ssh/special_key user@host

ssh user@host opens an interactive shell on host. -p for non-default port; -i for a specific key.

SSH config: ~/.ssh/config

For machines you connect to often:

# ~/.ssh/config
Host myserver
  HostName 192.168.1.100
  User deploy
  Port 22
  IdentityFile ~/.ssh/id_ed25519

Host *.example.com
  User alice
  IdentityFile ~/.ssh/work_key

Now:

ssh myserver
ssh prod.example.com

No flags needed — config provides them. chmod 600 ~/.ssh/config for permissions.

SSH keys

# Generate a new key
ssh-keygen -t ed25519 -C "alice@laptop"

# Copy to remote
ssh-copy-id user@host

# Test
ssh user@host    # should not prompt for password

ed25519 is the modern default — small and fast. rsa 4096 is the older alternative.

After ssh-copy-id, your public key is in the remote's ~/.ssh/authorized_keys. Subsequent SSH connections use the key, no password.

Running remote commands

ssh myserver "uptime"
ssh myserver "df -h | head"
ssh myserver "tail -f /var/log/app.log"

Pass a command as an argument; SSH runs it and exits.

For multi-line scripts:

ssh myserver << 'EOF'
  cd /var/www
  git pull
  systemctl reload nginx
EOF

The heredoc becomes the remote shell's stdin.

SSH tunnels

# Local forwarding: localhost:8080 → server:80
ssh -L 8080:localhost:80 myserver

# Remote forwarding: myserver:9000 → laptop:3000
ssh -R 9000:localhost:3000 myserver

# SOCKS proxy
ssh -D 1080 myserver

For accessing resources behind firewalls or for development. The first form lets curl localhost:8080 on your laptop reach server:80.

scp: copy files over SSH

scp file.txt user@host:/remote/path/
scp user@host:/remote/file.txt ./
scp -r folder/ user@host:/remote/path/

Like cp but over SSH. -r for directories.

For modern use, prefer rsync — handles large files better, resumes on failure, syncs incrementally.

rsync: incremental sync

rsync -av source/ destination/
rsync -av source/ user@host:/remote/path/
rsync -av user@host:/remote/path/ ./local/

-a (archive) preserves permissions, ownership, timestamps, recursion. -v verbose.

Trailing slash matters:

  • rsync -av src/ dst/ — copies contents of src into dst.
  • rsync -av src dst/ — copies src itself into dst (dst now has src/).

The first is usually what you want.

rsync flags worth knowing

-a   archive (recursive + preserve metadata)
-v   verbose
-z   compress during transfer (good for slow links)
-h   human-readable sizes
-P   show progress + allow resume (= --partial --progress)
--delete   delete files in dst not in src (DANGEROUS)
--exclude PATTERN
--dry-run  show what would be transferred

Common backup pattern:

rsync -avhz --delete --exclude='.git' --exclude='node_modules' \
  ~/projects/ \
  myserver:~/backups/projects/

A real deployment script

#!/usr/bin/env zsh
set -euo pipefail

REMOTE=myserver
APP_DIR=/var/www/myapp

# Build locally
echo "Building..."
npm run build

# Sync (excluding node_modules)
echo "Syncing to $REMOTE..."
rsync -avz --delete \
  --exclude='node_modules' --exclude='.env' \
  dist/ "$REMOTE:$APP_DIR/"

# Reload service
ssh "$REMOTE" "sudo systemctl reload myapp"

# Verify
if curl -fsS "https://example.com/health" > /dev/null; then
  echo "Deploy succeeded"
else
  echo "Health check failed!" >&2
  exit 1
fi

Standard deployment shape: build, sync, reload, verify.

Network diagnostics

ping example.com              # is it reachable?
ping -c 3 example.com         # 3 packets

nc -zv host 80                # is port 80 open?
telnet host port              # interactive

nslookup example.com          # DNS lookup
dig example.com               # detailed DNS

traceroute example.com        # route trace
mtr example.com               # interactive trace + ping (install)

netstat -an | grep LISTEN     # listening ports (Linux)
lsof -i -P -n | grep LISTEN   # listening ports (macOS)

These are your first stop when a network thing isn't working.

Common stumbles

curl https://... but https not in $PATH. The shell tried to run a command named https://.... Make sure to call curl explicitly.

Body posted but server says "missing field". Wrong Content-Type. -d defaults to form-encoded. For JSON, add -H "Content-Type: application/json".

curl exit 0 even on 404. Default exit is 0 for any HTTP response. Use -f for "fail on 4xx/5xx."

Permissions on SSH keys. chmod 600 ~/.ssh/id_* and chmod 700 ~/.ssh. SSH refuses to use keys with looser permissions.

rsync trailing slash mistake. rsync -av src dst/ vs rsync -av src/ dst/ — different. Test with --dry-run first.

--delete destroys data. Removes from dst whatever isn't in src. With a typo (wrong src), you can wipe a server. Always --dry-run first.

SSH config not picked up. Permissions issue: chmod 600 ~/.ssh/config.

Slow rsync over slow link. Add -z for compression. Watch progress with -P.

What's next

Lesson 24: cron and launchd. Schedule tasks.

Recap

curl url — GET; -X POST -d data, -H header, -o file, -L follow, -I headers, -f fail on HTTP errors, -w response info. SSH: ~/.ssh/config for shortcuts; ssh-copy-id for keys; remote command via arg or heredoc. rsync -av src/ dst/ (mind trailing slash); --delete and --dry-run. For deployment: build, sync, reload, verify. For diagnostics: ping, nc, dig, lsof.

Next lesson: cron and launchd.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.