Docker Compose setup for my Synology NAS. main is what runs: the NAS pulls it every 5 minutes.
scripts/deploy.sh (root, every 5 minutes) fast-forwards the NAS checkout to origin/main, then, for what changed
since the last deployed commit (.last-deployed):
- first the
*.before.shone-time operations not run yet stacks/<stack>/…→scripts/compose.sh <stack> up -d --remove-orphans, skipped for a stack whose services all sit behind a profile (backup: nothing to run until asked)config/<name>/…→docker restart <name>- then the other one-time operations not run yet
A failure leaves the commit unmarked, so the next run retries it and DSM emails the output. A deleted stack is never
torn down automatically: the run prints the docker compose -p <stack> down to run. Every run also checks that the
nightly database backup still succeeds (see Backups).
stacks/<stack>/compose.yml one Compose project per folder
stacks/<stack>/.env.example the stack's variables (absent when it only uses common ones)
stacks/common.env.example variables shared by every stack
config/<name>/ files mounted into the container named <name>
operations/ one-time scripts, see below
scripts/ deploy and maintenance scripts, tests in scripts/tests/
The real stacks/common.env and stacks/<stack>/.env exist only on the NAS (gitignored, mode 600).
- Run Compose through
scripts/compose.sh <stack> …: plaindocker composedoesn't load the env files. - Every service has a
container_name, andconfig/<name>/is named after it. - App data lives in host folders (
${DOCKERCONFDIR},${DOCKERSTORAGEDIR}), never in the repo or a Docker volume. - Every
${VAR}used in a compose file is listed in an.env.example(CI checks it; the deploy refuses a stack whose.envlacks a listed key). - Images are pinned to exact versions.
- Only
portaineranddocker-socket-proxymay mount the Docker socket, and nothing runs privileged (CI checks it; allowlist inscripts/check_stacks.sh). - Every service with a writable volume has a
homelab.backuplabel saying how its data is backed up (CI checks it; see Backups).
Edit them on the NAS, never in git:
sudo scripts/edit_env.sh <stack> # or: commonIt validates the file, keeps the old one as .previous, and redeploys what it affects. Single-quote values
containing $ (password hashes).
Renovate opens a pull request per image or GitHub Action update and merges it once CI passes; the NAS deploys it within 5 minutes. Both Opusline images update together. The Dependency Dashboard issue shows what's pending.
- Never proposed, on purpose: Postgres major versions and Immich's database/cache images (see
renovate.json). - CI checks the configuration, not the app: a broken release still deploys.
- To roll back,
git revert, and in the same commit block the version inrenovate.json({"matchPackageNames": ["<image>"], "allowedVersions": "<x.y.z"}), or Renovate merges it again.
For commands that must run once on the NAS (fix a database, move a folder), like Laravel's one-time operations:
scripts/new_operation.sh "reset immich password" # runs after the stacks are updated
scripts/new_operation.sh --before "create the foo folder" # runs right after the pull, before any container changesWrite the commands (container commands through scripts/compose.sh <stack> exec -T …), push. The next deploy runs
it once, as root from the repo root, in file name order: *.before.sh operations before the env files are checked
and before any container changes (to prepare a folder or a missing .env key), the others after the stacks are
updated. It is then recorded in .operations-done and never runs again, even if edited. A failing one blocks the
deploy and is retried. sudo scripts/run_operations.sh --list shows what ran.
Quality profiles and custom formats live in config/recyclarr/: profiles and scores in configs/instances.yml,
each custom format as a JSON file in custom-formats/<service>/. scripts/sync_arr_settings.sh keeps them in sync
both ways, every 15 minutes on the NAS:
- changed in Sonarr/Radarr → pull request
arr-settings-syncwith the new files, merged once CI passes; - changed in the repo → applied to the apps by Recyclarr once deployed;
- changed on both sides since the last sync → nothing is touched and the job fails (DSM emails you) until you pick
a side:
sudo scripts/sync_arr_settings.sh --take-appsor--take-repo.
Not synced: naming (Recyclarr only knows the TRaSH Guides' presets), and deleting a quality profile from the repo
(delete it in the app). Edit the files the way the export writes them (a score of 0 is no entry): after applying,
the sync reads the apps back and fails if they don't match the repo exactly. Before pushing a change,
scripts/preview_recyclarr.sh (your computer) shows what it would do to the apps. scripts/export_arr_settings.sh
(your computer) copies the apps into the repo by hand.
scripts/backup_nas.sh runs every night as root and takes the NAS's own data off the NAS, to the Hetzner Storage Box:
scripts/backup_databases.shdumps every database into${BACKUPDIR}/<date>/(14 days kept), by label, see below.- restic, from
stacks/backup, backs up the photos, the app data, those dumps, the home folders and this checkout (its.envfiles included), minusstacks/backup/excludes.txt: live database folders, caches and what the apps regenerate. Encrypted withRESTIC_PASSWORD. - On Sundays, restic forgets old snapshots (7 daily, 4 weekly, 12 monthly), prunes, and reads back a 5% sample.
Movies, TV and torrents are left out: they are re-downloadable and don't fit the Storage Box.
Every service with a writable volume has a homelab.backup label. CI fails until a new one has it.
| Label | What backup_databases.sh does |
Used by |
|---|---|---|
postgres |
pg_dumpall into <container>.sql.gz, kept only if the dump is complete |
Immich, Opusline, Prowlarr databases |
sqlite |
copies every SQLite file in the container's ${DOCKERCONFDIR} folders with SQLite's online backup, as the file's owner, under the same relative path, and checks each copy with PRAGMA quick_check; skips an app's own dated copies (name-YYYY-MM-DD) |
Vaultwarden, Sonarr, Radarr, Tautulli, Seerr, Maintainerr |
sqlite-unchecked |
like sqlite, without the check: for databases only the app's own SQLite build can open fully; the log marks each copy (not checked) |
Plex |
bolt |
stops the container, archives its ${DOCKERCONFDIR} folders into <container>.tar.gz, starts it again (seconds of downtime) |
Portainer, Filebrowser |
none |
nothing: plain files restic copies as they are, or data not worth keeping; a comment beside the label says which | everything else with a writable volume |
The job fails if a sqlite container holds no SQLite file.
Every deploy run checks that both backups succeeded in the last 26 hours: ${BACKUPDIR}/last-success for the dumps,
${BACKUPDIR}/offsite-last-success for restic. When one didn't, or never did, the deploy run fails once, so DSM
emails you, then stays quiet until backups succeed again. It never holds a deploy back.
Glance's Home page shows the backups under the NAS stats:
- Backups: for PC → NAS and PC → Storage Box (the PC backs itself up, from its dotfiles) and NAS → Storage Box,
the number of snapshots, the age of the last one and the space taken, plus the NAS's snapshots of the PC's share.
scripts/backup_status.shreads them every hour from the repositories' files, without their passwords (restic writes each snapshot as one file when the backup ends), into${DOCKERCONFDIR}/backup-status/backups.json, which thebackup-statuscontainer serves to Glance. A repository it can't read shows its error in red, and so does "Updated" when the hourly task stops. - Storage Box: space used out of the quota, split into data and snapshots, and the Storage Box snapshots, live from the Hetzner API.
Once, on the NAS, as root:
- The folders the backup stack mounts (Synology's Docker refuses to start a container whose bind-mounted folder
doesn't exist, so create
BACKUPDIRtoo), then the SSH key for the Storage Box, readable by root only:sudo mkdir -p /volume1/docker/appdata/restic/ssh /volume1/docker/appdata/restic/cache /volume1/backup/databases sudo chmod 700 /volume1/docker/appdata/restic/ssh /volume1/backup/databases sudo ssh-keygen -t ed25519 -N '' -C jeancloud-restic -f /volume1/docker/appdata/restic/ssh/id_ed25519 - Add the public key to the Storage Box's
.ssh/authorized_keys(with SFTP, from a machine that can already log in), as the usual one-line OpenSSH key, and in Hetzner Console keep "SSH Support" and "External Reachability" on. /volume1/docker/appdata/restic/ssh/config(mode 600;IdentityFileis the path inside the container). Port 23: the Storage Box only accepts one-line OpenSSH keys there, port 22 wants them in RFC4716 format:Then connect once, which records the host key (DSM has noHost storagebox HostName u000000.your-storagebox.de Port 23 User u000000 IdentityFile /root/.ssh/id_ed25519ssh-keyscan) and checks the key login:echo 'ls -la' | sudo sftp -b - \ -F /volume1/docker/appdata/restic/ssh/config \ -i /volume1/docker/appdata/restic/ssh/id_ed25519 \ -o StrictHostKeyChecking=accept-new \ -o UserKnownHostsFile=/volume1/docker/appdata/restic/ssh/known_hosts \ storagebox
sudo scripts/edit_env.sh common(BACKUPDIR), thensudo scripts/edit_env.sh backup(RESTIC_REPOSITORY,RESTIC_PASSWORD,HOMESDIR). KeepRESTIC_PASSWORDoutside the NAS: Vaultwarden runs on it.- Create the repository:
sudo scripts/compose.sh backup run --rm -T restic init. - Task Scheduler:
bash /volume1/docker/homelab/scripts/backup_nas.shas root daily at 02:30, email on failure. Run it once by hand: the first upload takes hours, and a run still going the next night is skipped. - Dashboard: in Hetzner Console, create a Read-only API token in the project holding the Storage Box, then
sudo scripts/edit_env.sh infrastructure(HETZNER_API_TOKEN,HETZNER_STORAGE_BOX_ID) andsudo scripts/edit_env.sh backup(thePC_RESTIC_*paths). Task Scheduler:bash /volume1/docker/homelab/scripts/backup_status.shas root every hour, email on failure.
Every restic command runs through the stack, e.g. sudo scripts/compose.sh backup run --rm -T restic snapshots.
- Files: mount a folder to restore into, e.g.
sudo scripts/compose.sh backup run --rm -T -v /volume1/restore:/restore restic restore latest --target /restore --include /source/appdata/sonarr. Snapshot paths start with/source/photos,/source/appdata,/source/databases,/source/homesand/source/homelab. - Postgres: restore the dump from
/source/databases/<date>/, thengunzip -c <file>.sql.gz | sudo docker exec -i <container> psql -U <user> -d postgres. - SQLite: stop the container, replace the database file with its copy from
/source/databases/<date>/, delete its-waland-shmfiles, start it. The copies under/source/appdatawere taken live and may be inconsistent. - Bolt: stop the container,
sudo tar -xzf <container>.tar.gz -C ${DOCKERCONFDIR}(it overwrites the folder's files), start it. - Whole NAS lost: on any machine with Docker, run
restic/resticwith the same.sshfolder andRESTIC_PASSWORD(from outside the NAS) and restore/sourcefirst; this checkout and its.envfiles come back with it.
| Script | Purpose |
|---|---|
deploy.sh |
the 5-minute deploy (DSM Task Scheduler, root) |
compose.sh <stack> … |
docker compose with the stack's env files |
edit_env.sh <stack>|common |
edit settings and secrets on the NAS |
new_operation.sh, run_operations.sh |
one-time operations (--before/--after, --list, --mark-all-done) |
premigration_check.sh <stack> |
compare running containers with the compose file before replacing them |
cleanup_deluge.sh |
remove orphaned torrents (scheduled; reads its API keys from stacks/media/.env, DRY_RUN=1 to simulate) |
backup_nas.sh |
nightly: database dumps, then the restic backup to the Storage Box (see Backups) |
backup_databases.sh |
the database dumps, by homelab.backup label (run by backup_nas.sh) |
backup_status.sh |
hourly: the backup numbers the dashboard shows (see Backups, Dashboard) |
check_backups.sh |
fails once when a backup is over 26 hours old (run by deploy.sh) |
sync_arr_settings.sh |
two-way sync of the Sonarr/Radarr settings (every 15 minutes, root; --take-apps, --take-repo) |
export_arr_settings.sh, preview_recyclarr.sh |
copy Sonarr/Radarr settings into the repo, preview a sync (your computer) |
check_stacks.sh, check_glance_config.sh |
CI checks, runnable locally |
migration_helpers.sh |
helpers used once, for the migration from Portainer |
Tests: for test_file in scripts/tests/*_test.sh; do bash "$test_file"; done (needs bash, git, jq, flock, Docker
Compose). CI runs them, the two checks and gitleaks on every push and pull request.
- Install Git (Package Center), clone the repo as root (LinuxServer images only run root-owned init scripts).
- Generate the dashboard login:
image=$(awk '$1 == "image:" && $2 ~ /^glanceapp\/glance:/ { print $2 }' stacks/infrastructure/compose.yml) docker run --rm --entrypoint /app/glance "$image" secret:make # GLANCE_SECRET_KEY docker run --rm --entrypoint /app/glance "$image" password:hash '<your password>' # GLANCE_PASSWORD_HASH
sudo scripts/edit_env.sh common, thensudo scripts/edit_env.sh <stack>for each stack with an.env.example.sudo scripts/deploy.shonce. It runs every one-time operation: on a rebuilt server whose data already went through them, runsudo scripts/run_operations.sh --mark-all-donefirst.- Task Scheduler:
bash /volume1/docker/homelab/scripts/deploy.shas root every 5 minutes, email on failure. - Backups: follow Backups, Setup. Until a backup succeeds, the deploy reports it.
- Settings sync: create a fine-grained GitHub token for this repository only, with Contents and Pull requests set
to read and write, and save it root-only on the NAS:
sudo sh -c 'umask 077; cat > /volume1/docker/homelab/.github-token'(paste, Enter, Ctrl-D). Turn on "Allow auto-merge" in the repository settings. Then Task Scheduler:bash /volume1/docker/homelab/scripts/sync_arr_settings.shas root every 15 minutes, email on failure. When the token expires, the job fails until you save a new one.
Pushing to main (or publishing an image Renovate picks up) deploys as root on the NAS. Keep 2FA on, and protect
main:
gh api -X PUT repos/Androlax2/homelab/branches/main/protection --input - <<'JSON'
{
"required_status_checks": {"strict": true, "contexts": ["stacks", "glance-config", "scripts", "secrets"]},
"enforce_admins": true,
"required_pull_request_reviews": {"required_approving_review_count": 0},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
JSONThe repo is public: secrets only in the NAS .env files, and security reviews are not committed. The NAS holds a
GitHub token for the settings sync: its pull requests may only change config/recyclarr/ (CI checks it).
- Deploy says
.env is missing/lacks keys:sudo scripts/edit_env.sh <stack>(orcommon). - Variables empty / "variable is not set": Compose was run directly; use
scripts/compose.sh. git merge --ff-onlyfails: local edits or a force push.git status, thengit reset --hard origin/main(.envfiles are gitignored, so they survive).- Glance won't start:
sudo scripts/compose.sh infrastructure logs glancenames the missing value. - Deploy says
Backups: …: the nightlybackup_nas.shtask didn't succeed. Open its last output in Task Scheduler, or runsudo bash scripts/backup_nas.shto see which step fails.