How to push a static site (or any build output) to four free hosts — Surge, Vercel, Netlify, and Cloudflare Pages — from one script, on a timer, with zero babysitting. Every command here is one I actually run.
A deploy is just: take a folder of files → authenticate → push to a host → get a URL. The hard part is never the push; it's the four small differences between hosts that waste your afternoon. This playbook removes all four. After it, deployment is a single command you forget about.
The loop we run daily:
dist/).200.Best for: a fast, permanent *.surge.sh URL with almost no config.
# install (once)
npm install -g surge
# deploy a folder to a domain
surge --project ./dist --domain freestack-autowork.surge.sh
# non-interactive (CI / scheduled): use a token
surge --project ./dist --domain freestack-autowork.surge.sh \
--token "$SURGE_TOKEN"
~/.netrc. For automation, generate a token in the Surge dashboard and pass --token. Don't store it in the repo — put it in an env var or a secret file outside version control.
Best for: a clean URL, automatic HTTPS, and great defaults. Free tier covers most static sites.
# install
npm install -g vercel
# first time: link the project (interactive, once)
vercel link --yes
# deploy to production, non-interactive
vercel --prod --token "$VERCEL_TOKEN" --confirm --name freestack
vercel --prod without --confirm may prompt. In a script always pass --confirm. If the CLI is invoked from a sub-shell where npx can't find it, call the local binary by absolute path instead.
Best for: fine control via netlify.toml and a stable site id.
# install
npm install -g netlify-cli
# deploy (needs site id + auth token from env)
netlify deploy --prod \
--dir=dist \
--auth="$NETLIFY_TOKEN" \
--site="$NETLIFY_SITE_ID"
netlify-cli cache (~/.npm/_npx) can throw an EBUSY lock. Clear it once: rm -rf ~/.npm/_npx. Then retry. Also: the first deploy may need the site created in the dashboard so NETLIFY_SITE_ID exists.
Best for: a fast, global CDN and a clean pages.dev URL.
# install
npm install -g wrangler
# WRANGLER 4.x: the project is NOT auto-created on deploy.
# Create it once first:
wrangler pages project create freestack --production-branch=main
# then deploy
wrangler pages deploy dist \
--project-name=freestack \
--branch=main \
--token="$CLOUDFLARE_TOKEN"
pages project create fails with a "project not found" error. Create-then-deploy. This is the one step the other three hosts don't require.
A minimal skeleton (pseudo-Python, the real one is in the companion code):
def deploy(target, token):
cmd = BUILD[target] # the command lists above
# ALWAYS capture as UTF-8 — see section 7
out = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
env={**os.environ, "TOKEN": token})
return out.returncode == 0 and "error" not in out.stderr.lower()
Each target is wrapped in try/except so one failure (e.g. a forgotten token) never blocks the others. The scheduler calls this once per day.
On Windows, subprocess capture defaults to the system encoding (often GBK). Any non-ASCII char in output (a checkmark, Chinese, an emoji) throws UnicodeDecodeError and kills the run at 3am. Fix: force UTF-8 everywhere and reconfigure stdout.
import sys
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
subprocess.run(cmd, encoding="utf-8", errors="replace", ...)
When a host or API is reachable only through a proxy, requests for an https:// URL needs proxies["https"] — setting only proxies["http"] silently falls back to a direct connection and times out. Set both.
proxies = {"http": "http://127.0.0.1:7890",
"https": "http://127.0.0.1:7890"}
If your product page needs a cover image fetched by a remote service, that image must already be live on a public URL before you ask the service to fetch it. Deploy first, then attach. Attaching to a not-yet-public URL fails silently.
A document from the FreeStack autonomous publishing line. Plain, tested, no upsell. Grab the free Deployment Readiness Checklist on the store — print it, run it once, never debug deploy again.