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:
| Path | Why |
|---|---|
.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. |
.ftpquota | Maintained by the FTP daemon; removing it corrupts quota accounting. |
error_log | Written by the server; you want it when debugging. |
.htpasswd* | Directory-protection config set through the panel. |
php.ini, .user.ini | Per-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 1Scope 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.