ers# Code Review — rsync2backup
Date: 2026-07-29
The project is at an early prototype stage. The concept works (client generates keys, server accepts them, rsync transfers happen), but the code has substantial issues in security, correctness, portability, and maintainability that must be addressed before it can be recommended for real use — which the README itself acknowledges ("don't use it yet!").
This document goes file by file, then lists cross-cutting concerns, then gives a prioritized action list.
This is the heart of the client. Several bugs and design smells.
-
if ! [ -f ssh_key ]uses a relative path, but the working directory depends on how the container is invoked.WORKDIR /data/sshkeysin the Dockerfile fixes this in practice, but the script is fragile — onecdelsewhere breaks it. → Use an absolute path (/data/sshkeys/ssh_key) or a variable. -
mv *.pub public— afterssh-keygen -f ssh_key, onlyssh_key.pubexists. The glob works, but if a leftover.pubfrom a previous run is around, it moves everything. Prefer explicit:mv ssh_key.pub public/. -
Reading
$PUSHwhen unset crashes underset -u.if (( $PUSH > 0 ))fails hard ifPUSHisn't exported. Also, ifPUSHis a non-numeric string the arithmetic errors. → Default it:PUSH="${PUSH:-0}"and use[ "$PUSH" -gt 0 ]. -
Race condition / logic hole: on the first run, the script generates keys and writes
docker-compose.ymlbut never actually runs rsync. The user has to rundocker compose upa second time. That's OK if documented, but the script silently exits with success — no message like "rerun after starting the server." Add a clear "next steps" print. -
sed -i "s/#USER_NAME#/$USER_NAME/g"etc. are unquoted. If any variable contains/,&, or a newline,sedwill corrupt the file or fail. → Use a delimiter unlikely to occur (sed -i "s|#USER_NAME#|${USER_NAME}|g") and validate inputs. -
docker-compose.ymlis written into the current directory (/data/sshkeys). That means it lands in the user's key mount — which is confusing (compose file for the server stored next to the client's private key). It should be written to a separate output dir the user mounts, e.g./data/server-compose/docker-compose.yml. -
cp -r .ssh ~on every subsequent run unconditionally copies persisted.sshover the container's home. If it doesn't exist,cperrors and (withoutset -e) execution continues. → Guard with[ -d .ssh ](which you already have for the outerif, but the innercpis inside that guard already; still worth using-Tor an explicit target:cp -rT .ssh "$HOME/.ssh"). -
After push,
cp -r ~/.ssh .writes the host-key-augmented.sshback into/data/sshkeys. Fine in principle — but it will happily overwrite anything in there and it isn't done in the pull branch. Both branches should updateknown_hosts. -
Shell quoting inside the
-estring is broken-looking:-e "ssh -i /data/sshkeys/ssh_key -o \"StrictHostKeyChecking ${STRICT_HOST_CHECKING}\" -p ${RSYNC_PORT} "The
-ovalue doesn't need to be quoted inside the outer double-quotes — rsync passes the string to a shell, and the escaped quotes actually reach that shell as literal"characters. It works by accident. Cleaner:rsync -av --delete \ -e "ssh -i /data/sshkeys/ssh_key -o StrictHostKeyChecking=${STRICT_HOST_CHECKING} -p ${RSYNC_PORT}" \ /upload/ "${USER_NAME}@${RSYNC_SERVER}:/data/"
-
Trailing-slash asymmetry between push and pull.
rsync -av /upload user@host:/datacopies/uploadas a subdirectory into/data, giving/data/upload/.... The pull path does the same reversed. Was that intended? For a mirror, you almost always want/upload/(with trailing slash) →/data/. -
--deleteis only in the push path. On pull, deletions from the server are not reflected locally. Fine for restore semantics, but worth being explicit about in a comment. -
No
set -euo pipefail. Any command can fail silently. Add:set -euo pipefailat the top and remove reliance on non-strict mode.
-
Missing required-variable validation. The script assumes
RSYNC_SERVER,RSYNC_PORT,USER_NAME,RSYNC_UID,RSYNC_GIDare all set. If any is missing, either the rsync command silently misbehaves or the sed templating leaves#PLACEHOLDER#strings in the output. Add explicit checks. -
The comment on line 26–27 ("create add-user-skript, pass it to rsync-server") is a TODO in a code path that reaches production. Either implement it or remove the ambiguity.
-
echo Rsync in push mode— unquoted, which works, but inconsistent with the rest. Nit.
- No logging with timestamps.
- No exit-code propagation from rsync (fine because there's no
set -e, but that's itself the problem). - No dry-run switch (
RSYNC_DRY_RUN=1→-n). - No bandwidth limit switch (
--bwlimit). - No
--partial --partial-dirfor interrupted transfers over slow links.
FROM janpdev/rsyncbackup-common:v0.1- Base image is versioned to
:v0.1. OK for reproducibility, but the client compose file referencesjanpdev/rsyncbackup-client:v0.11while the README says buildv0.1. → Version drift. Standardize on a single scheme and document it. ConsiderARG BASE_VERSIONso both stay in sync. LABEL MAINTAINERis deprecated. UseLABEL org.opencontainers.image.authors=...(which the server Dockerfile already does correctly).RUN chmod u+x /usr/bin/rsync2backup.shis redundant — theCOPY --chmod=u+xalready did it.RUN dos2unix ...in three separate layers bloats the image. Combine:RUN dos2unix /usr/bin/rsync2backup.sh /tmp/template1.yml /tmp/template2.yml \ && chmod +x /usr/bin/rsync2backup.sh- No
.dockerignorein the repo..git,.idea,.obsidianwill be shipped into the build context. Add a.dockerignore. - No
HEALTHCHECK, no non-root user. The container runs as root; it doesn't need to for rsync client work. ENTRYPOINT ["rsync2backup.sh"]with noCMD— fine, but arguments passed viadocker runwill be positional args to the script, which the script ignores. Consider parsing them.
FROM linuxserver/openssh-server:latest:latestis a reproducibility hazard — image rebuilds pick up unrelated upstream changes. Pin to a specific digest or tag (e.g.linuxserver/openssh-server:9.6_p1-r0-ls148).- Trailing whitespace / empty
\on lines 7–8 — theRUNcommand effectively ends withrsyncand nothing follows. Works, but noisy. - No hardening on top of the upstream:
AllowUsers/Match Userrestricted to only the backup user.- Force
ForceCommand internal-sftpor restrict to rsync viaauthorized_keyscommand="..."(see cross-cutting note below). - Disable password auth explicitly (upstream already does, but making it explicit here is good defense-in-depth).
LABEL version=0.1should beorg.opencontainers.image.version.
image: janpdev/rsyncbackup-client:v0.11
...
- RSYNC_SERVER=172.26.192.1
- RSYNC_UID=2001
volumes:
- source: C:\nobackup\clienttest\keystuff
- source: C:\Users\jan\Documents\projects\rsync2backup\- Version mismatch with README (
v0.1vsv0.11). - Hard-coded hostnames and Windows paths commit personal setup details to a
public repo. This is the number-one issue for a GitHub release. Convert to a
.envfile:and ship aenvironment: - RSYNC_SERVER=${RSYNC_SERVER} - RSYNC_PORT=${RSYNC_PORT} - USER_NAME=${USER_NAME} - RSYNC_UID=${RSYNC_UID} - RSYNC_GID=${RSYNC_GID} - PUSH=${PUSH:-1} volumes: - ${KEY_DIR}:/data/sshkeys - ${SOURCE_DIR}:/upload
.env.example. RSYNC_GIDis not set in the compose file although the script references it — it will be empty, and the generated server compose will havePGID=.version: "3.8"is obsolete — modern Compose ignores it. Remove./uploadis bind-mounted read-write for push. In push mode it can be read-only:This is a small but real safety win (defends against a script bug that could delete source data).- type: bind source: ${SOURCE_DIR} target: /upload read_only: true
version: "2.1"
- C:\nobackup\server\config:/config
- C:\nobackup\server\transfer_target:/data
- C:\nobackup\server\keystuff\public:/keys
ports:
- 2001:2222- Same hard-coded Windows paths and version pinning issues as the client.
- Server is exposed on
2001:2222bound to all interfaces. For a server that only serves LAN or VPN traffic, bind to a specific interface:127.0.0.1:2001:2222or the LAN IP. If it truly is internet-exposed, add fail2ban / rate limiting outside the container. PUID=1000/PGID=1000hard-coded here, but the client sendsRSYNC_UID=2001. Ownership on/datawill mismatch what the client believes. Push works because rsync-over-ssh runs as the login user, but any script logic assumingPUIDandRSYNC_UIDmatch will silently break.- The
USER_NAME=transferis set on the server side but there is no mechanism to ensure it matches the value the client used to generate the key. Document this coupling or auto-derive.
The cat template1.yml public/ssh_key.pub template2.yml > docker-compose.yml trick
is clever but fragile:
- The public key is a single long line. Injecting it between two YAML fragments
works only because it lands on the same physical line as
- PUBLIC_KEY=intemplate1.yml(which ends withPUBLIC_KEY=and no newline). Buttemplate1.ymlas shown in your workspace does end with a normal newline (12 lines). If Git or an editor normalizes EOL, this will produce broken YAML like:which is not valid. → Either strip trailing newline explicitly in the script:- PUBLIC_KEY= ssh-ed25519 AAAA...
or, better, useprintf '%s' "$(cat /tmp/template1.yml)" > docker-compose.yml cat public/ssh_key.pub >> docker-compose.yml cat /tmp/template2.yml >> docker-compose.yml
envsubst/ a real template engine and inject the key as a properly-quoted YAML string. template1.ymlanddocker-compose-template.ymldiverge (janpdevops/...:latestvsjanpdev/...:v0.1). Only one is actually used (the split templates). Either deletedocker-compose-template.ymlor make it authoritative and generate from it.template2.ymlstill has hard-coded Windows paths for/configand/data. These will be baked into whatever server compose is generated — meaning the generated file is only useful on that one Windows machine. Parameterize them:- #SERVER_CONFIG_DIR#:/config - #SERVER_DATA_DIR#:/data
template2.ymlhas no trailing-newline handling either. Same risk.#USER_NAMEon line 14 ofdocker-compose-template.ymlis missing its trailing#→sedwill never replace it. Bug in the (currently unused) template.version: "2.1"is obsolete.
adduser --uid ${UID} --gid 2000 --disabled-password --disabled-login ${username}${UID}is a bash builtin — it always holds the current shell's UID, so this line silently ignores whatever you meant to pass in. Rename to something like${NEW_UID}.${username}(lowercase) is not exported by convention. If this ever gets sourced or called with env vars, use${USERNAME}. Also, on many Linux distrosUSERNAMEis set bylogin— pick a non-colliding name likeRSYNC_USERNAME.adduser --disabled-password --disabled-loginis Debian-specific.linuxserver/openssh-serveris based on Alpine, whoseadduseruses different flags (-D,-H, etc.). This template will not run against the base image the server actually uses.- The script is a stub (
# TODO: find non-interactive version). Alpine equivalent:addgroup -g 2000 backupusers 2>/dev/null || true adduser -D -H -u "$NEW_UID" -G backupusers "$RSYNC_USERNAME"
This template is not wired into anything yet, but it should either be finished or removed to reduce confusion.
- The client's SSH key can do arbitrary things over SSH, not just rsync. That
means a compromised client can
sshin and delete anything under/data— which is the whole backup mirror. Restrict viaauthorized_keysoptions:command="rrsync -wo /data",restrict ssh-ed25519 AAAA...rrsync(ships with rsync) enforces read-only or write-only rsync-only access. This is the single most impactful hardening step for the mirror. - No host-key verification on first run.
StrictHostKeyChecking=noopens the door to MITM until.ssh/known_hostsis written. Provide a way to seed the server host key out-of-band, or at least document the risk clearly. - Private key on a Windows bind-mount has whatever ACLs Windows gives it — often world-readable within the user profile. OpenSSH inside Linux does not refuse it because the mount masks permissions. Document that the key mount must be a directory only the user can read.
- No key-rotation story. How does a user replace a compromised key? Not addressed.
- No logging / audit trail on either side.
- The whole system assumes Docker Desktop on Windows (bind-mount syntax like
C:\nobackup\...). Nothing in the compose files exercises Linux/macOS paths. Provide at least one Linux example (./data:/data) indocker-compose.ymlor indocs/.
- No
.gitignore..idea/,.obsidian/, and — worse — any generatedssh_key/ssh_key.pubthat ends up next to the source tree can be accidentally committed. Add:.idea/ .obsidian/ ssh_key ssh_key.pub public/ known_hosts .env - No
.dockerignore. Same problem for the build context. Rsync-Backup.mdas the filename is unusual — GitHub rendersREADME.mdon the repo landing page. Rename toREADME.mdor add a smallREADME.mdthat links to bothRsync-Backup.mdandBackup-overview.md.- No LICENSE header info in the README. You have a
LICENSEfile but the README doesn't state which license. - Version numbers:
v0.1in Dockerfiles,v0.11in the client compose,latestintemplate1.yml. Pick one strategy and enforce it. - CI: no GitHub Actions to at least
docker buildboth images on push. A one-file workflow would catch a lot of the above.
- No integration test that spins up server + client, runs a sync, and verifies content on the server side. Given this is a backup tool, an automated round-trip test (sync → tamper → resync → assert) is essential before you can drop the "don't use it yet!" warning.
- Remove personal Windows paths and hard-coded IPs from all
docker-compose.yml; move to.env+.env.example. - Add
.gitignoreand.dockerignore. - Add
set -euo pipefailand input validation torsync2backup.sh. - Fix the
template1.yml+ public-key concatenation to guarantee correct YAML (strip trailing newline). - Standardize image versions across README, Dockerfiles, and compose files.
- Rename
Rsync-Backup.md→README.md(or add aREADME.mdlanding page). - State the license in the README.
- Restrict the client key on the server via
authorized_keyscommand="rrsync -wo /data",restrict. - Fix rsync trailing-slash semantics and the
--deleteasymmetry; document push vs pull semantics. - Add explicit
known_hostshandling; document TOFU risk. - Parameterize
template2.ymlserver paths and fix the missing#on#USER_NAMEindocker-compose-template.yml. - Rewrite
adduser-template.shfor Alpine, or delete it. - Add a minimal GitHub Actions workflow that builds both images.
- Pin
linuxserver/openssh-serverto a specific tag (not:latest).
- Add
--partial,--bwlimit, dry-run, and structured logging options via env vars. - Add an integration test (docker-compose network with both containers, verify a file round-trips).
- Add
HEALTHCHECKto the server image. - Bind the server port to a specific interface, not
0.0.0.0. - Squash the three
dos2unixlayers in the client Dockerfile.
A natural first batch of implementation work is:
.gitignore.dockerignore.env.example+.env-based compose files (client & server)- hardened
rsync2backup.sh(strict mode, input validation, safer sed, correct trailing slashes, symmetricknown_hostshandling) - corrected templates (
template1.ymlnewline handling, parameterized paths) - consistent image versioning
These can be done without changing the fundamental architecture, and would move
the project from "prototype" to "safe to try on real data" — at which point stages
2–4 of the backup pipeline (see Backup-overview.md) become the next focus.