The build doesn't know where it's going
Somebody builds a whole site on a laptop, imports a decade of old posts into it, previews every page — and none of it has gone anywhere yet. Nothing in env.sh says where, and nothing else is waiting on that: the build runs, the archive checks out, every page renders at localhost. The build doesn't know where the site is supposed to end up, and it doesn't need to.
The same build can start on a laptop, move to a rented VPS a year later, and land on a Cloudron instance after that. Nothing in the content pipeline changes between any of those moves. What changes is the answer to one question — where should the files go — and six things can answer it.
What decides it
DEPLOY_BACKEND in env.sh names the target; the values under it depend on which one. Everything past that line is identical across all six. Each backend keeps its own manifest — the SHA-256, size and modification time of every file it has already uploaded — so a deploy only ever sends what changed since the last one. deploy-web.sh --dry-run reads that manifest without touching anything.
Here's what that prints, run just now against this site's own backend:
$ ./scripts/deploy-web.sh --dry-run
== deploy-web.sh ==
Mode: preview (dry-run) -- nothing is actually uploaded.
Deploy web -> Surfer: https://blogsh.app [DRY-RUN]
Files selected: 194, 33.3 MB in the build, new or changed: 0, unchanged (skipped): 194
Nothing had changed since the last real deploy, so the manifest says so and stops there — the same sentence a git or rclone target would print, with a different line above it saying which one answered.
The manifest is per backend, though, so point the same build at a different target for the first time and that first deploy uploads everything — moving is one full upload, not a redo of anything the build did.
The strictest one sets the rule
A single file over 100 MB is refused at save time and again at deploy time, and the limit is the same number on all six backends — not because every target shares that ceiling, but because the engine picks the strictest one and holds everyone to it. A site built to run on rsync stays deployable on sftp without anyone finding out the hard way which backend was the forgiving one.
That's the tell that this isn't six uploaders sitting next to each other: it's one deploy model — one manifest shape, one safety guard, one file-size limit — with six ways to reach the far end. The backends differ in how the bytes travel; what's allowed to travel isn't theirs to decide.
The six
surfer is the default — set SURFER_URL and SURFER_TOKEN and nothing else needs saying. It's Cloudron Surfer, the host this project itself runs on.
local writes straight into a directory, for a docroot behind your own nginx or Caddy. HTTPS is the web server's job either way; the engine's CSP arrives as a meta tag, so there's nothing to configure on that front even without one.
rsync goes to any SSH host with rsync installed — the most universal of the six, since that describes most VPSes and a good share of shared hosting too.
git pushes to GitHub, GitLab or Codeberg Pages, force-pushing the whole build as a single commit on every deploy. A custom domain needs GIT_PAGES_CNAME set, because the host keeps that address in a file living inside the very branch the snapshot overwrites.
rclone reaches S3, R2, B2, WebDAV and whatever else rclone config knows how to talk to, with the bucket's own credentials kept in rclone's config rather than in env.sh.
sftp is for the hosts with neither rsync nor git: plain SSH, batch-mode sftp, one connection per deploy. Key auth has to be set up first — a batch job has no terminal to answer a password prompt, and the connection fails rather than waiting for one that isn't coming.
Which of those six a site runs on is a single line in env.sh, picked once and rarely revisited — because the harder half of the problem was never the backend. It was making sure it wouldn't matter which one got picked.
