Skip to content

Latest commit

 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Homelab Stack

The requirements for this WIP homelab were the following:

  • Easy to modify, ideally with docker compose services
  • Accessible remotely
  • Should have automated + periodic (+ deduplicated) backups in place
  • Easily memorizable services (aka reachable at service.home instead of IP_SERVER:PORT_SERVICE)
  • Have some observability + monitoring in place

Network Topology

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
Loading

Legend

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)

Services

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

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.

Setup

  1. Create a free account at tailscale.com
  2. Install Tailscale on your server:
    curl -fsSL https://tailscale.com/install.sh | sh
    sudo tailscale up
  3. 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.

Accessing services remotely

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/24

Then 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:

  1. In Tailscale admin → DNS → Nameservers → Add nameserver → Custom
  2. IP: your server's Tailscale IP (tailscale ip on the server)
  3. Check Restrict to domain → enter home
  4. 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 *.home to the server's Tailscale IP (100.x.x.x) — Tailscale clients could then reach it without subnet routing. But 100.x.x.x is 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.

Notes

  • Tailscale overrides system DNS via 100.100.100.100 — split DNS for the home domain ensures .home queries 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

Immich is the FOSS equivalent of Google Photos. To set it up:

  1. Get a .env file - default one from Immich is fine. Put it in the immich directory.

  2. 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:PORT and 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

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).

  1. Create an .env file and populate it properly (using the example.env as a base)
  2. 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
  • 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 * * *
  • To verify this setup works:

    • Go to immich-live plan, and Backup 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.

Backup plans

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-db are 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 runs docker 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

Paperless-ngx is a document management system — scan or import PDFs and images, and it OCRs, tags, and indexes them so they're searchable.

  1. Create a .env file from example.env and 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)
  2. 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.

Gmail IMAP setup

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.

1. Enable IMAP in Gmail

In Gmail → Settings (⚙️) → See all settings → Forwarding and POP/IMAP → IMAP access → Enable IMAP → Save.

2. Generate a Google App Password

Regular Gmail passwords don't work with IMAP when 2FA is enabled. You need an App Password:

  1. Go to myaccount.google.com/apppasswords (requires 2FA to be active)
  2. App name: paperless → Create
  3. Copy the 16-character password — you'll only see it once

3. Add the mail account in Paperless

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.

4. Add a mail rule

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 paperless appears as the folder paperless. Nested labels (paperless/processed) appear as paperless/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.

Troubleshooting Gmail ingestion

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:

  1. In Gmail, search for label:paperless is:read has:attachment filename:pdf, select all, and mark as unread.
  2. In Paperless UI → Settings → Mail, click Process mail.
  3. 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.

Mobile scanning (phone → Paperless)

The recommended way to push a photo of a document from your phone directly into Paperless is the Paperless Mobile app.

Setup

  1. Open the app and tap Add server.
  2. Server URL: http://paperless.home (works on LAN and via Tailscale).
  3. Log in with your Paperless username and password.

Usage

  • 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-scan so you can easily filter and review phone uploads later.

Alternative: sync via Syncthing (folder-based)

If you prefer a folder-drop workflow (e.g. using a document scanner app like Microsoft Lens that saves to a local folder):

  1. Add a syncthing service to the stack (see proposal below or add it yourself).
  2. Share a folder from your phone (e.g. Documents/Scan) with the server, syncing to ./paperless/consume/.
  3. Any file saved to that phone folder is automatically picked up and consumed by Paperless.

Multi-user workflow (you + partner)

Paperless-ngx has full multi-user support with per-document ownership and sharing.

Create a user for your partner

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

Each person installs the Paperless Mobile app

Both of you add the same server URL but log in with your own credentials. Scans go into each person's account.

Sharing documents between users

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

Netdata is a real-time server monitoring tool — CPU, memory, disk, network, and Docker container stats out of the box.

  1. 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.

Alerts and notifications

