How to Manage Config Files Across Servers With rsync
Keep config files in sync across servers with rsync from a repository: per-host trees, dry runs, sudo, validation before reload and backups.

Config drift happens when a server gets a hand-edit that nobody recorded, and the next rebuild loses it. The cure is a single source of truth, and for a handful of servers rsync from a repository is enough. This post gives the push script with the safety rails. It is the step before a tool like Ansible, and builds on rsync deploys. The commands assume rsync 3.3 and nginx 1.26.
Lay out the repository
config/
common/etc/chrony/chrony.conf
common/etc/ssh/sshd_config.d/10-hardening.conf
web/etc/nginx/conf.d/site.conf
db/etc/postgresql/17/main/conf.d/tuning.conf
Each top-level directory mirrors the filesystem root for a role, so the path in the repository is the path on the server. Per-host differences get their own directory, named by host or role, and are applied after the common one. Secrets stay out; they come from a secrets store, as in SOPS and age.
Preview, then push
#!/usr/bin/env bash
set -euo pipefail
host=$1; role=$2; mode=${3:-dry}
opts=(-rlptvh --itemize-changes --backup --suffix=".bak-$(date +%Y%m%d%H%M)"
--rsync-path="sudo rsync" --chown=root:root)
[ "$mode" = dry ] && opts+=(--dry-run)
for tree in common "$role"; do
rsync "${opts[@]}" "config/$tree/" "$host:/"
done./push.sh web1 websending incremental file list
>f.st...... etc/nginx/conf.d/site.conf
The itemized code >f.st...... says a file will be sent, with size and time changed. Read the list before the real run: ./push.sh web1 web apply. The trailing slash on the source means "the contents of this directory" and is essential. --rsync-path="sudo rsync" starts rsync with sudo on the far side, which needs a sudoers rule limited to that command and passwordless auth for the deploy user. --backup --suffix keeps the replaced file beside the new one.
Validate before reload
ssh web1 'sudo nginx -t && sudo systemctl reload nginx' || {
echo "config test failed; restoring" >&2
ssh web1 'sudo find /etc/nginx -name "*.bak-*" -newer /etc/nginx/nginx.conf -print'
exit 1
}Pushing the file is not applying it. The service's own check (nginx -t, sshd -t, visudo -c) catches a syntax error before it takes the site down. Always test a new sshd config before reloading and keep an existing session open while you try a fresh login. For a more careful rollback, restore from the .bak files or from git.
About --delete
--delete removes files on the target that are not in the source, which gives true convergence and also deletes anything a person or a package put there. Use it only on directories you wholly manage, such as /etc/nginx/conf.d/, never on /etc. Run it first with --dry-run and read the *deleting lines.
Where this does not apply
Rsync copies files; it does not install packages, manage services or know about ordering. Once you need that, or more than a few servers, configuration management is the better fit. Files that vary by value, such as an IP address inside a config, need templating, which rsync cannot do. For ad hoc commands on many servers, see running a command on many servers.
What rsync decides and how it decides it
By default rsync compares the size and modification time of each file and transfers only those that differ, using a delta algorithm that sends the changed parts of large files. That makes repeated pushes cheap, and it is also a weakness for deploys: a file whose contents changed but whose size and time did not will be skipped, and a build system that sets all file times to a fixed value, for reproducible builds, makes every changed file of equal size look unchanged. The --checksum option compares content hashes instead, at the cost of reading every file on both sides. Know which behavior you have, and choose on purpose. Also decide on the permission and ownership options. The archive flag -a preserves permissions, times, symlinks and, as root, ownership, and a deploy as a non-root user cannot set ownership, so add --no-owner --no-group to avoid noisy errors.
A trailing slash on the source changes the meaning. src/ copies the contents into the destination, while src copies the directory itself into it. Getting that wrong creates a nested directory and a broken site, which is why the preview step matters.
Excluding what must not travel
A deploy pushes the built site and not the repository, the editor files or the local configuration. Use --exclude patterns or an --exclude-from file to leave out .git, environment files, caches and anything that lives only on the server, such as uploaded content and generated sockets. Without that, --delete will remove server-side data that the source does not contain, which is the classic way a deploy erases user uploads. Protect such paths with --filter 'P uploads/', a protect rule that stops deletion without stopping transfer, and keep the rules in a file under version control so the deploy behaves the same for everyone. Check the effective list by running with --dry-run --itemize-changes, which prints one line per file with a code for what would change.
Secrets need special care. Never deploy a file containing credentials from the repository. Keep them on the server, outside the synced tree, and link them in.
Safer cutovers and failure handling
Plain rsync into the live directory updates files one at a time, so for a moment visitors can get a mix of old and new files, such as a page that references an asset that has not arrived. For anything with several interdependent files, sync into a new release directory and switch a symlink, as the atomic deploy post describes. The switch is a single rename, and rollback is pointing the link back. Use --partial for large files over unreliable links, so an interrupted transfer resumes, and check the exit code, since rsync returns nonzero values for partial transfers, such as 23 and 24, that a script must treat as failure or as a warning on purpose. Run the deploy under a lock to prevent two overlapping pushes, and validate the configuration on the server before reloading, as in the earlier section.
Verification after the push
A deploy is finished when the site works, not when rsync exits. After the sync, test the configuration on the server with the service's own check, reload the service, and request a few important URLs from the script, checking the status code and a string you expect in the page. Compare a version file or commit hash served by the site against the one you meant to deploy. If a check fails, switch back to the previous release automatically, or at least print the exact command to do so. Keep the last few releases on the server to make that cheap, and delete older ones at the end of each deploy. Log every deploy with who, what and when. Use SSH keys limited to the deploy command with a forced command in authorized_keys, so a stolen key can do little more than deploy.
See the rsync manual and sudoers(5).


