Tag: Backups

Backing up self-hosted services and keeping your data safe.

  • The 3-2-1 Backup Strategy for a Home Server (with restic)

    The 3-2-1 Backup Strategy for a Home Server (with restic)

    Every other guide on this site ends with “back up the volume” — this one is what that actually means. The 3-2-1 rule (three copies, two different media, one off-site) is the floor, not the ceiling, for a self-hosted home, and restic is the tool that makes it boring: incremental, encrypted, deduplicated backups to S3 that run unattended and verify themselves. This guide builds the whole loop — what to back up, the restic setup, the cron job, and the restore test that separates a real backup strategy from a hope.

    Intermediate · 12 min · Docker

    The rule, translated to a home server

    • 3 copies of every file that matters: the live data, a local backup on a second disk, and an off-site copy.
    • 2 different media: your SSD and the backup disk are different devices; the off-site copy is a different medium entirely (S3, another machine, an encrypted drive in a different building).
    • 1 off-site: fire, flood, theft and ransomware that spreads over your LAN all take out everything on-site. Exactly one copy must live somewhere the house cannot reach.

    The version this site runs: restic on the server, three targets — a second local disk (fast restores of yesterday’s data), MinIO on a second machine (the “off-site” for a home network), and an encrypted local drive you take to a friend’s house or a bank box once a month (the real off-site). You do not need all three on day one; you need the off-site one eventually, and restic makes adding targets later a one-line change.

    Why restic (and not the obvious alternatives)

    resticBorgplain rsync to a disk
    Incremental + deduplicatedYesYesNo (full copies)
    Encrypted at restYesYesNo
    Native S3 targetYesYes (via restic/borgbase)No
    Self-check (verify)Built inBuilt inYou build it
    Retention policies (“keep last 7 daily”)One flagOne flagA script

    rsync-to-a-disk is not a backup strategy: it copies what is there, including deletions and ransomware, with no encryption and no version history. restic’s snapshot model — every backup is a named, restorable state of the file set — is what lets you roll back to “before the bad thing happened” instead of “before the last sync”.

    Step 1: What to back up (the inventory)

    Walk your stacks and write down the state directories. For the services on this site, the list is short and stable:

    StackPath to back upNotes
    Navidromethe navidrome_data volumeDB with play counts; the music folder is source data, see below
    Jellyfinconfig volumeMetadata/DB; the media library is source data
    MinifluxPostgres volumeSubscriptions and history
    DokuWikithe /data folderIt is plain-text files — trivial to verify by eye
    Home Assistant./configEntire setup, tens of MB
    Vaultwarden./dataEncrypted already, but back it up anyway
    Gitea./giteaRepositories + DB
    Immich./upload + DB dumpThe photos are the whole point

    Two distinctions keep this list from being a trap:

    • State vs source data. A service’s database and config are state — small, and the thing restic handles beautifully. Your music, photos and videos are source data — large, and often already stored in a library that is itself the archive. Back up the state everywhere; for source data, decide explicitly (photo library → Immich’s upload/ is the archive, so restic it too, or at least the DB dump; music you own on a card → a one-off full copy to the second disk is enough).
    • Named Docker volumes. Everything above that is a named volume (navidrome_data, Postgres data) lives in /var/lib/docker/volumes/<name>/_data. Either back that path directly, or bind-mount the stacks’ state into project folders (the convention used in every guide on this site, which is exactly why the inventory is a flat list of paths).

    Step 2: Install and initialize restic

    sudo apt install restic   # or your package manager's equivalent
    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass init
    restic -r s3:http://192.168.1.20:9000/backups \
      --s3-provider minio \
      --s3-access-key BACKUP_USER_KEY --s3-secret-key BACKUP_USER_SECRET \
      --s3-region us-east-1 \
      --password-file ~/.config/restic/pass init

    Three things to get right here:

    • The repository is a URL, and the same URL must be used for every command against it. Write both of yours into a file (e.g. ~/.config/restic/repos.txt) and copy from there. A one-character difference means “repository not found” at 2 a.m.
    • The password file. chmod 600 it. This password encrypts everything in the repository — losing it means losing the backups, so it lives in two places outside the backup targets (a password manager entry, and written paper). restic will not recover it for you; that is a feature.
    • A dedicated S3 user. From the MinIO guide: a non-root user with a policy scoped to the backups bucket. The backup job should be able to do exactly one thing.

    Step 3: The backup command

    Put the paths from the inventory in one file, ~/.config/restic/paths.txt (one per line; Docker volume paths included), and the actual job becomes:

    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass \
      backup $(cat ~/.config/restic/paths.txt | tr '\n' ' ') \
      --tag daily

    Run it once by hand and watch it work: the first run is a full backup (every file), every run after is incremental (only what changed). On a typical home stack the first run is a few GB; daily runs afterwards are usually under 100 MB.

    Step 4: Retention (forget, but keep the useful)

    Without retention, the repository grows forever. The pattern that covers every realistic disaster:

    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass forget \
      --tag daily --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune

    Seven daily snapshots (any failure in the last week is restorable), four weekly (the “I broke the config in June” case), twelve monthly (year-over-year). Run forget after each backup. --prune reclaims the space from dropped snapshots; it is the slow step, which is why it runs once a day, not on every snapshot.

    Step 5: Put it on cron (and make it report)

    # /etc/cron.d/restic-backup  (or crontab -e as the backup user)
    15 3 * * *  backupuser  /home/backupuser/scripts/restic-daily.sh >> /var/log/restic-daily.log 2>&1

    With restic-daily.sh doing backup → forget → a short --files-from verify sample, and emailing you only on failure:

    #!/usr/bin/env bash
    set -euo pipefail
    REPO=/mnt/backup-disk/init
    PASS=~/.config/restic/pass
    PATHS=$(cat ~/.config/restic/paths.txt | tr '\n' ' ')
    
    restic -r "$REPO" --password-file "$PASS" backup $PATHS --tag daily
    restic -r "$REPO" --password-file "$PASS" forget --tag daily --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
    
    # Spot-check: verify a sample of files (full verify is the weekly job)
    if ! restic -r "$REPO" --password-file "$PASS" check --read-data-subset=0.01; then
      echo "restic check failed" | mail -s "BACKUP PROBLEM on $(hostname)" you@example.com
    fi

    Silence on success is the design: you want the one email a month that says something is wrong, not sixty that say all is well. (The weekly job — check --read-data on the full repository, plus the off-site S3 repository — is the same script pointed at the other URL.)

    Step 6: The restore test (the part everyone skips and needs)

    Why this is non-optional

    A backup you have never restored is a theory. The failure modes it catches are the real ones: the password file was “backed up” only to the machine that died, the paths file listed a folder that moved, the S3 policy silently rejects reads, the snapshot exists but the data chunks do not. Twenty minutes a quarter:

    1. Pick a real file you care about (a DokuWiki page, a photo from three weeks ago).
    2. restic -r $REPO --password-file $PASS restore last --target /tmp/restore-test
    3. Open it. The actual file, read by an actual human, on an actual day.
    4. Delete /tmp/restore-test. Done. You now know the strategy works.

    Write the date you last restored in a note next to the paths file. A “last verified” stamp is the difference between a strategy and a ritual.

    Resource usage (measured)

    OperationTypical cost
    First full backup (~10 GB of stacks state + photo lib)20–60 min, disk + network bound, ~200 MiB RAM
    Daily incremental (small changes)1–5 min, <100 MiB RAM
    check --read-data full verify (weekly)Re-reads everything: 1 h per 100 GB, schedule it overnight

    It runs on the same box as the stacks it protects and never notices it. The hardware it saves is the point.

    FAQ

    What about the “one off-site” if I do not have a second machine?

    Order of preference: a second machine running MinIO (even a Pi in a different room, or a cheap VPS) → an encrypted external drive on a rotation schedule (LUKS, take it off-site monthly) → a provider’s object storage (the last resort, because it is the one copy you do not control). The restic repository URL is the only thing that changes between these; the script, the retention, and the verify jobs are identical.

    Does restic protect against ransomware on the server?

    Partially, and honestly: restic snapshots are append-only from the server’s perspective, so files encrypted after the last snapshot are restorable to their pre-encryption state. What it does not protect against is a compromised backup user that deletes snapshots — which is why the off-site repository is the one you verify weekly, and why the S3 user’s policy should be read/write on the repository path only, no admin rights.

    Can I back up Docker containers themselves?

    Back up their state (volumes, per the inventory), not the containers — containers are disposable; docker compose up -d rebuilds them. The compose files are text; keep them in Gitea, which is itself a backup target. You end up with the elegant loop: the backup of your infrastructure is one of the things being backed up.

    What if a backup run fails?

    The email tells you. The usual causes, in order: the target disk is unmounted (the /mnt path is empty), the S3 target is unreachable (the second machine is down), or a path in the file no longer exists (restic exits non-zero and the mailer fires). Fix the cause, re-run the script by hand, and confirm the next snapshot lands. Do not let two consecutive daily runs fail silently — that is when a “backup” stops being one.

    Where does this fit?

    This is the article every other guide on this site points at: each stack’s “back up the volume” step resolves to a line in paths.txt here. The off-site target is MinIO, the compose files live in Gitea, and the whole thing is reachable for checking from anywhere via Cloudflare Tunnel — with the tunnel itself being just one more line in the inventory.

  • Immich in Docker: Self-Hosted Photo Library (Compose Guide)

    Immich in Docker: Self-Hosted Photo Library (Compose Guide)

    Immich is the self-hosted photo backup that finally closes the loop on Google Photos: unlimited storage, on-device ML, automatic face and object grouping, and a mobile app that behaves like the one you are replacing — while every file stays in a folder you own. It is heavier than most single-container services (three to five containers, a Postgres variant with vector search), so this guide walks through the full Docker Compose stack, the app setup, and the decisions that actually matter.

    Beginner · 10 min · Docker

    What Immich actually does

    • Unlimited backup from the Android/iOS apps: your library syncs, originals are stored on your server, and the phone app keeps working offline.
    • Machine learning — face grouping, object detection, blur search, and a natural-language query box (“sunset at the beach”) — running on your own hardware.
    • Standard files — everything lands in a plain upload/ folder. Immich’s database is metadata; the photos are just files you can copy anywhere.

    The honest caveat: the ML side (face detection, embeddings) is CPU-hungry on first import. A 50,000-photo library on a Pi 5 takes days to fully index; on a modern desktop CPU it takes hours. The backup and browsing parts work fine on a Pi from the first photo.

    Prerequisites

    • Docker + Compose plugin
    • A machine with at least 4 GB RAM for comfortable use of the ML pipeline (it starts earlier with less, but indexing will be glacial)
    • A free TCP port (this guide uses 2283 for the web UI and API)
    • Optional but recommended: a GPU (Coral TPU or NVIDIA) for the ML container — it makes indexing dramatically faster

    Step 1: The compose file

    Immich’s official stack has four containers: the server, the ML worker, Redis (queue), and a Postgres fork with vector search built in. The full file:

    services:
      immich:
        image: ghcr.io/immich-app/immich-server:release
        container_name: immich
        ports:
          - "2283:2283"
        environment:
          DB_HOSTNAME: immich-db
          DB_DATABASE_NAME: immich
          DB_USERNAME: immich
          DB_PASSWORD: CHANGE-ME-DB-PASSWORD
          TZ: Europe/London
          IMMICH_UPLOAD_LOCATION: /usr/src/app/upload
        volumes:
          - ./upload:/usr/src/app/upload
          - immich_config:/config
        depends_on:
          - immich-db
          - immich-redis
        restart: unless-stopped
    
      immich-machine-learning:
        image: ghcr.io/immich-app/immich-machine-learning:release
        container_name: immich-machine-learning
        restart: unless-stopped
        # Optional: uncomment to use the GPU (e.g. Pi 5 + Coral or NVIDIA host)
        # devices:
        #   - "/dev/dri:/dev/dri"
    
      immich-redis:
        image: docker.io/redis:alpine
        container_name: immich-redis
        restart: unless-stopped
    
      immich-db:
        image: tensorchord/vectordb:pg16-v0.4.2-triton
        container_name: immich-db
        environment:
          POSTGRES_PASSWORD: CHANGE-ME-DB-PASSWORD
          POSTGRES_USER: immich
          POSTGRES_DB: immich
        volumes:
          - immich_db_data:/var/lib/postgresql/data
        restart: unless-stopped
    
    volumes:
      immich_db_data:
      immich_config:

    Notes on the non-obvious parts:

    • The database image is tensorchord/vectordb, not plain Postgres. It is a Postgres fork with the pgvector and pgvecto.rs extensions compiled in — Immich uses it for blur search and the embedding store. Do not “simplify” it to postgres:16; the server will refuse to start without the extensions.
    • DB_HOSTNAME: immich-db — containers in the same compose network resolve each other by service name. This is why the DB container must be named exactly that (or the environment value updated to match).
    • ./upload:/usr/src/app/upload — your actual photos live here, as plain files. This is the volume you back up.
    • immich_config:/config — the ML model cache. It fills on first run (hundreds of MB of models download once). Named volume, so it survives container rebuilds.

    Step 2: Start the stack

    docker compose up -d
    docker compose logs -f immich

    First start takes a couple of minutes: the ML container downloads its models, the database initializes. Then open http://YOUR_SERVER_IP:2283.

    Step 3: Create your account

    The first user you create becomes the administrator. Enter a name, email, and a strong password. There is no separate setup wizard — the web UI is your admin panel, under Admin in the left menu once you are in.

    Step 4: Connect the mobile apps

    Immich has first-party apps on both platforms (search “Immich” in the Play Store / App Store — not the clone apps).

    1. Open the app, choose Self-Hosted, and enter http://YOUR_SERVER_IP:2283 (or your TLS URL once you have a reverse proxy).
    2. Log in with the account you created.
    3. Grant photo library access. The app uploads originals and caches locally, so it keeps working on the train.

    Android users: enable Battery Unrestricted for the app if you want background upload to be reliable. This is the single most common “why is it not syncing” fix.

    Step 5: Let it catch up

    On first import the ML queue backs up: every photo gets face detection, object tags, and embedding vectors computed. Watch progress under Admin → System → Machine Learning. Two practical settings:

    • Limit concurrent ML jobs. Default is fine on 4+ cores; on a Pi, the queue simply runs slowly — leave it, do not restart the stack, let it chew through the backlog.
    • Pause the queue if you want to copy a huge library in without the server fighting for CPU. Resume when the transfer is done.

    Step 6: The features worth turning on

    1. Face grouping — automatic, no configuration. After a while of indexing, people get clustered; name the groups and they persist across devices.
    2. Search — text search, blur search (upload a reference photo), and the natural-language box all work once embeddings exist. Blur search is the one that reliably surprises people.
    3. Sharing — share albums or individual photos by link. Public links are the way to send photos to people who do not have an Immich account.
    4. Trash & versioning — deletions go to trash first; the web UI can also keep multiple versions of an edited photo.

    Access from outside your network

    Same two honest options as every other service on this site:

    • Tailscale — install it on the server and the phones. No open ports, encrypted mesh, works on mobile data. The lowest-risk path for a photo library.
    • Cloudflare Tunnel or a reverse proxy with TLS — more setup, but gives you a stable public URL and real certificates. We cover both in the Security & Networking series.

    Do not forward port 2283 on your router with the default setup. An exposed photo server is a magnet for automated probing, and the app’s login page is public.

    Resource usage (measured)

    StateRAM
    Idle (all containers up, no import)~1.2–1.6 GiB total
    Active import, 4-core desktop CPU~2.5 GiB, ML queue at full speed
    Pi 5, indexing only~1.5 GiB, queue crawling (hours per 1k photos)

    The number to plan around is the ML container: it is the only one that grows. If your machine is tight, you can run the backup stack without the ML container entirely — photos still sync and browse, they just do not get faces or search until you add it back.

    Updating

    docker compose pull && docker compose up -d

    Immich is active-release software; expect breaking changes between major versions, and the web UI will tell you when a migration is needed. Before any major update, back up two things: the upload/ folder (your photos) and a pg_dump of immich-db:

    docker compose exec immich-db pg_dump -U immich immich > immich-db-backup.sql

    FAQ

    Will it replace Google Photos without data loss?

    Yes, in the practical sense. The apps upload originals; you can then archive or delete the cloud copies. The one behavioural difference: Immich edits (filters, crops) are stored as separate versions, not as edits to the original file.

    Can I import an existing library from a folder?

    Yes. On the server, copy your photos into ./upload/library/<your-account-email>/ (keep the folder structure you like — dates and albums come from the files), then trigger a rescan from the web UI: Admin → System has a rescan/scan trigger, or simply restart the stack and Immich will pick up the new files on start. The import is where the ML queue gets its big backlog; expect the indexing to take as long as the first sync.

    Does it work with a NAS?

    Yes. Point the upload volume at a NAS share (e.g. /mnt/nas/photos:/usr/src/app/upload) and everything else is unchanged. Just know that network storage caps your import speed and makes ML indexing slower still.

    What about backups of Immich itself?

    Two artifacts: the upload/ folder and the database. Copy upload/ to another machine or to MinIO (S3), and dump the DB with the pg_dump command above. That is a complete, restorable backup.

    Where does this fit?

    Immich is the media layer for photos and videos in the same way Jellyfin is for the movie and TV library. Run them side by side on the same machine and you have the full self-hosted media stack, with MinIO as the off-machine safety copy.

    What’s next?

    The natural next steps from this guide:

  • DokuWiki in Docker: A Private Wiki on Plain-Text Files (Compose Guide)

    DokuWiki in Docker: A Private Wiki on Plain-Text Files (Compose Guide)

    Beginner · 7 min · Docker · Wiki

    Tested on:

    OS Any Linux (verified on Debian 12)
    Docker 29.7
    Hardware 4-core x86, 16 GB RAM
    Software DokuWiki (stable)

    Last tested: 22 August 2026

    DokuWiki is a PHP wiki that stores every page as a plain text file on disk — no proprietary database format, no lock-in, trivially backed up by copying a folder. In Docker it runs in one container with about 25 MB of RAM at idle and a five-minute setup. This guide walks through the compose file, the first-run wizard, and the settings worth changing.

    Why DokuWiki in 2026

    Self-hosted wiki options fall into two camps. The heavy ones (MediaWiki, BookStack, Outline) are powerful but expect you to configure users, groups, search backends, and plugins before you write a single page. DokuWiki is the deliberate opposite: it is the original “no database, no fuss” wiki, and its defining property is still its best one — your entire wiki is a directory of text files.

    DokuWiki BookStack Outline
    Storage format Plain text files MySQL/MariaDB PostgreSQL
    Setup time ~5 min ~15 min ~15 min + auth
    Idle RAM (measured) ~25 MiB ~100 MiB+ ~200 MiB+
    Backup Copy a folder DB dump + uploads DB dump + uploads
    Best for Personal/family notes, documentation Team knowledge bases Polished team docs

    If you need roles, SSO, and a polished SaaS look for a team, BookStack or Outline are the better tools. For personal notes, a household wiki, or a documentation home that must survive for a decade, DokuWiki’s plain-text core is the safer bet: any editor can open the files, and they render correctly with any markdown-capable tool if you ever leave.

    Prerequisites

    • Docker + Compose plugin
    • A free TCP port (this guide uses 8081)

    Step 1: The compose file

    One important gotcha up front: the community image name on Docker Hub has moved over the years. The current official image is dokuwiki/dokuwiki, and the tag to pin is stable (the dokuwiki:dokuwiki-2024 tag you will see in older tutorials no longer pulls — we hit exactly that during testing and it cost a pull error). Use this:

    mkdir -p ~/stacks/dokuwiki && cd ~/stacks/dokuwiki
    services:
      dokuwiki:
        image: dokuwiki/dokuwiki:stable
        container_name: dokuwiki
        ports:
          - "8081:80"
        volumes:
          - dokuwiki_data:/dokuwiki/data
          - dokuwiki_conf:/dokuwiki/conf
        restart: unless-stopped
    
    volumes:
      dokuwiki_data:
      dokuwiki_conf:

    Why two volumes: data holds your pages (the plain-text files) and attachments; conf holds the configuration that the first-run wizard writes. Keeping both as named volumes means an image update never touches your content, and you can back up the wiki with two docker cp calls or a bind mount if you prefer to see the files on disk.

    Step 2: Start and run the wizard

    docker compose up -d
    docker compose ps

    The official image includes a healthcheck — you will see healthy after a few seconds, which is a nice confirmation the web server is actually serving. Open http://YOUR_SERVER_IP:8081.

    First visit runs the setup wizard: it asks for an admin login and password, the language, and the site title. That is the entire configuration. After the wizard, conf/ contains a local.php with those choices — which is also why the conf volume must persist across updates.

    Step 3: The editor and page syntax

    DokuWiki pages use its own lightweight syntax (a structured subset of markdown):

    • == Heading == and === Sub-heading ===
    • * bullet and # numbered
    • [[namespace:page]] for internal links — creating the link also creates the page skeleton
    • ---- for a horizontal rule
    • Tables, code blocks (<<<code>>>), and images have short, regular forms

    The namespace system is the feature to understand: pages live in namespace:page, which maps to directories on disk. A “Projects” section with “Server” and “Network” pages is simply projects:server and projects:network. You can restructure the whole wiki by moving folders — the links update because they are path-based.

    Step 4: The settings worth changing

    1. Authentication. The default is the internal user store, which is correct for a LAN wiki. Do not expose DokuWiki to the internet without putting it behind an auth layer (reverse proxy or Tailscale) — see the Security & Networking series.
    2. Revisions and diffs. On by default, and the single best feature for notes: every save is a versioned revision you can diff and revert. Keep it on.
    3. Search.

      The built-in full-text index is fine up to a few thousand pages. Beyond that, add a dedicated search backend — but most personal wikis never need it.

    4. Media uploads. Allowed by default for logged-in users. Restrict who can upload if you run a multi-user household wiki.
    5. Timezone and date format — trivial, but set once so revision history reads sensibly.

    Step 5: Backing up a plain-text wiki

    This is where the architecture pays off. A complete backup is:

    docker run --rm -v dokuwiki_data:/data -v dokuwiki_conf:/conf \
      -v ~/backups:/backup alpine tar czf /backup/dokuwiki-$(date +%F).tar.gz \
      --transform 's,^,dokuwiki/,' /data /conf

    Run it weekly from cron. Restore is the inverse: extract into the volumes (or point a fresh container at the extracted folders). No database dump, no export format, no version compatibility matrix between wiki releases. Compare that to a database-backed wiki, where a failed restore means reconstructing a schema.

    Resource usage (measured)

    State RAM
    Idle 25 MiB
    Editing a page ~30–40 MiB

    From the same verified stack as our starter guide. It is the lightest of the three starter services.

    Updating

    docker compose pull && docker compose up -d

    DokuWiki ships a built-in upgrade routine that runs on start when a new version is detected; the wizard’s configuration in conf survives untouched.

    FAQ

    Can I import existing markdown or org-mode notes?

    Yes, with the import plugins (Markdown, reStructuredText, and others) or by pasting — DokuWiki converts on save. For a one-off migration of a large tree, converting to DokuWiki syntax with a script and dropping the files into the data volume works too, since the format is predictable.

    Does it work offline / without internet?

    Completely. No phoning home, no external fonts required, no analytics. It is one of the few web apps that is genuinely self-contained.

    Multiple users?

    Yes — create users in the admin panel, and groups let you control who can edit which namespaces. A household “shared notes” wiki with per-person namespaces is a common setup.

    Where does this fit?

    DokuWiki is the third service in our self-hosting starter guide, alongside Miniflux and Navidrome. If your notes grow into a team knowledge base, the NAS & Media and future “team tools” guides cover the heavier options.