Making an rsync --delete deploy safe

Guard rails that stop a routine static-site deploy from turning into an account wipe: sentinel files, exclusion lists and deletion ceilings.

Deploying a static site means making a remote directory match a local one. The --delete flag is what makes stale files go away, and it’s also what makes the whole operation dangerous. Point it one directory too high and you wipe the account, quickly, silently and with no way back.

I use three cheap guards, and together they remove almost all of that risk.

1. A sentinel file

Put a file in the target directory by hand, once:

printf 'cs-ol.de-production\n' > "$DOCROOT/.cs-ol-docroot"

Every deploy reads it back and refuses to run unless the contents match exactly. The file is excluded from the sync, so automation never deletes it and never recreates it. If it’s there, someone put it there on purpose, in the right place.

This is the guard that catches the really bad case, where a misconfigured path variable points at a home directory or at some unrelated site.

2. An exclusion list that survives deletion

Excluded paths have to be protected on the remote as well as skipped locally. With rsync that’s the default behaviour, and that’s exactly why --delete-excluded must never be added: it removes that protection.

These are the paths I protect in a cPanel document root:

PathWhy
.well-known/ACME/AutoSSL validation and mail autoconfig. Deleting it can break the hosting account’s own TLS certificate.
cgi-bin/Created per-account by cPanel.
.ftpquotaMaintained by the FTP daemon; removing it corrupts quota accounting.
error_logWritten by the server; you want it when debugging.
.htpasswd*Directory-protection config set through the panel.
php.ini, .user.iniPer-directory PHP configuration.

3. A deletion ceiling

A normal deploy removes a handful of stale hashed assets. If it wants to delete hundreds of files, something has gone wrong. So I count the deletions in a dry run first and refuse to go on above a limit:

dels=$(rsync "${RSYNC_OPTS[@]}" --dry-run -e "$SSH_CMD" \
         dist/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DOCROOT}/" \
       | grep -c '^\*deleting' || true)
[ "$dels" -le "${MAX_DELETIONS:-200}" ] || exit 1

Scope the credential too

The strongest guard lives outside the script. A deploy credential should be able to reach the document root and nothing else. On cPanel that means a dedicated FTP account whose home directory is the docroot, instead of the master login.

The master account can read every mailbox and rewrite DNS. On a domain where DNS also drives certificate issuance for unrelated infrastructure, putting that credential into CI means one leaked build log could cost you the whole domain along with the website.

Flags I chose on purpose

I don’t use -a. It implies -og, which fails or warns on shared hosting, where you don’t control the uid mapping.

I do use --checksum, because mtimes that have made a round trip through CI aren’t reliable. Comparing content hashes is the honest check, and these payloads are small.

And I use --delete-after, so new files land before old ones are removed. That shrinks the window in which a visitor can request an asset that’s already gone.