Rsync UI is a web application that lets you create, schedule, and execute file synchronization jobs with just a few clicks, powered by rsync.
- Dashboard for job health, activity, schedules, and storage
- Synchronization jobs for local and remote destinations
- Fully customizable command-line arguments
- Automation through scheduled jobs
- Remote server management with SSH key deployment and resource usage visibility
- Custom pre-/post-synchronization hooks
- Builtin customizable notifications
- Real-time synchronization progress
- SSH command auditing for security and compliance
Note
Artificial Intelligence tooling is used during the development of this project. All generated code is thoroughly reviewed, tested, and verified manually to ensure the highest quality and security standards.
Rsync UI is distributed as a Docker image and is meant to be run with Docker compose.
- Docker with the Docker compose plugin
- A reverse proxy that terminates TLS (see Reverse proxy)
-
Create a
compose.ymlfile:x-app: &app image: ghcr.io/floriandejonckheere/rsync-ui:v1.0.0 restart: unless-stopped volumes: - rsync_ui:/app/storage/ # Application storage (rsync logs) - /path/to/storage:/data/storage:ro # Local directory to back up (read-only) - /path/to/backup:/data/backup:rw # Local directory to back up to (read-write) environment: SECRET_KEY_BASE: my-secret # Application secret key ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: my-secret # Encryption secret key ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: my-secret # Encryption secret key ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: my-secret # Encryption secret key PG_HOST: postgres PG_USER: rsync_ui PG_PASSWORD: my-password PG_DATABASE: rsync_ui APP_HOST: rsync-ui.example.com # Public hostname of the application APP_EMAIL: rsync-ui@example.com # Sender address of emails sent by the application ADMIN_EMAIL: admin@example.com # Default administrator account ADMIN_PASSWORD: my-admin-password # Default administrator password depends_on: postgres: condition: service_healthy services: web: <<: *app ports: - "127.0.0.1:3000:3000" worker: <<: *app command: bin/jobs postgres: image: postgres:18 restart: unless-stopped volumes: - postgres:/var/lib/postgresql/18/docker/ environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: my-postgres-password healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 2s timeout: 5s retries: 30 volumes: postgres: rsync_ui:
Generate each secret with
openssl rand -hex 32, and replace the passwords with strong, unique values.The following image tags are available:
vX.Y.Z(e.g.v1.0.0): a specific release (recommended)latest: the most recent stable releasemain: the current development version (unreleased, not recommended for production)
-
Start the database:
docker compose up -d postgres
-
Create the database user and database for the application, using the
PG_USER,PG_PASSWORDandPG_DATABASEvalues from step 1:docker compose exec postgres psql -U postgres \ -c "CREATE USER rsync_ui WITH PASSWORD 'my-password';" \ -c "CREATE DATABASE rsync_ui OWNER rsync_ui;"
-
Start the application:
docker compose up -d
The database schema is created automatically on first start, and the administrator account is created using
ADMIN_EMAILandADMIN_PASSWORD. -
Configure your reverse proxy to forward
https://rsync-ui.example.comto port 3000, and sign in with the administrator account.
Rsync UI does not handle TLS itself, and serves plain HTTP on port 3000. Run it behind a reverse proxy (e.g. Caddy, Traefik or nginx) that terminates TLS. The reverse proxy must:
- Serve the application on the hostname configured in
APP_HOST - Set the
X-Forwarded-Protoheader - Support WebSocket connections (used for real-time updates)
For example, using Caddy (which sets up TLS certificates automatically):
rsync-ui.example.com {
reverse_proxy localhost:3000
}
Local directories are mounted into the container under /data, and can be added as local repositories in the application (e.g. /data/storage).
The application runs as user and group ID 1000 inside the container, so this user needs read access to source directories and write access to destination directories.
Besides the variables in the example above, the following optional environment variables are available:
| Variable | Default | Description |
|---|---|---|
RAILS_LOG_LEVEL |
info |
Log level (debug, info, warn, error, fatal) |
JOB_CONCURRENCY |
1 |
Number of background job worker processes |
RAILS_MAX_THREADS |
5 |
Number of web server threads (also the database connection pool size) |
MISSION_CONTROL |
0 |
Set to 1 to enable the background job dashboard for administrators |
SKIP_CREDENTIALS_CHECK |
0 |
Set to 1 to skip checking the required environment variables on startup |
SKIP_CONFIGURATION_CHECK |
0 |
Set to 1 to skip checking the application configuration on startup |
SKIP_SSH_CONFIG_SYNC |
0 |
Set to 1 to skip regenerating the SSH configuration on startup |
The application exposes a health check endpoint at /up, which returns HTTP 200 when the application is running, and HTTP 500 otherwise.
Read the changelog before upgrading.
Change the image tag in compose.yml to the new version (e.g. ghcr.io/floriandejonckheere/rsync-ui:v1.0.0), then pull the image and restart the containers:
docker compose pull
docker compose up -dDatabase migrations are run automatically on startup.
All application data (servers, repositories, jobs and their history) is stored in the PostgreSQL database. Back up the database regularly, for example:
docker compose exec postgres pg_dump -U postgres rsync_ui > rsync_ui.sqlServer credentials (passwords and SSH keys) are encrypted in the database using the ACTIVE_RECORD_ENCRYPTION_* keys.
Store these keys together with your backups: without them, the encrypted credentials cannot be restored.
First, ensure you have a working Docker environment with the Docker compose plugin.
Build the images and start the containers:
docker compose up -dOn startup, the database is created, migrated, and seeded with sample data.
The application is now available at http://localhost:3000. Sign in with the administrator account configured in .development.env.
The development environment includes three fake SSH servers that simulate remote storage targets, seeded with realistic data so jobs can be run end-to-end without real infrastructure.
| Container | Script | Mount |
|---|---|---|
nas |
docker/ssh/init-nas.sh |
./tmp/data/nas → /data |
backup |
docker/ssh/init-backup.sh |
./tmp/data/backup → /backup |
mirror |
docker/ssh/init-mirror.sh |
./tmp/data/mirror → /backup |
The app container stores all local repository data under ./tmp/data/app (mounted to /data).
The following jobs are pre-seeded and can be run against these servers:
| Job | Source | Destination | Schedule |
|---|---|---|---|
| Docker replica | Docker (local) |
Docker Replica (local) |
Every 5 minutes, disabled |
| Home backup | Home (local) |
Home backup (Backup server) |
Daily at 02:00 |
| Projects backup | Projects (local) |
Projects backup (Backup server) |
Daily at 02:00 |
| NAS photos sync | NAS Photos (NAS server) |
Photos (local) |
Weekly on Sunday at 03:00 |
| Photos mirror sync | Photos (local) |
Photos mirror (Mirror server) |
Weekly on Monday at 03:00 |
Source repositories are pre-populated with representative data. Destination repositories start empty and are filled when their job runs.
To reset all destination repositories back to their initial empty state, run from the project root:
docker/reset.shRun the bin/update script to pull and rebuild the images, install the Ruby and JavaScript dependencies, and restart the application:
bin/updatedocker compose logs -f app worker # Follow application and background worker logs
docker compose exec app bash # Open a shell in the application container
docker compose exec app bundle exec rails console # Open a Rails console
docker compose exec app bundle exec rails db:migrate # Run database migrations
docker compose exec app bundle exec rails database:seed # Seed the database with sample data
docker compose exec app bundle exec rspec # Run the test suite
docker compose exec app bundle exec rspec spec/path/to/file_spec.rb:12 # Run a single test
docker compose exec app bundle exec rubocop # Lint Ruby code
docker compose exec app yarn herb:format # Format ERB templates
docker compose exec app bundle exec brakeman # Run a security scanSee docs/COMMANDS.md for more commands.
Call binding.break anywhere in the source code to start a debugger.
When adding application environment variables, do not forget to add them in the following places:
.development.envconfig/initializers/credentials.rb(if the variable is required in production)- The Installation or Configuration section of this README
The CI and CD workflows authenticate with the GitHub Container Registry using the built-in GITHUB_TOKEN, so no registry secrets are needed.
The repository needs the Write role in the package's "Manage Actions access" settings.
The cleanup workflow deletes untagged images from the registry every Sunday (and can be run manually, by default as a dry run).
It uses the workflow token, so the repository needs the Admin role in the package's "Manage Actions access" settings.
Add your changes to the Unreleased section of the changelog as you go.
To release a version (vMAJOR.MINOR.PATCH, optionally with a pre-release suffix, e.g. v1.0.0-rc.1), run from an up-to-date, clean main branch:
bin/release v1.0.0The script:
- Writes the version to
lib/rsync_ui/version.rb(usingbin/version) - Moves the
Unreleasedchangelog entries to a new version section (dated today) and updates the comparison links (usingbin/changelog) - Commits the changes (
Bump version to v1.0.0) and tags the commitv1.0.0 - Shows the release notes, and asks for confirmation before pushing
mainand the tag to GitHub
Once the tag passes the tests, the CI workflow verifies that the tag matches the version and changelog, builds a Docker image and pushes it to the registry (e.g. ghcr.io/floriandejonckheere/rsync-ui:v1.0.0, and latest for stable versions), and creates a GitHub release with the changelog entries of the version as release notes (marked as pre-release if the version has a suffix).
Every push to main also builds and pushes the main image.
The script refuses to release if the Unreleased section is empty.
Copyright 2026 Florian Dejonckheere














