Pick a deploy target

Getting started — part 7 of 10

Set DEPLOY_BACKEND in env.sh plus the values for your choice, then:

./scripts/deploy-web.sh --dry-run   # shows what would upload, touches nothing
./scripts/deploy-web.sh             # first real deploy (uploads everything once)

Every later deploy uploads only new/changed files -- a SHA-256 manifest (.deploy_manifest*.json, one per backend) tracks what the target already has, while .deploy_baseline.json records the shape of the last build the safety guards accepted. Both are gitignored and both are disposable.

The first deploy is rarely the one you run by hand. From here on the engine deploys as part of writing: saving a draft builds the site and deploys it, because a draft's preview is a real address on the real site (see Writing → Writing and publishing). So the target is written to the first time you save anything -- not the first time you type deploy-web.sh. Whatever is already at that address under a name the build also uses is replaced, and a placeholder page is exactly such a name.

If the target still holds something you want to keep -- a "coming soon" page, an old site you have not moved yet -- do not point env.sh at it until you are ready. An unedited env.sh deploys nowhere and everything else works, so a whole site can be written, previewed locally and imported into before any address of yours is touched; set the target when the site is ready to be seen. Reported from the outside, and it cost somebody their placeholder.

The other way round, nothing is removed. A deploy writes the site's files and deletes only files an earlier deploy of this site wrote; whatever else was in the target stays. After a previous site that matters: a host typically serves index.php before index.html, so an old WordPress front page goes on answering over the new one, and an old .htaccess can go on rewriting addresses the new site depends on. Clear the old site out of the target before the first deploy -- over FTP, with the client or file manager you used for it -- and keep only what the host itself put there. (The git backend is the exception: every push replaces the whole branch.) ./blog.sh doctor --online lists what stands in the target's root that the site does not put there -- run it once the target is set, before the first deploy. What stands there on purpose (screenshots an issue links to, a file a search console asked for) goes under deploy.keep in config/site.yml, and doctor stops naming it; .well-known/ it never names.

One thing to know before you write your first post with a big attachment: a single file over 100 MB is refused, at save time and again at deploy time. The limit is the same for every backend so the site stays portable between them -- the strictest supported target refuses anything larger. See Deploying for the rest of the guards.

surfer (Cloudron Surfer -- the default)

export SURFER_URL=https://surfer.example.com
export SURFER_USERNAME=...     # your Cloudron username, not the e-mail
export SURFER_PASSWORD=...     # an app password: Cloudron profile -> App Passwords
export SURFER_REMOTE_DIR=      # optional subdirectory; empty = app root

No DEPLOY_BACKEND needed -- surfer is the default whenever SURFER_URL is set.

Surfer 7 (September 2026) signs in with the Cloudron user and an app password -- one you create for the Surfer app in your Cloudron profile, not the password you sign in to Cloudron with. The access token of Surfer 6 and earlier (SURFER_TOKEN) is still read, but Surfer 7 answers it with HTTP 401, and the deploy then stops at the first file and says so. Moving over is two lines in env.sh: add SURFER_USERNAME and SURFER_PASSWORD and leave SURFER_TOKEN where it is. They can go in before the app has updated: a Surfer older than 7 answers the password with an error (HTTP 500), and the run then goes on with the token -- every run tries the password first, so the day Surfer 7 arrives it takes over by itself. ./blog.sh doctor --online says which of the two Surfer took.

local (a directory on the same machine)

export DEPLOY_BACKEND=local
export DEPLOY_TARGET_DIR=/var/www/mysite

For a docroot served by your own nginx/Caddy, or a mounted volume. Your web server handles HTTPS (Caddy does it automatically; certbot for nginx). The engine's CSP arrives via a meta tag so no header configuration is required -- but if you can set real HTTP headers, nothing stops you from adding more.

rsync (any SSH host)

export DEPLOY_BACKEND=rsync
export RSYNC_TARGET=user@server:/var/www/mysite
export RSYNC_SSH="ssh -p 2022"   # optional, only for a non-default ssh

The most universal remote option -- works against any VPS or shared host with SSH and rsync installed.

git (GitHub / GitLab / Codeberg Pages)

export DEPLOY_BACKEND=git
export GIT_PAGES_REMOTE=git@github.com:you/yoursite.git
export GIT_PAGES_BRANCH=gh-pages       # optional, this is the default
export GIT_PAGES_CNAME=www.example.com # optional custom domain

Free hosting with HTTPS. Setup on the host's side (GitHub shown, GitLab/Codeberg analogous): create a repository, then Settings → Pages → Build and deployment → Source → "Deploy from a branch" → gh-pages. Every deploy force-pushes the build as a single-commit snapshot; a custom domain must be set via GIT_PAGES_CNAME (the host stores it as a CNAME file in the branch, which a snapshot push would otherwise wipe).

rclone (S3, R2, B2, WebDAV, FTP, ...)

export DEPLOY_BACKEND=rclone
export RCLONE_TARGET=r2:my-bucket/site
export RCLONE_ARGS="--s3-acl public-read"   # optional provider flags

Run rclone config once to set up the remote -- credentials live in rclone's own config, never in env.sh. Needs the rclone binary installed -- from your distribution's packages or from rclone.org, not as a snap. The snap is not published by the rclone project, and it is confined to your home directory: with the site anywhere else (/srv, /var/www, /opt) it cannot read a single file of it, and says so as "no such file or directory" about a file that is plainly there. ./blog.sh doctor notices the snap and says when the site is out of its reach.

A plain FTP host -- the kind a shared hosting plan gives you, with no SSH to it at all -- goes through rclone as well. In rclone config pick the type ftp and give it the host, the user and the password; then

export DEPLOY_BACKEND=rclone
export RCLONE_TARGET=myftp:public_html   # remote name : the directory the host serves

Nothing else is needed: the engine tells rclone exactly which files to send and which to delete, so it never asks the server for checksums, which FTP cannot give. If the host offers FTPS, turn it on in rclone config (explicit_tls) -- plain FTP sends the password in the clear.

If the deploy fails with the server complaining about too many connections -- shared hosting often allows only a few per account -- make rclone open one at a time:

export RCLONE_ARGS="--checkers 1 --transfers 1"

rclone also has --ftp-concurrency, but its own documentation warns that setting it is very likely to deadlock unless it is one more than --checkers and --transfers together; the two flags above are the simpler way to the same limit.

sftp (hosts with neither rsync nor git)

export DEPLOY_BACKEND=sftp
export SFTP_TARGET=user@server
export SFTP_REMOTE_DIR=/var/www/mysite   # optional; nested paths must exist
export SFTP_ARGS="-P 2022"               # optional

Uses openssh's sftp in batch mode -- one connection uploads exactly what the manifest says changed. Set up SSH key auth first; a password prompt would block the batch.