Root-owned deployment trees should not become writable by the account used for file transfer. A restricted remote receiver lets rsync update one approved directory while the login account remains unable to run arbitrary commands as root.
The rrsync helper validates server-mode requests and anchors every accepted path under a fixed directory. A root-owned wrapper supplies the remote command to rrsync after rejecting unexpected argument characters, while write-only mode, deletion blocking, and symlink munging narrow what the receiver can do.
Use a dedicated SSH key for the deployment account and prepare the protected directory before applying the elevation rule. The client addresses the receiver's restricted root rather than the server filesystem root, and both traversal outside that root and direct sudo access to the rsync binary must fail.
Steps to copy files to a sudo-protected path with rsync:
- Confirm that rrsync is installed on the remote server.
$ ssh deploy@server.example.com 'command -v rrsync' /usr/bin/rrsync
The wrapper below uses this exact path. Adjust /usr/bin/rrsync in the wrapper only when the remote package installs it elsewhere.
- Check the protected destination before granting elevated access.
$ ssh deploy@server.example.com 'stat -c "%A %U:%G %n" /srv/www/app' drwxr-xr-x root:root /srv/www/app
The deploy account has no write bit on this root-owned directory.
- Create the receiver wrapper as an administrator on the remote server.
- /usr/local/sbin/rsync-deploy
#!/usr/bin/python3 import os import re import sys RESTRICTED_DIR = "/srv/www/app" SAFE_ARGUMENT = re.compile(r"^[A-Za-z0-9_.,=+:/@%*~-]+$") def fail(message: str) -> None: print(f"rsync-deploy: {message}", file=sys.stderr) raise SystemExit(64) remote_args = sys.argv[1:] if os.geteuid() != 0: fail("must run through sudo") if not remote_args or remote_args[0] != "--server": fail("receiver mode is required") if any(not SAFE_ARGUMENT.fullmatch(arg) for arg in remote_args): fail("unsupported remote argument") os.environ["SSH_ORIGINAL_COMMAND"] = "rsync " + " ".join(remote_args) os.execv( "/usr/bin/rrsync", ["rrsync", "-wo", "-no-del", "-munge", RESTRICTED_DIR], )
Keep this wrapper owned by root and non-writable by deploy. A deployment account that can replace the wrapper can replace the program that sudo runs as root.
- Make the wrapper executable without granting write access to the deployment account.
$ sudo chmod 0755 /usr/local/sbin/rsync-deploy
- Grant passwordless sudo access to the wrapper only.
- /etc/sudoers.d/rsync-deploy
deploy ALL=(root) NOPASSWD: /usr/local/sbin/rsync-deploy
Do not grant deploy passwordless access to /usr/bin/rsync. The wrapper and rrsync path checks are the boundary that prevents an arbitrary root receiver.
- Validate the sudoers file before opening a new deployment session.
$ sudo visudo -cf /etc/sudoers.d/rsync-deploy /etc/sudoers.d/rsync-deploy: parsed OK
- Confirm that the deployment account cannot start /usr/bin/rsync directly as root.
$ ssh deploy@server.example.com 'sudo -n /usr/bin/rsync --version' sudo: a password is required
The nonzero result is expected. Only /usr/local/sbin/rsync-deploy appears in the passwordless rule.
- Test that rrsync rejects a destination outside the allowed subtree.
$ ssh deploy@server.example.com 'sudo -n /usr/local/sbin/rsync-deploy --server . /../forbidden/' /usr/bin/rrsync error: do not use .. in arg (anchor the path at the root of your restricted dir)
The deliberate traversal request must fail before a receiver starts.
- Preview the release through the restricted receiver.
$ rsync --archive --dry-run --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/ .d..t...... ./ <f+++++++++ app.txt cd+++++++++ assets/ <f+++++++++ assets/index.html
The remote slash is the root of the rrsync restriction, not the server filesystem root. Review every itemized path before removing --dry-run.
Related: How to preview rsync changes before syncing - Copy the reviewed release into the protected directory.
$ rsync --archive --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/ .d..t...... ./ <f+++++++++ app.txt cd+++++++++ assets/ <f+++++++++ assets/index.html
- Verify the receiver ownership on the transferred files.
$ ssh deploy@server.example.com 'stat -c "%U:%G %n" /srv/www/app/app.txt /srv/www/app/assets/index.html' root:root /srv/www/app/app.txt root:root /srv/www/app/assets/index.html
- Repeat the transfer as a checksum dry run through the same restricted receiver.
$ rsync --archive --checksum --dry-run --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/
No output means rsync found no content or requested metadata changes, while the wrapper still confines the remote receiver to /srv/www/app.
Mohd Shakir Zakaria is a cloud architect with deep roots in software development and open-source advocacy. Certified in AWS, Red Hat, VMware, ITIL, and Linux, he specializes in designing and managing robust cloud and on-premises infrastructures.