The requirements for this WIP homelab were the following:
- Easy to modify, ideally with
docker composeservices - Accessible remotely
- Should have automated + periodic (+ deduplicated) backups in place
- Easily memorizable services (aka reachable at
service.homeinstead ofIP_SERVER:PORT_SERVICE) - Have some observability + monitoring in place
flowchart TB
subgraph CLIENTS["Client devices"]
phone["📱 Phone"]
laptop["💻 Laptop"]
end
tailscale{{"Tailscale VPN<br/>WireGuard overlay + subnet routing"}}
router["Router<br/>primary DNS → AdGuard"]
subgraph SERVER["Linux server"]
adguard["AdGuard Home<br/>DNS :53"]
caddy["Caddy (reverse proxy)<br> *.home → :80/:443"]
subgraph APPS["Services"]
direction LR
immich["Immich"]
paperless["Paperless"]
vikunja["Vikunja"]
memos["Memos"]
netdata["Netdata"]
uptimekuma["Uptime Kuma"]
filebrowser["Filebrowser"]
end
subgraph BACKUPS["Backups"]
backrest["Backrest"]
repo[("restic repo<br/>homelab-backups<br/>/mnt/backup")]
end
end
ntfy["ntfy.sh<br/>(external, public instance)"]
phone -. Tailscale .-> tailscale
laptop -. Tailscale .-> tailscale
phone -- LAN --> router
laptop -- LAN --> router
tailscale -. "subnet route (e.g. 192.168.200.0/24)" .-> router
router --> adguard
adguard -. "*.home DNS rewrite" .-> caddy
caddy --> immich
caddy --> paperless
caddy --> vikunja
caddy --> memos
caddy --> netdata
caddy --> uptimekuma
caddy --> filebrowser
caddy --> adguard
caddy --> backrest
backrest -. "daily snapshots" .-> repo
backrest -. "backs up" .-> immich
backrest -. "backs up" .-> paperless
backrest -. "backs up" .-> vikunja
backrest -. "backs up" .-> memos
backrest -. "backs up" .-> uptimekuma
backrest -. "backs up conf" .-> adguard
uptimekuma -. alerts .-> ntfy
netdata -. alerts .-> ntfy
ntfy -. push .-> phone
classDef client fill:#5878a3,stroke:#3d5876,color:#ffffff;
classDef infra fill:#6a4f96,stroke:#4a3768,color:#ffffff;
classDef app fill:#2f7d5c,stroke:#215a41,color:#ffffff;
classDef backup fill:#a8672a,stroke:#7a4a1e,color:#ffffff;
classDef external fill:#6b6b6b,stroke:#4d4d4d,color:#ffffff;
class phone,laptop client
class tailscale,router,adguard,caddy infra
class immich,paperless,vikunja,memos,netdata,uptimekuma,filebrowser app
class backrest,repo backup
class ntfy external
| Color | Group |
|---|---|
| 🔵 Blue | Client devices |
| 🟣 Purple | Network / infra (Tailscale, router, AdGuard, Caddy) |
| 🟢 Green | App services |
| 🟠 Orange | Backups (Backrest + restic repo) |
| ⚪ Grey | External services (ntfy.sh) |
| Service | Docs | Description |
|---|---|---|
| Tailscale | docs | Zero-config VPN — securely access all homelab services from anywhere |
| Caddy | docs | Reverse proxy — routes *.home domains to the right service |
| AdGuard Home | docs | Network-wide DNS server — resolves .home domains and blocks ads |
| Immich | docs | Self-hosted Google Photos alternative — photo/video backup and browsing |
| Paperless-ngx | docs | Document management — OCRs, tags, and indexes PDFs and scanned documents |
| Backrest | docs | Backup UI on top of restic — scheduled, deduplicated backups |
| Netdata | docs | Real-time server monitoring — CPU, memory, disk, network, Docker stats |
| Uptime Kuma | docs | Uptime monitor — alerts when a service goes down |
| ntfy | docs | Push notifications — delivers alerts from Uptime Kuma and Netdata to your phone |
| Vikunja | docs | Task management — self-hosted to-do lists, projects, and kanban boards |
| Memos | docs | Note-taking — lightweight, markdown-native quick capture |
| Filebrowser | docs | Web file browser — browse, upload, and download files over the network |
Tailscale is a zero-config VPN (built on WireGuard) that lets you securely access your homelab services from anywhere — phone, laptop, work PC — without opening ports on your router.
- Create a free account at tailscale.com
- Install Tailscale on your server:
curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up - Install the Tailscale app on each client device (Android, iOS, Windows, Mac) and sign in with the same account.
All your devices are now on a private network. Your server gets a stable Tailscale IP (100.x.x.x) that works from anywhere.
The homelab uses two Tailscale features together so that *.home domains work identically from any Tailscale-connected device, with no separate URLs or ports to remember.
1. Subnet routing — exposes your LAN subnet to Tailscale clients so they can reach LAN IPs - let's assume that your LAN subnet is 192.168.200.X:
sudo tailscale set --advertise-routes=192.168.200.0/24Then approve the route in the Tailscale admin console → your server → Edit route settings → enable 192.168.200.0/24.
On each client device, enable route acceptance:
- Tailscale app → Settings → Use Tailscale subnets (macOS/iOS/Android)
- Linux:
sudo tailscale set --accept-routes
2. Split DNS — tells Tailscale clients to use AdGuard for .home resolution:
- In Tailscale admin → DNS → Nameservers → Add nameserver → Custom
- IP: your server's Tailscale IP (
tailscale ipon the server) - Check Restrict to domain → enter
home - Save
AdGuard already resolves *.home to the server's LAN IP. With subnet routing active, that IP is reachable through the Tailscale tunnel. immich.home, paperless.home, etc. work from anywhere.
Why subnet routing and not just pointing AdGuard to the Tailscale IP? The alternative would be changing AdGuard's DNS rewrites to resolve
*.hometo the server's Tailscale IP (100.x.x.x) — Tailscale clients could then reach it without subnet routing. But100.x.x.xis not routable on the LAN, so any device without Tailscale (guests, IoT, etc.) would lose access to*.home. Subnet routing keeps AdGuard resolving to the LAN IP, which works for everyone.
- Tailscale overrides system DNS via
100.100.100.100— split DNS for thehomedomain ensures.homequeries go to AdGuard rather than the public internet - The free plan supports up to 100 devices, which is more than enough for a homelab
- No ports need to be forwarded on your router
Immich is the FOSS equivalent of Google Photos. To set it up:
-
Get a
.envfile - default one from Immich is fine. Put it in theimmichdirectory. -
Bring Immich up:
cd immich docker compose up -d
Then it should be up and running on port 2283. First steps:
- Create an admin user on the web UI
- Create a user for your friends/partner etc (Top right: Administration -> Users -> Create user)
- Download the Immich app on your phone, setup the Immich service
IP:PORTand tap the "Backup" button. Select the directory you wanna backup (usually "Camera") and "Enable Backup" (this will take some time to finish...). Other users should do the same.
Backrest is a web UI backup solution built on top of restic. We use a single repo and a set of per-service plans to back up every service's critical data (see Backup plans below).
- Create an
.envfile and populate it properly (using theexample.envas a base) - Bring it up:
cd backrest docker compose up -d
Then it should be up and running on port 9898. First steps:
-
Create an instance ID (e.g.
homelab-main) and a user (e.g.admin) with a password -
Go to
Repositories -> Add Repo:- Repo name:
homelab-backups - Repository URI:
/backup/restic-repo - Pick a strong password
- Leave the rest as default
- Repo name:
-
Go to
Plans -> Add Plan:- Plan name:
immich-live - Repository: Select
homelab-backups - Paths:
/source/homelab/immich/library
- Backup schedule: Use following cron expression to backup everyday at 3 a.m.
0 3 * * *
- Plan name:
-
To verify this setup works:
- Go to
immich-liveplan, andBackup now(this will take some time) - After it's done, you can select the backup, go to Snapshot Browser, select a file or directory, and on the
...select "Restore to path". Pick a path, and check that the file/directory was restored fine at the desired location. - Follow-up backups should be fast and small, since only changed chunks are stored.
- Go to
All plans below back up into the single homelab-backups repo (/mnt/backup/restic-repo), each on its own daily cron and a daily: 7, weekly: 4, monthly: 6 retention policy (except where noted):
| Plan | Path | Covers |
|---|---|---|
immich-live |
immich/library |
Photo/video originals, thumbs, encoded video — and Immich's own built-in daily pg_dump (writes to library/backups/, enabled by default), so the Postgres DB is covered without a separate raw-snapshot plan |
paperless-media |
paperless/media |
Original + archived documents |
paperless-db |
paperless/pgdata |
Paperless Postgres data dir (raw file snapshot — Paperless has no built-in dump feature, so this isn't guaranteed point-in-time consistent; acceptable risk for now) |
vikunja-files |
vikunja/files |
Task attachments |
vikunja-db |
vikunja/db |
Vikunja Postgres data dir (same raw-snapshot caveat as paperless-db) |
adguard-conf |
adguard/conf |
DNS rewrites and settings |
uptime-kuma-data |
uptime-kuma/data |
Monitors, notification config |
memos-data |
memos/data |
Notes (SQLite DB + attachments) |
backrest-config |
backrest/config |
Backrest's own config — critical: this is the only place the restic repo's encryption password lives, so if it's lost with no backup, the entire repo becomes permanently undecryptable |
Why
paperless-db/vikunja-dbare raw snapshots, not dumps: Immich ships a built-in scheduled DB dump, so backing up its data directory is safe. Paperless and Vikunja don't have an equivalent feature, so these two plans snapshot the live Postgres data directory directly via restic. This isn't guaranteed to produce a consistent restore point under heavy concurrent writes — if this matters to you, add a pre-backup hook (Backrest supports these per-plan) that runsdocker exec paperless-db pg_dump .../docker exec vikunja-db pg_dump ...into a dump file before the snapshot, and point the plan at the dump file instead.
Paperless-ngx is a document management system — scan or import PDFs and images, and it OCRs, tags, and indexes them so they're searchable.
- Create a
.envfile fromexample.envand fill in the values:cp example.env .env # edit PAPERLESS_DB_PASSWORD, PAPERLESS_SECRET_KEY, PAPERLESS_ADMIN_PASSWORD # set USERMAP_UID/GID to match your host user (run: id $USER)
- Bring it up:
cd paperless docker compose up -d
Then it should be up and running at http://paperless.home. First steps:
- Log in with the admin credentials you set in
.env - Go to Settings → Tags and create tags for your document categories (e.g.
finance,medical,insurance) - Drop documents into the
consume/folder — Paperless will OCR and ingest them automatically within a minute - Alternatively, use Upload in the UI to add documents directly
- Set up correspondents (senders/organisations) and document types under Settings for better organisation
consume/ folder tip: You can point your scanner's output folder or a cloud sync folder at
paperless/consume/for fully automatic ingestion.
Paperless can poll your Gmail and automatically consume attachments from emails that land in your paperless label. Mail accounts are configured through the Paperless web UI — no env vars needed.
In Gmail → Settings (⚙️) → See all settings → Forwarding and POP/IMAP → IMAP access → Enable IMAP → Save.
Regular Gmail passwords don't work with IMAP when 2FA is enabled. You need an App Password:
- Go to myaccount.google.com/apppasswords (requires 2FA to be active)
- App name:
paperless→ Create - Copy the 16-character password — you'll only see it once
Go to http://paperless.home → Settings → Mail → Mail Accounts → Add:
| Field | Value |
|---|---|
| Name | Gmail |
| IMAP server | imap.gmail.com |
| IMAP port | 993 |
| Username | your full Gmail address |
| Password | the 16-char App Password |
Click Test to verify the connection, then Save.
Go to Mail Rules → Add:
| Field | Value |
|---|---|
| Name | Paperless label |
| Account | Gmail |
| Folder | paperless ← Gmail label name, exactly as you created it |
| Filter | All mail |
| Action | Consume documents from attachments |
| Attachment type | Everything (or PDFs and images if you want to be stricter) |
| Mark as read | ✅ |
| Action for processed mail | Move to folder → paperless/processed (create this nested label in Gmail first) |
Gmail labels as IMAP folders: Gmail exposes labels as IMAP folders. A label named
paperlessappears as the folderpaperless. Nested labels (paperless/processed) appear aspaperless/processed.
Emails without attachments: For emails from specific senders (e.g. gemeente) that don't have attachments, the email body itself won't be consumed — only attachments are. For these, manually forward the email as a PDF (print → save as PDF → drop in
consume/), or set the rule's attachment action to also consume the email body as PDF (available in Paperless ≥ 2.x under Action → Consume document from mail text).
Paperless polls the mailbox every 10 minutes by default.
Not all emails are being picked up
Paperless only processes unread emails. If you applied a Gmail filter retroactively, some emails may have been skipped or re-marked as read before processing. To re-trigger ingestion:
- In Gmail, search for
label:paperless is:read has:attachment filename:pdf, select all, and mark as unread. - In Paperless UI → Settings → Mail, click Process mail.
- If nothing happens, restart the container:
docker compose restart webserver— the mail task can get stuck silently with no errors logged.
Paperless has duplicate detection (content checksum), so re-processing already-imported emails is safe — they'll be skipped automatically.
Mail task runs but processes 0 emails with no errors
This usually means the celery mail worker is stuck. A container restart fixes it. There will be no error in the logs — the task just silently does nothing until restarted.
The recommended way to push a photo of a document from your phone directly into Paperless is the Paperless Mobile app.
- Android: Play Store / F-Droid
- iOS: App Store
- Open the app and tap Add server.
- Server URL:
http://paperless.home(works on LAN and via Tailscale). - Log in with your Paperless username and password.
- Tap + → Scan to use the phone camera. The app stitches multi-page scans into a single PDF.
- Tap + → Upload to pick an existing photo from your gallery.
- Before uploading you can set the title, tags, correspondent, and document type directly in the app.
- Documents land in Paperless immediately — no
consume/folder involved.
Tip: Keep the app's default tags set to something like
mobile-scanso you can easily filter and review phone uploads later.
If you prefer a folder-drop workflow (e.g. using a document scanner app like Microsoft Lens that saves to a local folder):
- Add a
syncthingservice to the stack (see proposal below or add it yourself). - Share a folder from your phone (e.g.
Documents/Scan) with the server, syncing to./paperless/consume/. - Any file saved to that phone folder is automatically picked up and consumed by Paperless.
Paperless-ngx has full multi-user support with per-document ownership and sharing.
Go to http://paperless.home/admin/ → Authentication → Users → Add user. Fill in username and password, then under Permissions check Staff status if you want them to be able to manage tags/correspondents too (optional).
Alternatively via shell:
docker exec -it paperless python manage.py createsuperuser
Both of you add the same server URL but log in with your own credentials. Scans go into each person's account.
By default each document is only visible to its owner. To share:
- Per-document: open the document → Edit → Permissions → add the other user under View or Edit.
- Global default: in Settings → Permissions you can configure new documents to be shared with a specific group by default.
Proposed shared tagging convention:
| Tag | Meaning |
|---|---|
shared |
both partners should see this document |
jacob / partner-name |
personal docs — default for mobile scans from each person's account |
mobile-scan |
needs review before final tagging |
Create a group household in /admin/ → Authentication → Groups, add both users to it. Then in Settings → Permissions, set the default view group to household so all new documents are visible to both of you out of the box.
Netdata is a real-time server monitoring tool — CPU, memory, disk, network, and Docker container stats out of the box.
- Bring it up:
cd netdata docker compose up -d
Then it should be up and running at http://netdata.home (works on LAN and via Tailscale). No initial setup required — the dashboard is live immediately with CPU, memory, disk, network, and per-container stats.
The compose file mounts /proc, /sys, and other host paths read-only so Netdata can see real host metrics rather than just the container's view. The docker.sock mount gives it per-container CPU/memory/network breakdown.
Netdata ships with hundreds of pre-built alert rules (high CPU, low disk, OOM, etc.) that fire automatically. To receive them somewhere useful:
- Open a shell in the container:
docker exec -it netdata bash - Edit the notification config:
cd /etc/netdata && ./edit-config health_alarm_notify.conf
- Find your provider (Telegram, email, Slack, etc.) and fill in the credentials — each has a clearly labelled block in the file.
- Test it:
sudo -u netdata /usr/libexec/netdata/plugins.d/alarm-notify.sh test
Config changes are persisted via the ./config volume mount.
Uptime Kuma is a self-hosted monitoring tool that tracks whether your services are up and alerts you when they go down.
- Bring it up:
cd uptime-kuma docker compose up -d
Then it should be up and running at http://uptime-kuma.home. First steps:
- Create an admin account on first visit
- Add a monitor per service — use HTTP(s) type with the following URLs:
http://host.docker.internal:2283(Immich)http://host.docker.internal:9898(Backrest)http://host.docker.internal:8080(AdGuard)http://host.docker.internal:8000(Paperless-ngx)http://host.docker.internal:5230(Memos)
Why
host.docker.internal? Uptime Kuma runs inside a container, solocalhostrefers to the container itself — not the host.host.docker.internalis a hostname that Docker resolves to the host machine's IP, letting the container reach services bound to the host. On Linux this requires theextra_hosts: host.docker.internal:host-gatewayline in the compose file (already set).
- Configure notifications under Settings → Notifications — see the ntfy section below for the recommended setup.
ntfy is a simple, open-source push notification service. You publish a message to a topic (a URL like https://ntfy.sh/your-topic) and any subscribed device gets a push notification instantly. No account required when using the public instance.
- Install the app on your phone: Android (Play Store / F-Droid) or iOS (App Store).
- Pick a topic name. Topics are public by default on
ntfy.sh, so use something unguessable:abc123-homelab-xyz789 - Subscribe to the topic in the app: tap + → enter
https://ntfy.sh/abc123-homelab-xyz789. - Hook up Uptime Kuma:
- Go to Settings → Notifications → Add Notification
- Type: ntfy
- Server URL:
https://ntfy.sh - Topic:
abc123-homelab-xyz789 - Save and use Test to verify a notification arrives on your phone.
- Assign the notification to monitors: edit each monitor and add the ntfy notification under the Notifications field.
Note: The public
ntfy.shinstance is free and reliable for low-volume personal use. Messages are not end-to-end encrypted, so avoid sending sensitive data in alert bodies.
- Self-host ntfy as a Docker service in this stack so alerts don't rely on an external service.
- Lock the topic down with access control so only the server can publish to it.
- Point Netdata alert notifications at the same ntfy topic.
Caddy is a reverse proxy that routes *.home domains to the appropriate services, so you don't need to remember ports.
- Bring it up:
cd caddy docker compose up -d
Services are available at http://<service>.home — both on the LAN and via Tailscale (see Tailscale → Accessing services remotely):
http://immich.homehttp://backrest.homehttp://paperless.homehttp://adguard.homehttp://uptime-kuma.homehttp://netdata.homehttp://vikunja.homehttp://memos.homehttp://filebrowser.home
Direct IP:PORT access still works in parallel as a fallback.
Note: Caddy joins the Docker networks of each service (
immich_default,backrest_default,adguard_default, etc.) and proxies by container name — no IP addresses needed in the config. When adding a new service, add its network tocaddy/docker-compose.ymland recreate the container withdocker compose up -d --force-recreate.
AdGuard Home is a network-wide DNS server. It resolves .home domains to your server's LAN IP and blocks ads/trackers for all devices on the network.
-
Bring it up:
cd adguard docker compose up -d -
Complete the one-time setup wizard at
http://SERVER_IP:3000. After setup the UI moves tohttp://SERVER_IP:8080(orhttp://adguard.homeonce DNS is working). -
In the AdGuard UI, add upstream DNS servers (Settings → DNS settings):
https://1.1.1.1/dns-queryhttps://8.8.8.8/dns-query
-
Add DNS rewrites (Filters → DNS rewrites) for each service:
immich.home→SERVER_LAN_IPbackrest.home→SERVER_LAN_IPpaperless.home→SERVER_LAN_IPadguard.home→SERVER_LAN_IPuptime-kuma.home→SERVER_LAN_IPnetdata.home→SERVER_LAN_IPvikunja.home→SERVER_LAN_IPmemos.home→SERVER_LAN_IPfilebrowser.home→SERVER_LAN_IP
-
Set your router's primary DNS server to
SERVER_LAN_IPand secondary to1.1.1.1.
- Tailscale devices: handled via subnet routing + split DNS — see Tailscale → Accessing services remotely. No extra AdGuard config needed.
- Android phones: Android may prefer IPv6 DNS servers advertised by the router via Router Advertisement, bypassing AdGuard. Workaround: set Private DNS to Off on each phone, or disable IPv6 on the router.
- VPN clients: Third-party VPNs (e.g. ProtonVPN) own DNS while active.
.homedomains won't resolve through them — disable the VPN when on the home network.
Vikunja is a self-hosted task management app — create projects, to-do lists, and kanban boards. Think Todoist or Trello, but yours.
- Create a
.envfile fromexample.envand fill in the values:cp example.env .env # edit VIKUNJA_DB_PASSWORD, VIKUNJA_SECRET # set VIKUNJA_PUBLIC_URL to the URL your browser reaches Vikunja at
- Create the
filesdirectory and set ownership so Vikunja (which runs as UID 1000) can write to it:mkdir -p vikunja/files chown 1000 vikunja/files
- Bring it up:
cd vikunja docker compose up -d
Then it should be up and running at http://vikunja.home. First steps:
- Register the first account — this becomes the admin user
- Register an account for your friend/partner (and repeat for any other users). This can only done via the CLI in the
self-hosted setup as follows:
docker exec -it vikunja /app/vikunja/vikunja user create -e "email" -p "password" -u "username"
- Create a project, add tasks, and switch views (List, Gantt, Kanban) from the view toggle in the top bar
Memos is a lightweight, markdown-native note-taking app built for quick capture — a single ~20MB Go binary with an embedded SQLite database, no separate DB container needed.
- Bring it up:
cd memos docker compose up -d
Then it should be up and running at http://memos.home. First steps:
- Register the first account on the web UI — this becomes the admin (Settings → Preferences → Sign Up can be disabled afterwards to keep it private)
- Start writing memos from the timeline view — tag with
#taginline, and use/for slash commands (to-do lists, code blocks, etc.) - Under Settings → Member you can invite your partner/friends with their own accounts
- Memos supports pinning, archiving, and a Resources tab for uploaded attachments (images, files)
Data storage: all notes and attachments live in
memos/data/(mounted to/var/opt/memosin the container), backed by an embedded SQLite database — back up that folder like you would any other service's data directory.
Filebrowser is a lightweight web UI for browsing, uploading, and downloading files over the network — used here to browse the general backup archive at /mnt/backup/all/Backups (personal folders, OS images, old flash-drive backups) without needing shell/SMB access.
- Bring it up:
cd filebrowser docker compose up -d
Then it should be up and running at http://filebrowser.home (also on port 8081 directly). First steps:
- Log in with the default credentials
admin/admin - Immediately change the password under Settings → Profile — the default login is publicly known and this is reachable on the LAN/Tailscale
- Browse, upload, or download files under the mounted
/srvroot (/mnt/backup/all/Backupson the host)
Note: Filebrowser runs as
user: rootso it can read files owned by other users in the backup archive. Its own settings/database live in thefilebrowser_dataDocker volume (not a bind-mounted folder in this repo), so they aren't covered by a Backrest plan — this only holds Filebrowser's own config (users, UI prefs), not the files it browses. The/mnt/backup/all/Backupsarchive it points at is separate from the Backrest-managedhomelab-backupsrestic repo (/mnt/backup/restic-repo) and isn't part of that backup rotation either.