A modular homeserver setup using Docker Compose with Traefik reverse proxy, Authelia authentication, Pi-hole ad blocking, WireGuard VPN, n8n automation, and more.
homeserver/
├── .env # All environment variables (common + service-specific)
├── traefik/
│ └── docker-compose.yml # Traefik reverse proxy
├── authelia/
│ ├── docker-compose.yml # Authentication service
│ └── config/
├── <service>/
│ └── docker-compose.yml # Each service has its own docker-compose.yml
└── ... # More services can be added
https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository
Repo -> Settings -> Actions -> Runners -> New self-hosted runner Then follow the simple instructions
Install the runner service:
sudo ./svc.sh installEnable and start the service:
systemctl enable actions.runner.<TAB><TAB>
systemctl start actions.runner.<TAB><TAB>Create a .env file in the root directory:
cp .env.example .envEdit .env with ALL your values (common + service-specific):
# Common variables
DATADIR=/path/to/data/directory
CONFIGDIR=/path/to/config/directory
DOMAIN_NAME=example.com
LOCAL_IP=server.local.ip.address
SSL_EMAIL=your-email@example.com
TIMEZONE=Asia/Jerusalem
# User/Group IDs (get with: id -u and id -g)
PUID=1000
PGID=1000
# Cloudflare API token (for Traefik SSL certificates and DDNS)
CF_DNS_API_TOKEN=<your-cloudflare-api-token>
CF_DNS_ZONE_ID=<your-cloudflare-zone-id>
# Authelia secrets (generate using command below)
JWT_SECRET=<generated-secret>
SESSION_SECRET=<generated-secret>
STORAGE_ENCRYPTION_KEY=<generated-secret>
# SMTP configuration (for email notifications)
SMTP_USERNAME=your-email@gmail.com
SMTP_PASSWORD=<gmail-app-password>
# Servarr API Keys (found in Settings → General → Security in each app)
SONARR_API_KEY=<your-sonarr-api-key>
RADARR_API_KEY=<your-radarr-api-key>
PROWLARR_API_KEY=<your-prowlarr-api-key>
BAZARR_API_KEY=<your-bazarr-api-key>
# Media Server API Keys
JELLYFIN_API_KEY=<your-jellyfin-api-key>
JELLYSEER_API_KEY=<your-jellyseerr-api-key>
# qBittorrent credentials
QBIT_USER=<your-qbittorrent-username>
QBIT_PASS=<your-qbittorrent-password>
# WireGuard (WG-Easy) credentials for Homepage widget
WIREGUARD_USERNAME=<your-wireguard-username>
WIREGUARD_PASSWORD=<your-wireguard-password>
# Pi-hole API Key (found in Settings → API / Web interface)
PIHOLE_API_KEY=<your-pihole-api-key>
# CrowdSec (generate bouncer key after first deployment)
CROWDSEC_BOUNCER_KEY=<generate-after-deployment>
CROWDSEC_ENROLL_KEY=<optional-for-console>
# JOAL (Jack of All Trades torrent ratio faker)
JOAL_SECRET_TOKEN=<random-secret-string>
JOAL_SECRET_OBFUSCATION_PATH=<random-path-string>
# Restic Backup Configuration
RESTIC_PASSWORD=<generate-with-openssl-rand-base64-32>
# Paperless-ngx Configuration
PAPERLESS_SECRET_KEY=<generate-with-openssl-rand-base64-32>
# SparkyFitness Configuration
SPARKY_FITNESS_DB_NAME=sparkyfitness_db
SPARKY_FITNESS_DB_USER=sparky
SPARKY_FITNESS_DB_PASSWORD=<generate-with-openssl-rand-base64-32>
SPARKY_FITNESS_APP_DB_USER=<app-db-username>
SPARKY_FITNESS_APP_DB_PASSWORD=<generate-with-openssl-rand-base64-32>
SPARKY_FITNESS_API_ENCRYPTION_KEY=<generate-with-openssl-rand-base64-32>
SPARKY_FITNESS_JWT_SECRET=<generate-with-openssl-rand-base64-32>docker run --rm authelia/authelia:latest authelia crypto rand --length 64Run this command three times to generate the three secrets, then add them to .env.
To use Cloudflare for SSL certificates (via DNS challenge) and dynamic DNS updates:
- Go to Cloudflare API Tokens
- Click "Create Token"
- Use the "Edit zone DNS" template
- Configure permissions:
- Permissions:
Zone - DNS - Edit - Zone Resources:
Include - Specific zone - <your-domain>
- Permissions:
- Click "Continue to summary" and "Create Token"
- Add this token to your
.envfile asCF_DNS_API_TOKEN
To use Gmail for email notifications, you need to generate an app password:
- Go to Google App Passwords
- Enable 2-factor authentication if you haven't already
- Select "Mail" and your device
- Click "Generate" to get a 16-character password
- Add this password to your
.envfile asSMTP_PASSWORD
Set these as GitHub Actions variables/secrets (Settings → Secrets and variables → Actions):
Variables (public):
| Variable | Service | Notes |
|---|---|---|
DATADIR |
All | Path to data directory on server |
CONFIGDIR |
All | Path to config directory on server |
TIMEZONE |
All | Timezone (e.g., Asia/Jerusalem) |
Secrets (encrypted):
| Secret | Service | Notes |
|---|---|---|
DOMAIN_NAME |
All | Your domain name |
LOCAL_IP |
All | Your local server IP |
PUID |
All | User ID (typically 1000) |
PGID |
All | Group ID (typically 1000) |
SSL_EMAIL |
Traefik | Email for Let's Encrypt notifications |
CF_DNS_API_TOKEN |
Traefik, Cloudflare-DDNS | Cloudflare API token with DNS edit permissions |
CF_DNS_ZONE_ID |
Cloudflare-DDNS | Cloudflare zone ID (found in domain dashboard) |
JWT_SECRET |
Authelia | Generate with: docker run --rm authelia/authelia:latest authelia crypto rand --length 64 |
SESSION_SECRET |
Authelia | Generate with: docker run --rm authelia/authelia:latest authelia crypto rand --length 64 |
STORAGE_ENCRYPTION_KEY |
Authelia | Generate with: docker run --rm authelia/authelia:latest authelia crypto rand --length 64 |
SMTP_USERNAME |
Authelia | Your Gmail address (e.g., your-email@gmail.com) |
SMTP_PASSWORD |
Authelia | Gmail app password (generate at https://myaccount.google.com/apppasswords) |
SONARR_API_KEY |
Recyclarr, Unpackerr, Homepage | Sonarr API key (found in Sonarr → Settings → General → Security) |
RADARR_API_KEY |
Recyclarr, Unpackerr, Homepage | Radarr API key (found in Radarr → Settings → General → Security) |
PROWLARR_API_KEY |
Homepage | Prowlarr API key (found in Prowlarr → Settings → General → Security) |
BAZARR_API_KEY |
Homepage | Bazarr API key (found in Bazarr → Settings → General → Security) |
JELLYFIN_API_KEY |
Homepage | Jellyfin API key (create in Dashboard → API Keys) |
JELLYSEER_API_KEY |
Homepage | Jellyseerr API key (found in Settings → General) |
QBIT_USER |
Homepage | qBittorrent username |
QBIT_PASS |
Homepage | qBittorrent password |
WIREGUARD_USERNAME |
Homepage | WireGuard (WG-Easy) username |
WIREGUARD_PASSWORD |
Homepage | WireGuard (WG-Easy) password |
PIHOLE_API_KEY |
Homepage | Pi-hole API key (found in Settings → API / Web interface) |
CROWDSEC_BOUNCER_KEY |
Traefik, CrowdSec | Generate with: docker exec crowdsec cscli bouncers add traefik-bouncer (after first CrowdSec deployment) |
CROWDSEC_ENROLL_KEY |
CrowdSec | Optional - For CrowdSec Console enrollment (get from https://app.crowdsec.net/) |
JOAL_SECRET_TOKEN |
JOAL | Random secret string for UI authentication |
JOAL_SECRET_OBFUSCATION_PATH |
JOAL | Random path string to obfuscate UI URL (e.g., my-secret-path-123) |
PAPERLESS_SECRET_KEY |
Paperless-ngx | Generate with: openssl rand -base64 32 |
SPARKY_FITNESS_DB_NAME |
SparkyFitness | PostgreSQL database name (e.g., sparkyfitness_db) |
SPARKY_FITNESS_DB_USER |
SparkyFitness | PostgreSQL database user (e.g., sparky) |
SPARKY_FITNESS_DB_PASSWORD |
SparkyFitness | PostgreSQL database password - Generate with: openssl rand -base64 32 |
SPARKY_FITNESS_APP_DB_USER |
SparkyFitness | Application database username |
SPARKY_FITNESS_APP_DB_PASSWORD |
SparkyFitness | Application database password - Generate with: openssl rand -base64 32 |
SPARKY_FITNESS_API_ENCRYPTION_KEY |
SparkyFitness | API encryption key - Generate with: openssl rand -base64 32 |
SPARKY_FITNESS_JWT_SECRET |
SparkyFitness | JWT authentication secret - Generate with: openssl rand -base64 32 |
Copy the example file and edit it:
cp authelia/config/users.yaml.example authelia/config/users.yamlConfigure hashed password:
docker run --rm -it authelia/authelia:4.39.11 authelia crypto hash generate argon2To configure Cloudflare Dynamic DNS:
- Copy the example configuration file to your config directory:
cp cloudflare-ddns/config.json.example ${CONFIGDIR}/cloudflare-ddns/config.json- Edit the configuration file and update the required fields:
nano ${CONFIGDIR}/cloudflare-ddns/config.jsonKey fields to configure:
zone_id: Your Cloudflare zone ID (found in your domain's dashboard)subdomains: Configure which subdomains to update (@for root domain)proxied: Set totrueto enable Cloudflare proxy, orfalsefor direct DNS
Note: The ${CF_DDNS_API_TOKEN} will be automatically substituted from your environment variables.
CrowdSec protects your homeserver by analyzing Traefik access logs and blocking malicious IPs using the Traefik bouncer plugin.
- Deploy CrowdSec (along with Traefik):
export $(cat .env | xargs)
docker compose -f traefik/docker-compose.yml up -d
docker compose -f crowdsec/docker-compose.yml up -d- Generate a bouncer API key:
docker exec crowdsec cscli bouncers add traefik-bouncerThis command will output an API key. Copy it and add it to your .env file as CROWDSEC_BOUNCER_KEY.
- Restart Traefik to apply the bouncer key:
docker compose -f traefik/docker-compose.yml restart- (Optional) Enroll in the CrowdSec Console for centralized management:
docker exec crowdsec cscli console enroll <your-enroll-key>Get your enrollment key from: https://app.crowdsec.net/
A self-hosted Metabase dashboard is available at https://crowdsec.${DOMAIN_NAME} for visualizing CrowdSec metrics, alerts, and decisions.
Initial Setup (First Time Only):
-
Access the dashboard at
https://crowdsec.${DOMAIN_NAME} -
Complete the Metabase setup wizard:
- Set your preferred language
- Create an admin account
- Skip "Add your data" (we'll add it manually)
-
Add CrowdSec database connection:
- Click "Settings" (gear icon) → "Admin settings" → "Databases" → "Add database"
- Database type: SQLite
- Display name: CrowdSec
- Filename:
/crowdsec-db/crowdsec.db - Click "Save"
For services with Authelia:
- traefik.http.routers.<service>.middlewares=crowdsec-bouncer@docker,authelia-forwardauth@dockerFor services without Authelia:
- traefik.http.routers.<service>.middlewares=crowdsec-bouncer@dockerTo add CrowdSec to new services in the future, simply add the appropriate middleware label to the service's Traefik configuration.
-
Set the two required secrets in your
.envfile:JOAL_SECRET_TOKEN: A random secret string for authentication (e.g., useopenssl rand -hex 32)JOAL_SECRET_OBFUSCATION_PATH: A random path to hide the UI URL (e.g.,my-secret-path-123)
-
Access the web UI at:
http://${LOCAL_IP}:8584/${JOAL_SECRET_OBFUSCATION_PATH}/ui
Automated backup solution using Restic with Google Drive (via rclone) as the backend storage.
- Automated Backups: Daily backups at 3:00 AM
- Intelligent Storage: Deduplication and compression to save space
- Retention Policy: Keeps 7 daily, 4 weekly, 4 monthly
- Encryption: All data encrypted with AES-256 before upload
- Google Drive Backend: Cloud storage via rclone
- Generate a strong password for encryption:
openssl rand -base64 32Add this to your .env file as RESTIC_PASSWORD.
-
Configure Google Drive authentication with rclone (see
restic/README.mdfor detailed instructions) -
Configure which directories to backup in
restic/config/backup-paths.txt -
Configure backup path in gdrive in
RESTIC_REPOSITORYenvironment variable. -
Run initial backup:
docker exec restic /scripts/backup.shFor detailed documentation, usage examples, and troubleshooting, see restic/README.md.
Unmanic re-encodes the media library from H.264 to HEVC to reclaim disk space. Reachable at
https://unmanic.${DOMAIN_NAME} (behind Authelia — Unmanic has no authentication of its own).
Unmanic's configuration is not GitOps. It lives in SQLite plus JSON under /config
(${DATADIR}/unmanic/config), and is edited through the web UI, not this repo. Two consequences:
${DATADIR}/unmanic/configis inrestic/config/backup-paths.txt. The cache directory is deliberately excluded — it holds multi-GB transcode scratch files.unmanic/library-config.jsonis a committed export of the whole setup: library config, enabled plugins, every plugin's settings, and flow order.
# substitute the ${...} placeholders from .env, then import
python3 - <<'EOF'
import json, os, re, urllib.request
cfg = open('unmanic/library-config.json').read()
cfg = re.sub(r'\$\{(\w+)\}', lambda m: os.environ[m.group(1)], cfg)
body = json.dumps(json.loads(cfg)).encode()
req = urllib.request.Request('http://localhost:8888/unmanic/api/v2/settings/library/import',
data=body, headers={'Content-Type': 'application/json'})
print(urllib.request.urlopen(req).read())
EOFExport it again after any UI change:
docker exec unmanic curl -s -X POST -H 'Content-Type: application/json' \
-d '{"id":1}' http://localhost:8888/unmanic/api/v2/settings/library/exportStrip the API keys back to ${SONARR_API_KEY} / ${RADARR_API_KEY} /
${UNMANIC_JELLYFIN_API_KEY} before committing — the raw export embeds them verbatim.
Unmanic stores plugin settings twice: userdata/<plugin>/settings.json (global) and
settings.<library_id>.json (per-library, which wins when present). This deployment uses globals
only — the per-library files were removed, so Settings → Plugins in the UI is the single place to
configure everything. If you add a per-library override later, remember it silently takes precedence.
Which plugins are enabled and their flow order remain per-library (enabledplugins and
librarypluginflow tables) — Unmanic has no global equivalent.
video_transcoder in standard mode: libx265, preset medium, CRF 21, 8-bit, container
preserved, audio and subtitles stream-copied.
mode: standard is load-bearing. In the default basic mode the preset and CRF fields are hidden and
the plugin hardcodes -crf 28, which is visibly worse.
8-bit rather than 10-bit is a deliberate client-compatibility choice: 10-bit would be slightly more efficient but pushes older clients into live transcoding.
TRaSH scores the x265 (HD) custom format at -10000 to discourage third-party HD x265 re-encodes.
Left at that value, Sonarr re-evaluates Unmanic's own output as x265 (HD), and because the Sonarr
profiles have upgrade.allowed: true, it searches for an x264 replacement — silently undoing every
transcode and re-downloading.
recyclarr/recyclarr.yml therefore assigns that custom format score: 0 on the WEB-1080p,
WEB-2160p, and 1080p/4K Sonarr profiles. The Anime profile never included it and
reset_unmatched_scores leaves it at 0 there. Radarr keeps the penalty — all its profiles have
upgrade.allowed: false, so nothing can be replaced.
If you ever re-add that custom format to a Sonarr profile at its TRaSH default, transcoded episodes will start being replaced.
Deletes already-watched movies and TV seasons once free space gets tight, at
https://maintainerr.${DOMAIN_NAME} (behind Authelia — it has no auth of its own).
Chosen over Janitorr and Jellysweep because both delete what nobody has touched recently, which
targets the unwatched backlog first. Maintainerr reads Jellyfin's watch state natively, so no
Jellystat/Tautulli is needed, and it gates on diskspace_remaining_gb from the *arr API.
Config is UI-managed in ${DATADIR}/maintainerr/data/maintainerr.sqlite (in
restic/config/backup-paths.txt). Rule conditions can be exported as YAML from the rule editor, but
the endpoint reports a skipped count, so treat that as a restore aid rather than the source of truth.
${DATADIR}/library is mounted at /data to match Sonarr and Radarr exactly — leftover-folder
cleanup resolves paths as the *arrs report them, so a different mount point silently finds nothing.
- *The arr action must stop the re-download.
newtarrandprefetcharrwill re-grab anything merely deleted — the same fight documented in the Unmanic section above. Movies useDelete, which removes the film from disk and from Radarr, so there is nothing left to re-grab. Seasons useUnmonitor and delete existing, which unmonitors only that season and leaves the series monitored, so future seasons still download. NoteUnmonitor and delete all filesis silently unsupported for season collections — the Sonarr handler logs "not supported for type: season" and does nothing, so do not pick it here. - There is no dry-run; the collection is the preview. Rules run every 8h, the handler every 12h.
Build rules with the action on
Do nothingfirst and inspect membership before arming them. /data/libraryis not backed up — only app config is, so the *arr recycle bin is the only undo. Radarr uses/data/.recycle/movies, Sonarr/data/.recycle/tv(host${DATADIR}/library/.recycle/*), both with a 7-day cleanup. Deliberately placed as a sibling ofmedia/rather than inside it, so Jellyfin never indexes it, and on the same filesystem so deletes are instant renames not copies. Note the trade-off: a recycled file still occupies the disk, so space is not truly reclaimed until the 7-day cleanup runs. That is the reason the window is 7 days and not 30.- Wire up qBittorrent (
Settings → Download Client). Without it, a file still seeding won't free space when deleted. With it, Maintainerr honours the client's seeding goal, falls back todownload_client_fallback_ratio(default 0.5) when no limit is set, and skips cross-seeded torrents. - Exclusions work via a rule condition, not the settings toggle. Every rule group leads with
tags not contains dnd(Radarr.tagsfor movies,Sonarr.tagsfor seasons), so taggingdndin the *arr protects an item across all tiers at once. Tagging a series protects every one of its seasons. TheRadarr/Sonarr tag exclusionssetting is write-only and does not filter anything on its own — nothing in the rules engine reads it. It exists so that clickingExclin the Maintainerr UI also stampsdndinto the *arr. That is worth leaving on, because it makes UI exclusions feed the rule condition above and become visible in Sonarr/Radarr. - Prefer tags over the UI
Exclbutton. An exclusion created from a collection can be scoped to that single rule group, which with six tiers would leave the other five still able to catch the item. Adndtag has no such footgun.
Thresholds are tiered so cleanup stays dormant until space runs short. The grace period shortens with each tier too — otherwise every tier reacts at the same speed and the emergency tier is no more urgent than the relaxed one, just broader:
| Tier | Free space | Deletes watched older than | Grace before action |
|---|---|---|---|
| 1 | < 1000 GiB | 90 days | 30 days |
| 2 | < 500 GiB | 30 days | 14 days |
| 3 | < 250 GiB | 7 days | 3 days |
Set arrDiskPath per rule (/data/media/movies, /data/media/tv) so it reads that root folder instead
of aggregating across every path the *arr reports.
Ensure the following ports are open in your firewall:
| Port | Protocol | Service | Purpose |
|---|---|---|---|
| 80 | TCP | Traefik | HTTP (auto-redirects to HTTPS) |
| 443 | TCP | Traefik | HTTPS |
| 51820 | UDP | WG-Easy | WireGuard VPN |
Important: Always export environment variables first before running any docker compose commands:
export $(cat .env | xargs)Deploy services in the following order:
1. Deploy Traefik first (creates required networks):
docker compose -f traefik/docker-compose.yml up -d2. Deploy all other services automatically:
for dir in */; do [ -f "$dir/docker-compose.yml" ] && [ "$dir" != "traefik/" ] && docker compose -f "$dir/docker-compose.yml" up -d; doneOr deploy individual services manually:
docker compose -f authelia/docker-compose.yml up -dNote: The export $(cat .env | xargs) command loads environment variables from the root .env file into your shell, making them available to docker-compose for variable substitution. Remember to run this command in each new shell session.
After deployment, you can access your services at:
- Authelia:
https://auth.${DOMAIN_NAME} - Pi-hole:
https://pihole.${DOMAIN_NAME}(requires Authelia login) - WG-Easy:
https://wg-easy.${DOMAIN_NAME}(requires Authelia login) - n8n:
https://n8n.${DOMAIN_NAME}(requires Authelia login)
On first access to protected services, you'll be redirected to Authelia for authentication using the credentials configured in authelia/config/users.yaml.
This project uses Renovate for automated dependency updates.
- Schedule: Sundays between 3:00 AM - 7:00 AM (Asia/Jerusalem timezone)
- Auto-merge: Minor and patch version updates are automatically merged
- Manual review: Major version updates require manual approval
- Discovery: Automatically finds all
docker-compose.ymlfiles in the repository - Security: Docker images are pinned with digests for immutable references
Renovate configuration is in .github/renovate.json
-
Install restic and rclone.
-
Authenticate rclone with
rclone.conffile
docker run --rm -it -v $(pwd):/config/rclone rclone/rclone:latest config reconnect "gdrive:" --config /config/rclone/rclone.confChoose "No" and run the rclone command from output in other terminal window to get the token, then insert it.
- Set those 3 environment variables
export RESTIC_REPOSITORY=rclone:gdrive:<RESTIC_REPO_PATH_INSIDE_GDRIVE>
export RCLONE_CONFIG=<PATH_TO_rclone.conf>
export RESTIC_PASSWORD=<RESTIC_PASSWORD>-
List snapshots
restic snapshots -
restic restore <SNAPSHOT_ID> --target <TARGET_DIR>