Netdata ships with hundreds of pre-built alert rules (high CPU, low disk, OOM, etc.) that fire automatically. To receive them somewhere useful:

  1. Open a shell in the container:
    docker exec -it netdata bash
  2. Edit the notification config:
    cd /etc/netdata && ./edit-config health_alarm_notify.conf
  3. Find your provider (Telegram, email, Slack, etc.) and fill in the credentials — each has a clearly labelled block in the file.
  4. 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

Uptime Kuma is a self-hosted monitoring tool that tracks whether your services are up and alerts you when they go down.

  1. 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, so localhost refers to the container itself — not the host. host.docker.internal is a hostname that Docker resolves to the host machine's IP, letting the container reach services bound to the host. On Linux this requires the extra_hosts: host.docker.internal:host-gateway line in the compose file (already set).

  • Configure notifications under Settings → Notifications — see the ntfy section below for the recommended setup.

ntfy

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.

Setup with the public instance

  1. Install the app on your phone: Android (Play Store / F-Droid) or iOS (App Store).
  2. Pick a topic name. Topics are public by default on ntfy.sh, so use something unguessable:
    abc123-homelab-xyz789
    
  3. Subscribe to the topic in the app: tap + → enter https://ntfy.sh/abc123-homelab-xyz789.
  4. 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.
  5. Assign the notification to monitors: edit each monitor and add the ntfy notification under the Notifications field.

Note: The public ntfy.sh instance is free and reliable for low-volume personal use. Messages are not end-to-end encrypted, so avoid sending sensitive data in alert bodies.

TODO

  • 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

Caddy is a reverse proxy that routes *.home domains to the appropriate services, so you don't need to remember ports.

  1. 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.home
  • http://backrest.home
  • http://paperless.home
  • http://adguard.home
  • http://uptime-kuma.home
  • http://netdata.home
  • http://vikunja.home
  • http://memos.home
  • http://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 to caddy/docker-compose.yml and recreate the container with docker compose up -d --force-recreate.

AdGuard Home

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.

  1. Bring it up:

    cd adguard
    docker compose up -d
  2. Complete the one-time setup wizard at http://SERVER_IP:3000. After setup the UI moves to http://SERVER_IP:8080 (or http://adguard.home once DNS is working).

  3. In the AdGuard UI, add upstream DNS servers (Settings → DNS settings):

    • https://1.1.1.1/dns-query
    • https://8.8.8.8/dns-query
  4. Add DNS rewrites (Filters → DNS rewrites) for each service:

    • immich.home → SERVER_LAN_IP
    • backrest.home → SERVER_LAN_IP
    • paperless.home → SERVER_LAN_IP
    • adguard.home → SERVER_LAN_IP
    • uptime-kuma.home → SERVER_LAN_IP
    • netdata.home → SERVER_LAN_IP
    • vikunja.home → SERVER_LAN_IP
    • memos.home → SERVER_LAN_IP
    • filebrowser.home → SERVER_LAN_IP
  5. Set your router's primary DNS server to SERVER_LAN_IP and secondary to 1.1.1.1.

Known DNS issues

  • 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. .home domains won't resolve through them — disable the VPN when on the home network.

Vikunja

Vikunja is a self-hosted task management app — create projects, to-do lists, and kanban boards. Think Todoist or Trello, but yours.

  1. Create a .env file from example.env and 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
  2. Create the files directory and set ownership so Vikunja (which runs as UID 1000) can write to it:
    mkdir -p vikunja/files
    chown 1000 vikunja/files
  3. 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

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.

  1. 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 #tag inline, 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/memos in the container), backed by an embedded SQLite database — back up that folder like you would any other service's data directory.

Filebrowser

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.

  1. 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 /srv root (/mnt/backup/all/Backups on the host)

Note: Filebrowser runs as user: root so it can read files owned by other users in the backup archive. Its own settings/database live in the filebrowser_data Docker 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/Backups archive it points at is separate from the Backrest-managed homelab-backups restic repo (/mnt/backup/restic-repo) and isn't part of that backup rotation either.

About

notes + setup for the services I run in my homelab

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages