README
¶
docker-rsync-scheduler
Push local directories to a remote host over rsync-and-ssh on a schedule — structured logs, no metrics, no open ports.
What it does
Reads a YAML config defining N sync jobs. For each job it runs rsync over ssh to push a local directory one-way to a remote host. Every run emits structured slog lines (logfmt) for collection by a log aggregator (Alloy, Promtail) and alerting via Grafana or similar.
- One-way mirror of each configured local directory to a
[user@]host:/path - Per-job
--delete,--chown=uid:gid, and exclude patterns - Empty-source guard: a job whose top-level source directory is missing or empty is skipped, so a wholly vanished or unmounted source cannot let
--deletewipe the matching remote tree. The guard probes the job'slocaldir only; for sources composed of nested bind mounts, ensure every nested mount is present before a--deletepass (or omitdelete: truefor such jobs). The guard consults only the built-in global excludes, never per-jobexcludes: adelete: truejob whose source holds only entries matched by its ownexcludesstill runs with an empty post-exclude file list, so--deletethen clears the remote. Avoiddelete: trueon a job whoseexcludescan match every top-level entry of its source, or setmax_deleteon that job as a backstop so a runaway pass aborts (rsync--max-delete=N) rather than clearing the remote. - Built-in interval scheduler, or hand scheduling to an external scheduler (cron, Ofelia, etc.) via the
syncsubcommand - File-marker healthcheck — unhealthy when any job fails, recovers on the next clean pass
- Logs only: no Prometheus exporter, no HTTP server, no listening socket
Architecture
- Scheduler your way. Ships with a self-contained Go interval scheduler so you don't need external cron, systemd timers, or orchestrator-level scheduling. Set
SYNC_INTERVALto a Go duration and the container runs one pass at startup (immediate freshness on deploy) then every interval. If you already run a central scheduler (Ofelia, cron), setSYNC_INTERVAL=offand trigger passes withdocker exec rsync docker-rsync-scheduler syncinstead. See Scheduling modes. - Overlap lock. A single advisory file lock (
flockon/tmp/.docker-rsync-scheduler.lock) serialises every sync pass — the built-in ticker racing the startup pass in-process, and an externalsyncexec racing the ticker cross-process — so two passes never run at once. - Subcommands.
daemon(PID 1, the default command; dispatches built-in vs external based onSYNC_INTERVAL),sync(one pass, exit 0 if all jobs succeed, 1 if any fail), andhealth(the Docker probe). The built-in startup pass, the interval pass, and thesyncsubcommand share one sync-pass function. - No shell. Each job is executed via
exec.CommandContextwith an explicit argument slice. The-e "ssh ..."value is a single argument that rsync splits internally — nothing is ever interpreted by a shell. - Injection guardrails. Config is validated at startup: required fields present, names unique,
local/remote_pathabsolute,remote_hostmatched against a strict pattern, and every field rejected if it contains shell metacharacters or control characters as defense-in-depth. The ssh key must exist and be readable. - Bounded resources. Per-job timeout via context (default 10m, override with
SYNC_TIMEOUT); captured rsync stderr is bounded to 1 MB so a chatty subprocess cannot OOM the container. - Health. File-marker pattern via
github.com/cplieger/health— the marker is set after each pass and probed by thehealthsubcommand.
Quick start
The image is published to both GHCR (ghcr.io/cplieger/docker-rsync-scheduler) and Docker Hub (cplieger/docker-rsync-scheduler) — identical contents, use whichever you prefer.
services:
rsync:
image: ghcr.io/cplieger/docker-rsync-scheduler:latest
container_name: rsync
restart: unless-stopped
environment:
LOG_LEVEL: "info"
CONFIG_PATH: "/config/config.yaml"
SYNC_INTERVAL: "6h" # Go duration; "off" disables the built-in scheduler
SYNC_TIMEOUT: "10m"
volumes:
- ./config.yaml:/config/config.yaml:ro
- ./id_ed25519:/keys/id_ed25519:ro
- /srv/source/certs:/sources/certs:ro
Scheduling modes
The container runs in one of two modes, selected by SYNC_INTERVAL.
Built-in scheduler (default)
Set SYNC_INTERVAL to a Go duration (6h, 1h, 30m, …). The container runs a sync pass at startup and then every interval. This is the zero-dependency default; nothing else is required. On an unset or unparseable (non-sentinel) value it falls back to 6h.
External scheduler
Set SYNC_INTERVAL=off (aliases: disabled, 0). The container stays running but idle, and you trigger each pass out-of-band by exec'ing the sync subcommand:
docker exec rsync docker-rsync-scheduler sync
The pass runs once and exits; its exit code is non-zero on failure, and it updates the same health marker the long-running container reports. This lets a central scheduler own the cadence. Example with Ofelia labels:
services:
rsync:
image: ghcr.io/cplieger/docker-rsync-scheduler:latest
container_name: rsync
restart: unless-stopped
environment:
LOG_LEVEL: "info"
CONFIG_PATH: "/config/config.yaml"
SYNC_INTERVAL: "off" # disable built-in loop; Ofelia drives it
SYNC_TIMEOUT: "10m"
labels:
ofelia.enabled: "true"
ofelia.job-exec.rsync-sync.schedule: "@every 6h"
ofelia.job-exec.rsync-sync.command: "docker-rsync-scheduler sync"
ofelia.job-exec.rsync-sync.no-overlap: "true"
volumes:
- ./config.yaml:/config/config.yaml:ro
- ./id_ed25519:/keys/id_ed25519:ro
- /srv/source/certs:/sources/certs:ro
Overlapping passes are prevented in both modes by an advisory file lock (flock) on /tmp/.docker-rsync-scheduler.lock, so a manual docker exec pass that races a scheduled one (or the built-in ticker) will skip rather than run a second concurrent pass. Ofelia's no-overlap is still recommended to avoid queuing redundant triggers.
Configuration reference
Environment variables
| Variable | Description | Default | Required |
|---|---|---|---|
CONFIG_PATH |
Path to the YAML config inside the container | /config/config.yaml |
No |
SYNC_INTERVAL |
Built-in scheduler cadence as a Go duration (e.g. 6h, 1h, 30m). The first pass runs at startup; subsequent passes fire every interval thereafter. Set to off (or disabled/0) to disable the built-in scheduler and trigger passes externally — see Scheduling modes. Falls back to 6h on an unset or unparseable (non-sentinel) value. |
6h |
No |
SYNC_TIMEOUT |
Per-job rsync timeout as a Go duration (e.g. 10m, 1h). Falls back to the default on unset or unparseable values. |
10m |
No |
LOG_LEVEL |
Log level: debug, info, warn, or error |
info |
No |
Config schema (config.yaml)
A ready-to-edit config.example.yaml ships in the repo — copy it to config.yaml and edit. The container fails fast with a clear error if the config is missing or invalid.
jobs:
- name: certs # required, unique, used as a log key
local: /sources/certs # required, absolute path inside the container
remote_host: root@192.0.2.10 # required, [user@]host (DNS, IPv4, or IPv6 literal)
remote_path: /srv/certs # required, absolute path on the remote
remote_uid: 1000 # optional; with remote_gid -> rsync --chown=uid:gid
remote_gid: 1000 # optional
ssh_key: /keys/id_ed25519 # required, private key path inside the container
delete: true # optional, default false -> rsync --delete when true
max_delete: 100 # optional; with delete -> rsync --max-delete=N (abort if a pass would delete > N files)
excludes: ["**/locks", "**/*.lock", "logs"] # optional, per-job exclude patterns
The remote_host field is [user@]host, where host is a DNS hostname, an IPv4 address, or an IPv6 literal. Write IPv6 literals as the bare address (2001:db8::1 or user@2001:db8::1) — the brackets rsync's host:path syntax needs are added for you. A host containing a colon that is not a valid IP (a trailing colon, or an incomplete address) is rejected at startup so it can't be misread as rsync's daemon-mode :: separator. Link-local IPv6 with a zone id (fe80::1%eth0) is not supported; use a global or ULA address, or define an ssh_config Host alias and reference the alias name.
Every job also receives a fixed set of global excludes: .stfolder, .stversions, .DS_Store, Thumbs.db. Each job is pushed with rsync -rlptD (archive minus owner/group/ACL/xattr) plus --stats, the per-job and global excludes, and the -e "ssh -i <key> -o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=10" transport.
Volumes
| Mount | Description |
|---|---|
/config/config.yaml |
The YAML config (mount read-only). Override the path with CONFIG_PATH. |
/config/known_hosts |
Optional SSH known_hosts file (mount read-only). When present, enables strict host-key pinning instead of TOFU. See SSH host-key verification. |
/keys/<name> |
SSH private key(s). Mount read-only; the host file must be mode 0600. |
| (your sources) | The local directories referenced by your jobs. Mount read-only. |
Healthcheck
The built-in healthcheck (docker-rsync-scheduler health) checks for a marker file that is set after each sync pass: healthy when the most recent pass had zero failed jobs, unhealthy when any job failed. Empty-source skips count as success. The container recovers automatically on the next clean pass — no restart required. In built-in mode it begins unhealthy, runs one pass at startup, and transitions accordingly, so size healthcheck.start_period for the time the initial pass may take. In external mode the container starts healthy (idle, nothing has failed) and each triggered sync updates the marker.
Because an empty source is skipped as a success, a job whose source silently becomes empty (for example a read-only bind mount that failed to mount and Docker materialised as an empty directory) keeps the container healthy and never logs at
level=error— it is invisible to both the error-level and heartbeat-absence alerts. Each skip emits alevel=warn msg="skip empty source"line and thesync cycle completeheartbeat carries askippedcount; add a Loki alert on a persistently non-zeroskipped(orskipped == jobs) across several consecutive passes, or on the recurringskip empty sourcewarning, to catch a vanished source before the remote mirror goes stale.
HEALTHCHECK --interval=60s --timeout=5s --retries=3 --start-period=120s \
CMD ["/usr/local/bin/docker-rsync-scheduler", "health"]
Security
No network listener, no HTTP server, no exposed ports. The image ships openssh-client only — no sshd. Each job is executed with an explicit argument slice via exec.CommandContext; nothing is passed through a shell. Config fields are validated and rejected if they contain shell metacharacters or control characters, even though the arg-list exec already prevents interpretation.
SSH host-key verification
By default the container uses StrictHostKeyChecking=accept-new (Trust On First Use). This lets a fresh deploy connect without pre-provisioning host keys, but trusts the first key it sees.
For stricter security, mount a read-only known_hosts file at /config/known_hosts. When this file is present the container switches to StrictHostKeyChecking=yes with an explicit UserKnownHostsFile, rejecting connections to any host whose key does not match the pinned entry. This prevents MITM attacks at the cost of requiring the operator to maintain the known_hosts file.
Generate it from your remote:
ssh-keyscan -t ed25519 192.0.2.10 > known_hosts
Then mount it into the container:
volumes:
- ./known_hosts:/config/known_hosts:ro
| Tool | Result |
|---|---|
| govulncheck | No vulnerabilities in call graph |
| golangci-lint (gosec, gocritic) | 0 issues |
| trivy | Inherits the Alpine base image scan |
| gitleaks | No secrets detected |
| hadolint | Clean |
Why it runs as root. The container runs as root by design: it must read host-owned source files (e.g. uid 568) across multiple bind mounts. A fixed non-root USER would break this. Mount sources read-only and use a dedicated, least-privilege SSH key on the remote.
Dependencies
All dependencies are updated automatically via Renovate. Base images and Go modules are pinned by digest/version; the rsync/openssh-client apk packages are installed unpinned so they track the digest-pinned base.
| Dependency | Source |
|---|---|
| golang | Go |
| alpine | Docker Hub |
| rsync | Alpine |
| openssh-client | Alpine |
Runtime Go modules: github.com/cplieger/health and gopkg.in/yaml.v3.
Credits
This project packages rsync (GPL-3.0) and the OpenSSH client (BSD) into a container image. All credit for those tools goes to their upstream maintainers.
Contributing
Issues and pull requests are welcome. Please open an issue first for larger changes so the approach can be discussed before implementation.
Disclaimer
This image is built with care and follows security best practices, but it is intended for homelab use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
This project is licensed under the GNU General Public License v3.0.