
A large upload that fails at 90% and restarts from zero wastes a day of bandwidth. The fix is to checkpoint at the protocol level and resume from the last confirmed byte. This guide sets that up on a hosted Mac for three common cases: AWS S3 (native multipart), general cloud storage via rclone, and a direct HTTP endpoint driven by a checkpoint script.
For a pure network-bound push — video exports, archives, dataset snapshots that are already prepared — Figment's $29/month virtual Intel macOS environment (2 vCPU, 8 GiB RAM, 80 GiB disk) is the useful starting point. It is a real macOS environment, not an SSH tunnel to another machine, and it runs compatible Intel Mac toolchains including supported Xcode 26 or earlier. Access is through a browser console; the AWS CLI, rclone, and curl all install with Homebrew the same as on a laptop. See the current plan listing on Figment Mac Hosting.
Pick a larger tier when the Mac itself is doing work before the upload:
The $29 plan is billed monthly in advance; a two-week transfer still pays one month. Size the plan around the longest job you expect to run, not the shortest.
Pin these down on paper before you write any shell:
| Variable | Purpose |
|---|---|
SOURCE_PATH | Local file or directory to upload |
DEST_URI | Target: s3://bucket/key, gs://bucket/path, or an HTTPS URL |
PART_SIZE_MB | Multipart chunk size; 64 MB is a good default on fast links |
CHECKPOINT_FILE | Records the last successfully uploaded part number |
| Cloud credentials | AWS credentials file, GCP service-account JSON, or an API token |
Keep credentials in the macOS Keychain or in your CI secret manager. If you shell them out, write ~/.aws/credentials with chmod 600 or inject AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY at job start from the secret store — do not dump the whole environment to a file and do not paste secret literals into shell history.
Run these before you start a long transfer:
Pre-upload
aws s3 ls s3://your-bucket/ returns without error.touch "$CHECKPOINT_FILE".s3api by hand, the upload ID from create-multipart-upload is saved.After an interruption
aws s3api list-multipart-uploads --bucket your-bucket.aws s3api list-parts --bucket your-bucket --key your-key --upload-id YOUR_UPLOAD_ID.s3api upload-part + complete-multipart-upload, or start again with aws s3 cp (it will open a new multipart upload).aws s3 ls s3://bucket/key against wc -c < source-file.aws s3api abort-multipart-upload.The AWS CLI's high-level aws s3 commands automatically perform a multipart upload for large objects. Per the AWS CLI User Guide, "you can't resume a failed upload when using these aws s3 commands"; a re-run opens a new multipart upload rather than continuing the previous one. For true resumption of a specific upload ID, drive s3api directly.
Set chunk size and parallelism in ~/.aws/config:
[default]
s3 =
multipart_threshold = 64MB
multipart_chunksize = 64MB
max_concurrent_requests = 4
Then upload:
aws s3 cp /path/to/large-file.zip s3://your-bucket/backups/large-file.zip \
--no-progress \
2>> ~/Library/Logs/upload.log
If it fails, re-run — or, if you need to pick up exactly where the previous attempt stopped, list the parts with aws s3api list-parts and upload only the missing part numbers against the same upload ID, then call complete-multipart-upload.
rclone supports dozens of backends and exposes explicit retry controls. The rclone global flags include --retries (high-level operation retries), --retries-sleep (duration between retries), and --low-level-retries (individual HTTP request retries). The S3 backend's chunk size flag is --s3-chunk-size. rclone does not resume an in-progress multipart upload at a byte offset; what it does well is retry transfers and, for directory copies, skip files already present at the destination with matching size and mtime.
rclone copy /path/to/large-file.zip remote:your-bucket/backups/ \
--s3-chunk-size 64M \
--retries 5 \
--retries-sleep 30s \
--low-level-retries 10 \
--progress \
2>> ~/Library/Logs/rclone-upload.log
Run rclone help flags on your installed version before trusting a script you copied — flag names and defaults do move between releases.
When the destination is your own HTTP API that accepts chunked PUTs, keep the last confirmed part number on disk and resume from it. The script stops immediately on the first failure so a failed job is never counted as a completed one.
#!/usr/bin/env bash
set -euo pipefail
SOURCE="${1:?Usage: upload.sh <file> <url>}"
DEST_URL="${2:?}"
PART_SIZE=$(( 64 * 1024 * 1024 )) # 64 MB
CHECKPOINT="${SOURCE}.upload_state"
LOG="${HOME}/Library/Logs/upload.log"
TOTAL=$(wc -c < "$SOURCE" | tr -d ' ')
PARTS=$(( (TOTAL + PART_SIZE - 1) / PART_SIZE ))
LAST_PART=0
[[ -f "$CHECKPOINT" ]] && LAST_PART=$(cat "$CHECKPOINT")
echo "$(date -u +%FT%TZ) start $SOURCE bytes=$TOTAL parts=$PARTS resume_from=$LAST_PART" >> "$LOG"
for (( part=LAST_PART; part<PARTS; part++ )); do
OFFSET=$(( part * PART_SIZE ))
HTTP_CODE=$(curl -sS -o /dev/null -w "%{http_code}" \
-X PUT "${DEST_URL}/part?n=${part}&offset=${OFFSET}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@-" \
< <(dd if="$SOURCE" bs="$PART_SIZE" skip="$part" count=1 2>/dev/null))
if [[ "$HTTP_CODE" =~ ^2 ]]; then
echo "$((part + 1))" > "$CHECKPOINT"
echo "$(date -u +%FT%TZ) part $part ok ($HTTP_CODE)" >> "$LOG"
else
echo "$(date -u +%FT%TZ) part $part FAILED ($HTTP_CODE) stopping" >> "$LOG"
exit 1
fi
done
rm -f "$CHECKPOINT"
echo "$(date -u +%FT%TZ) complete $SOURCE" >> "$LOG"
Adapt the URL, headers, and completion call to your API. The checkpoint file persists across runs on the same host, so a re-run picks up at the last confirmed part. Note the dd form uses bs=$PART_SIZE skip=$part count=1 — this is orders of magnitude faster than bs=1 with a large skip.
Say the source is a 42 GB archive and the link holds 200 Mbit/s steady, and you set PART_SIZE = 64 MB.
ceil(42 * 1024 / 64) = ceil(672) = 672 parts.42 * 1024 * 8 / 200 = 1720 s ≈ 28.7 min (ignoring TLS and per-request overhead).That ratio is the whole reason multipart exists. The $29 VM is fine for the push itself; your bottleneck will be the link, the API's per-part rate limits, and the destination's part-size minimums (S3 requires 5 MB minimum per part except the last).
For nightly exports, drop a LaunchAgent plist in ~/Library/LaunchAgents/com.example.nightly-upload.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.nightly-upload</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/agent/bin/upload.sh</string>
<string>/Users/agent/exports/nightly.zip</string>
<string>https://api.example.com/uploads/nightly</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/Users/agent/Library/Logs/nightly-upload.log</string>
<key>StandardErrorPath</key>
<string>/Users/agent/Library/Logs/nightly-upload-error.log</string>
</dict>
</plist>
Load it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.nightly-upload.plist. See Apple's Creating launchd Jobs for StartCalendarInterval semantics; consult the launchd.plist man page on the target macOS version for the current behavior around sleep and missed intervals rather than assuming it.
To start fresh: open Mac Hosting, pick the $29 virtual Intel macOS plan (or a larger tier if the Mac also packages the payload), open the browser console, install the AWS CLI or rclone via Homebrew, load credentials from your secret manager, and test a 1 GB transfer before you point the real job at it.
To migrate from another host: copy the upload script and the launchd plist, re-register credentials on the new host, run a small test upload end to end, then switch the cron/launchd source and point your producers at the new destination.
Related guides: