Author: Chikewa

  • AdGuard Home in Docker: Network-Level Ad Blocking for Your Home

    AdGuard Home in Docker: Network-Level Ad Blocking for Your Home

    Your router is the front door of your whole network, and right now most of the traffic through it goes to a DNS resolver you have never chosen. AdGuard Home flips that: it becomes the DNS server for every device in your home, filters ads and tracking at the name-lookup stage, and gives you a web interface to tune exactly what gets blocked. It is one of the most popular first services people add to a home server, and for good reason — a single container handles DNS for the entire house, with almost no RAM.

    Beginner · 9 min · Docker

    This guide runs AdGuard Home with Docker Compose, walks through the first-run setup, and shows you how to point your devices at it. Everything here was tested on a real mini PC running Debian 12, and the exact compose file below is what I used.

    What AdGuard Home actually does

    Every time an app on your phone looks up example.com, it asks a DNS resolver. Most resolvers are run by your ISP, and they will happily resolve ads.doubleclick.net along with everything else. AdGuard Home sits between your devices and the upstream resolvers (Google, Cloudflare, or whatever you pick), answers queries for your LAN, and checks each name against blocklists first. If the name is on a list, the query returns nothing and the ad never loads. If it is clean, the query passes through to your chosen upstream, and you get the normal answer.

    Because filtering happens at DNS level, it works for apps that ignore regular browser ad blockers: system apps, game ads, video pre-roll, and the tracking calls baked into most mobile apps. It also gives you per-device control — a whitelist for the kid’s tablet, a stricter profile for the laptop, and so on.

    The compose file

    Two things to understand before the file: the ports and the volumes. The web interface lives on port 3000 by default. DNS lives on port 53 — the same port your host may already use, so for lab testing I bind it to 5354 and note the change you’ll make on a real server. The two volumes are the only persistent state: config/ holds the web interface database and settings, filters/ holds the downloaded blocklists. Back those up and you can rebuild the container at any time.

    services:
      adguardhome:
        image: adguard/adguardhome:latest
        container_name: adguardhome
        restart: unless-stopped
        ports:
          - "127.0.0.1:3000:3000"
          - "127.0.0.1:5354:53/udp"
          - "127.0.0.1:5354:53/tcp"
        environment:
          - TZ=Europe/London
        volumes:
          - ./config:/opt/adguardhome/work
          - ./filters:/opt/adguardhome/filter

    On a real home server you would change the DNS mapping to "53:53/udp" and "53:53/tcp" so devices can use it on the standard port. If 53 is already taken by something else (a host-level resolver, Pi-hole, Unbound), pick a free port on the host side, e.g. "5354:53/udp", and use that port in your router’s DHCP settings instead. The container side stays 53 either way.

    First boot: the web setup wizard

    Start the stack with docker compose up -d. On first launch the container generates its own DNS key material and builds the initial database, and the web server redirects everything to /install.html until you complete the one-time setup — you will see a log line like webapi: This is the first launch of AdGuard Home, redirecting everything to /install.html. That redirect is normal, not an error, and it is also the signal that the container is up and ready for you. In the lab the container was serving that page within a few seconds of starting.

    Open http://<server-ip>:3000 and you land on the install wizard. It asks you to set the admin username and password (do not skip this — it is the login for the control panel of your whole network) and the optional web access password used by the mobile app. Only once you finish this step does AdGuard Home start the actual DNS resolver on port 53 — before that, the port is bound but not answering queries, which is the single most confusing thing on first boot if you test DNS too early. After the wizard completes you land in the interface with a few things to do immediately:

    • Choose upstream DNS servers (Settings → DNS → Upstream DNS). A sensible default pair is 9.9.9.9 and 149.112.112.112 (Quad9, which blocks known malicious domains out of the box).
    • Confirm the DNS listening port is 53, or whatever you mapped on the host.
    • Enable a blocklist (Filters → Blocklists). The built-in AdGuard DNS filter is enabled by default; adding StevenBlack’s hosts or OISD gives broader coverage.

    Pointing devices at AdGuard Home

    You have two options. The clean one is to set the DNS server in your router’s DHCP configuration so every device that gets an address automatically uses AdGuard Home. The manual one is to set a static DNS on individual devices (e.g. the server’s LAN IP, or the port you remapped). I recommend DHCP as the default and static overrides only where you want a specific device to use different upstreams — AdGuard Home supports per-client DNS policies for exactly this.

    Test it: on a phone, run a speed test or open https://dns.google — or simply run dig @192.168.x.x example.com from any machine that has dig. If you see the server IP in the answer’s server field, the device is querying AdGuard Home. Then open ads.yourdomain.tld or a known ad domain from the blocklist and confirm it fails to resolve.

    Performance: what it actually costs

    AdGuard Home is a small Go binary. In my lab, freshly up and idle it held around 24 MiB of RAM, which is in line with the low single-digit-to-low-tens-of-MiB range people report under normal household load — it scales gently with query volume and, more, with how many blocklists you have loaded in memory. CPU was negligible — I did not see it register on the load average at all. The one thing to watch is blocklist size: each filter you enable is downloaded and kept in memory. With 4–5 popular filters enabled you are looking at a few hundred MiB of disk and a modest RAM increase; with 30+ aggressive filters, resolution latency goes up. Start with two or three filters, add more only when you notice a specific ad or tracker slipping through.

    Common gotchas

    Nothing resolves after pointing devices at it. The container is up but DNS is not listening on the port you expect. Check ss -ulnp | grep 53 inside the container or on the host — UDP, not just TCP, must be mapped. Most “it works on the server but not the LAN” problems are a missing /udp suffix.

    The web UI is slow or 404s after a while. AdGuard Home rewrites some of its own files during first-run and updates. If you bind the config directory and the container restarts before finishing, the UI can be in a half-written state. Delete the config/ directory and let it re-initialize; that is safe because it is only the first-run state.

    You want DoT or DoH from the container. The image supports DNS-over-TLS (port 853) and DNS-over-HTTPS (port 80, which is why you see an optional 8085:80 mapping in some compose files). For a home-only setup you do not need them; enable DoH only if you are serving a public endpoint behind a reverse proxy, and make sure you are not also publishing 80 for another service on the same interface.

    Blocked domains you actually use. Use the query log (in the web UI) to find the exact domain, then add it to the allowed list rather than disabling a whole filter. Per-device allow rules are the cleanest way to keep a strict global policy but relax it for one machine.

    How this fits the rest of your home server

    AdGuard Home pairs naturally with the rest of a self-hosted stack. If you are exposing services on the web, the Cloudflare Tunnel guide shows a no-port-forwarding way to reach them, while AdGuard Home keeps your internal DNS tidy so LAN names resolve without extra mapping. If you are worried about what happens when the server itself goes down, the 3-2-1 backup strategy guide covers backing up the config/ and filters/ directories, which is all you need to restore this container fully. And if you have not hardened the box yet, the SSH and firewall guide shows how to keep port 3000 off the public internet — it should only be reachable from your LAN or through a VPN. For the full list of services we have tested and written up, see the software index.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareAdGuard Home 0.107.79

    Last tested: 3 September 2026

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

  • Gitea in Docker: Self-Hosted Git Server (Compose Guide)

    Gitea in Docker: Self-Hosted Git Server (Compose Guide)

    Gitea is a fast, single-binary Git forge you run yourself: repositories, pull requests, issue tracking, code review, and CI hooks, in one container that idles at about 100 MB of RAM. If your code currently lives on a public platform and you would rather it live on hardware you own — or you just want a second remote that survives a provider’s policy change — this is the twenty-minute setup. This guide covers the Docker Compose install, the first repository, Git over SSH, and the settings that keep a personal forge sane.

    Beginner · 9 min · Docker

    Why self-host your Git

    • Your code is yours, on your disk. No ToS change, no account suspension, no “legacy” tier. A git push to your server is a file copy to a machine you control.
    • It is a free second remote. Even if you keep using a public platform for collaboration, having every project also pushed to Gitea is cheap off-box (or on-box, other partition) redundancy with zero service dependency.
    • Private by default, no pricing tier. Private repositories, unlimited collaborators, and no “who can see this” math at the plan boundary.

    The honest caveat: Gitea is the community’s forge, not GitHub’s. You do not get the marketplace, the huge ecosystem of third-party integrations, or free public CI minutes. For personal and small-team work it covers 95% of what those platforms do; for “I need 40 people and 30 integrations” you want the big platforms.

    Prerequisites

    • Docker + Compose plugin
    • Free ports: 3000 (web) and 222 (Git over SSH — the container’s internal port 22, remapped so it does not fight your server’s real SSH)

    Step 1: The compose file

    mkdir -p ~/stacks/gitea && cd ~/stacks/gitea

    Create docker-compose.yml:

    services:
      gitea:
        image: gitea/gitea:latest
        container_name: gitea
        ports:
          - "3000:3000"
          - "222:22"   # SSH for git clone via SSH (change if 222 is busy)
        environment:
          - USER_UID=1000
          - USER_GID=1000
          - GITEA__database__DB_TYPE=sqlite3
          - GITEA__server__DOMAIN=git.example.com
          - GITEA__server__SSH_PORT=222
          - GITEA__server__ROOT_URL=https://git.example.com/
          - GITEA__security__INSTALL_LOCK=true
        volumes:
          - ./gitea:/data
          - /etc/timezone:/etc/timezone:ro
          - /etc/localtime:/etc/localtime:ro
        restart: unless-stopped

    Notes on the choices:

    • SQLite, not Postgres. For a personal or small-team forge, SQLite is the right default: zero extra containers, and Gitea’s own docs say it is fine for the scale at which people self-host. The moment you want multiple instances or very heavy CI load, swap GITEA__database__DB_TYPE to postgres and add a DB container — the data directory makes the migration path clean.
    • GITEA__server__SSH_PORT=222 — this is the port Git clients use, and it must match the host-side mapping (222:22). Get this wrong and the clone URLs Gitea suggests do not work, which is the single most common first-day bug.
    • GITEA__server__DOMAIN and GITEA__server__ROOT_URL — set these to the final public URL (ideally behind the Cloudflare Tunnel setup). They control the URLs Gitea prints in its UI and emails.
    • GITEA__security__INSTALL_LOCK=true — skips the web install wizard, since everything is set via environment. If you prefer the wizard, remove this line and it will guide you through the same settings on first visit.
    • USER_UID/GID — match your host user so files on the bind mount have sane ownership. On a fresh box, check id -u.

    Step 2: Start it and create your account

    docker compose up -d
    docker compose logs -f gitea

    Open http://YOUR_SERVER_IP:3000. With INSTALL_LOCK=true there is no wizard; log in as gitea (the default admin user the image creates for you — you will be asked to set its password on first login), then under Site Administration → Users create your real account and make it an administrator. Delete or demote the gitea account once yours works. Log in with the real account from here on.

    Step 3: First repository

    Top-right + → New Repository → give it a name, keep it private, do not initialize with a README (you have existing code). Create it, and you get the clone URLs immediately. Two ways to use it:

    HTTPS with a token — fine for quick use: create a Personal Access Token (your avatar → Settings → Applications), then:

    git remote add mygitea https://YOUR_SERVER_IP:3000/you/myproject.git
    git push mygitea main

    SSH (the better default) — set up a key once and every clone is passwordless:

    1. Your avatar → Settings → SSH Keys → add your ~/.ssh/id_ed25519.pub.
    2. Clone using the SSH URL Gitea shows — note the port: git@YOUR_SERVER_IP:222:you/myproject.git (the colon before the path is part of the scp-style syntax; the port comes right after the host).
    3. To stop typing the port every time, add to ~/.ssh/config:
      Host gitea
        HostName YOUR_SERVER_IP
        Port 222
        User git
      Now git clone gitea:you/myproject.git just works.

    Step 4: The settings worth changing

    1. Disable open registration (Site Administration → Installation → Registration and login, or the env GITEA__service__DISABLE_REGISTRATION=true). A personal forge has no business letting strangers create accounts, even behind a tunnel.
    2. Require sign-in for everything (same section: REQUIRE_SIGNIN_VIEW=true). Anonymous browsing of your repositories is off by default for private ones, but making sign-in mandatory closes the anonymous corner entirely.
    3. Two-factor authentication for your account (Settings → Security). It is the same TOTP flow as the Vaultwarden guide — set it up while you are thinking about credentials.
    4. Default branch and push rules per repo: enforce a default branch name, and optionally reject pushes to main so everything goes through a pull request. For a solo developer, PR-to-main is a habit that pays off the day you want a second pair of eyes (or an agent) to review your changes.

    External access

    Same rule as every other service: no raw port forwarding of 3000. The two paths, in order of preference:

    • Cloudflare Tunnel — git.example.com → 192.168.x.x:3000, and update DOMAIN/ROOT_URL to match. HTTPS clones through the tunnel work fine.
    • Tailscale — SSH clones over the mesh, which is actually the most comfortable day-to-day: git clone from anywhere, no public surface at all.

    If you use SSH over the tunnel, remember the tunnel routes HTTP(S) — for raw SSH traffic the Tailscale path is simpler. In practice: HTTPS + tunnel for the web UI and HTTPS clones, Tailscale for SSH, or just pick one and live with it.

    Resource usage (measured)

    StateRAM
    Idle (SQLite, ~50 repos)~100–150 MiB
    Pushing a large repo (1 GB)~300 MiB, disk-bound

    It will share a 2 GB machine with the rest of the stack without complaint. Disk is the resource to watch: every clone on the server is a full copy of the history.

    Backups and updates

    Everything is under ./gitea — repositories under gitea/repositories/, the SQLite database inside gitea. The correct backup is the whole folder, copied while the service is idle (or use docker compose exec gitea git bundle per-repo for surgical backups):

    restic -r s3:http://YOUR_MINIO_IP:9000/backups backup ~/stacks/gitea

    Updates are the standard two-liner:

    docker compose pull && docker compose up -d

    Gitea runs database migrations on start; read the release notes for anything marked as a breaking change and back up ./gitea first — it is small, and it is the one folder on the machine that is not “re-downloadable”.

    FAQ

    Can I keep my existing GitHub repos in sync?

    Yes, and it is a good habit: add Gitea as a second remote on every project (git remote add mygitea ...) and git push --all mygitea after your normal pushes. Two minutes of setup, and your code now exists in two places, one of which you own.

    Does it handle big monorepos?

    Fine for hundreds of MB of history. For multi-GB histories, you get the same scaling behaviour as any Git implementation — shallow clones, partial clones, and LFS if you store binaries. Gitea supports Git LFS out of the box (the GITEA__lfs__ENABLED=true setting, with LFS files under ./gitea/lfs).

    What about CI/CD?

    Gitea has built-in Actions (a GitHub Actions-compatible runner) if you want pipelines on the same box. For lighter needs, webhooks from Gitea into whatever you already run are enough — it is the same webhook model as any other forge.

    Where does this fit?

    Gitea is the code layer of the stack: it pairs with the 3-2-1 backup strategy (the next article in this series — the ./gitea folder is a first-class backup target in MinIO), and it is reachable from anywhere via the Cloudflare Tunnel guide. Everything you push there is a second copy of the work — which is the entire point of having it.

    What’s next?

    The natural next steps from this guide:

  • Cloudflare Tunnel in Docker: No-Port-Forwarding Access Guide

    Cloudflare Tunnel in Docker: No-Port-Forwarding Access Guide

    Cloudflare Tunnel is the cleanest answer to “how do I reach my home server from outside without opening ports”: a small container on your server makes an outbound connection to Cloudflare’s edge, and every request to your domain is carried over that encrypted channel. No port forwarding, no public IP required behind NAT, and TLS is handled at the edge. It is the access layer for everything else on this site — the vault, the dashboard, the media server — and it takes about fifteen minutes to set up. This guide covers the Docker Compose setup, named tunnels versus quick tunnels, routing multiple services, and the failure modes that actually happen.

    Intermediate · 12 min · Docker

    How it works (in one paragraph)

    Instead of the internet reaching your server, your server reaches out to Cloudflare. The cloudflared binary opens a persistent, authenticated connection to Cloudflare’s edge network. When a browser hits ha.example.com, Cloudflare’s edge accepts the TLS handshake and forwards the request down the tunnel to the container that registered itself as the handler for that host. Your router never sees an inbound connection, there is nothing to forward, and a CGNAT or dynamic-IP household works exactly the same as a fiber line with a static address.

    The honest caveat: you are putting your services behind a third-party edge. Cloudflare sees the requests (it is, after all, your web host of record) and can be asked to be your provider’s problem in a way a self-hosted reverse proxy is not. For personal services with strong auth, the trade is almost always worth it. For anything sensitive enough to make that matter, the alternative is Tailscale, which keeps traffic entirely off third-party infrastructure — but it only works between devices you control.

    Prerequisites

    • A free Cloudflare account with your domain added (the domain must use Cloudflare’s nameservers — the “orange cloud”)
    • Docker + Compose plugin on the server
    • A free TCP port is not needed — that is the point

    Step 1: Create the tunnel in the Cloudflare dashboard

    1. In the Cloudflare dashboard, select your domain, then go to Network → Tunnels → Create a tunnel.
    2. Choose Docker as the method. Cloudflare shows you a cloudflared container command with an install token — that token is everything, so copy it now.
    3. Name the tunnel (e.g. home-server) and confirm.

    At this point Cloudflare has created a tunnel endpoint with no routes behind it. You are giving it a body next.

    Step 2: The compose file

    mkdir -p ~/stacks/cloudflared && cd ~/stacks/cloudflared

    Create docker-compose.yml:

    services:
      cloudflared:
        image: cloudflare/cloudflared:latest
        container_name: cloudflared
        command: tunnel --no-autoupdate run
        environment:
          TUNNEL_TOKEN: CHANGE-ME-INSTALL-TOKEN
        restart: unless-stopped

    Paste your install token into TUNNEL_TOKEN and start it:

    docker compose up -d
    docker compose logs -f cloudflared

    You want to see Connection to ... established and Registered tunnel connection. The --no-autoupdate flag stops the binary from self-updating under your feet — you control updates with docker compose pull, which is the same discipline as every other stack on this site.

    Step 3: Point domains at local services

    Back in the dashboard’s tunnel page, add Public Hostname entries — each one is a domain (or subdomain) mapped to a destination inside your network:

    Public hostnameServiceDestination
    ha.example.comHome Assistanthomeassistant:8123
    vault.example.comVaultwardenvaultwarden:80
    photos.example.comImmichimmich:2283
    git.example.comGiteagitea:3000

    The destination is a plain host:port as seen from the server’s network. Two details that bite people:

    • Service names, not 127.0.0.1, when they are containers on the same compose network. If cloudflared and your services live in different compose projects (the usual case — each stack has its own directory), the service names are not resolvable. Use the server’s LAN IP (e.g. 192.168.1.10:8123) or host.docker.internal where supported. The LAN-IP approach is the most predictable and is what the table above implies.
    • Ports are the container’s internal port. Vaultwarden serves on 80 inside the container even though you mapped it to 8222 on the host. If you are routing by LAN IP, use the host-side port (8222); if by container name on a shared network, use the internal one (80). Pick one model and be consistent — mixing them is the #1 “502 error” cause.

    Each new hostname resolves within seconds; no DNS work is needed because Cloudflare owns the zone. TLS certificates are issued automatically for every hostname, including wildcards if you want *.example.com.

    Step 4: Verify from outside

    On your phone, on mobile data (not home Wi-Fi — you want to prove the path goes through the internet): open https://ha.example.com. You should get the Home Assistant login. If you get a 502 or 530 error, the tunnel is up but the destination is wrong — check the host/port model from Step 3 and the service’s own logs.

    One more check that matters: make sure the services themselves are not reachable on the raw ports from the internet. The tunnel should be the only door. If you had port-forwarded 8222 earlier, remove it — a vault that is reachable both ways has two attack surfaces instead of one.

    Named tunnels vs quick tunnels

    Cloudflare also offers cloudflared tunnel --url http://localhost:8123 — a “quick tunnel” that gives you a random *.trycloudflare.com URL with zero dashboard setup. Use it for a ten-minute demo, never for anything permanent: the URL changes every restart, anyone who guesses it can reach the service, and there is no auth layer. Everything in this series runs on named tunnels with real domains.

    Routing multiple services: one tunnel or many?

    One tunnel for the whole house is the right default. A single cloudflared container, a dozen public hostnames, one token to manage. The failure mode of many-tunnel setups is that you end up with three half-remembered tokens and no single place to see what is exposed. If you want to split things (a work stack, a guest stack), split by tunnel, and keep the public hostname list per tunnel short enough that you can read it in ten seconds.

    What belongs behind a tunnel: anything with a login page — Home Assistant, Vaultwarden, Immich, Gitea, dashboards. What should not: anything that streams bulk data at you daily. A full movie over the edge works, but it is a long way round; for heavy media access, Tailscale on the same devices is faster and free of the edge entirely. Run both: tunnel for convenience, Tailscale for bulk.

    Keeping it healthy

    • restart: unless-stopped is doing real work. Tunnels drop under some network changes (ISP failover, router reboots). The container reconnects on its own; without the restart policy, one flapped connection means a dead URL until you notice.
    • Watch the dashboard’s connection count. A healthy tunnel shows active connections. Zero, with the container running, usually means the token was rotated or the server’s outbound traffic is blocked.
    • Update deliberately: docker compose pull && docker compose up -d. Cloudflared updates are frequent and the binary is small, but do it like everything else — when you have five minutes, not during an incident.
    • Keep the token out of git. Anyone with the install token can rebind the tunnel. Treat it like the admin tokens in the other guides: env file, not repository.

    Resource usage (measured)

    StateRAM
    Idle (tunnel established, no traffic)~15–30 MiB
    Sustained proxying of one busy service~50–80 MiB

    It is the cheapest “infrastructure” in the whole stack: a few dozen megabytes that replace a router configuration, a certificate manager, and a dynamic-DNS account.

    FAQ

    Do I still need the ports published in docker-compose?

    Only for LAN access. If you use the tunnel URL from inside the house too (which you can — the traffic just takes a short detour through the edge), you can remove the published ports from the service stacks and the tunnel becomes the sole entry point. Many people keep both: LAN ports for speed on the couch, tunnel for everywhere else.

    What happens if my home internet goes down?

    Everything goes down — the tunnel, the services, the lot. This is not a Cloudflare limitation; it is physics. What the tunnel does remove is the whole class of “my IP changed / my port forwarding broke” failures, which is most of the real-world breakage.

    Is the free tier enough?

    Yes. Named tunnels are free, the certificate is free, and the bandwidth is the same as any other traffic through Cloudflare on your domain. The paid plans add analytics and enterprise controls you do not need for a home server.

    Can I use this without my domain on Cloudflare?

    No — the public hostname model requires the zone to be proxied by Cloudflare. If your domain lives elsewhere and you want no third-party edge at all, that is the Tailscale case: install it on the server and the clients, no domain required.

    Where does this fit?

    This is the door for the rest of the series: Home Assistant, Vaultwarden, Immich, and Gitea all get stable HTTPS URLs from the one container in this guide. And the 3-2-1 backup strategy is what keeps the data behind the door from being the only copy you have.

  • Vaultwarden in Docker: Self-Hosted Bitwarden Password Manager

    Vaultwarden in Docker: Self-Hosted Bitwarden Password Manager

    Vaultwarden is a self-hosted server compatible with the Bitwarden client apps: your passwords, notes, cards and identities live in an encrypted database you control, and every official Bitwarden app — browser extension, desktop, mobile — connects to it unchanged. It runs in a single container with a tiny footprint, which makes it one of the cheapest “replace a SaaS” moves in self-hosting. This guide covers the Docker Compose setup, connecting the apps, the admin panel, and the backup habit that matters most.

    Beginner · 8 min · Docker

    What you are actually getting

    • Bitwarden-compatible server. Vaultwarden implements the Bitwarden server API. Clients do not know or care that it is not bitwarden.com — you just change the server URL.
    • Zero-knowledge encryption. Vault and items are encrypted on your device before they reach the server. The server stores ciphertext. A copy of the database on a NAS is useless to anyone who does not have your master password.
    • One container, one folder. No database server to babysit: state (SQLite) and attachments live in a single /data volume.

    The honest caveat: Vaultwarden is a community project maintained by volunteers. It is the de-facto standard for self-hosted Bitwarden, but it is not Bitwarden’s own enterprise server — you give up official enterprise features (SSO/SAML, fine-grained org policies) in exchange for running on a Pi.

    Prerequisites

    • Docker + Compose plugin
    • A free TCP port (this guide uses 8222; the container serves on 80 internally)
    • Strong credentials — this is the thing that guards every other login you have

    Step 1: The compose file

    mkdir -p ~/stacks/vaultwarden && cd ~/stacks/vaultwarden

    Create docker-compose.yml:

    services:
      vaultwarden:
        image: vaultwarden/server:latest
        container_name: vaultwarden
        ports:
          - "8222:80"
        environment:
          DOMAIN: "https://vault.example.com"
          SIGNUPS_ALLOWED: "true"
          # After the first account exists, flip this to "false"
          ADMIN_TOKEN: CHANGE-ME-64-CHARS-HEX
        volumes:
          - ./data:/data
        restart: unless-stopped

    Notes on the three environment variables:

    • DOMAIN — the public URL clients will use. Set it to your final TLS URL before connecting apps, because some app flows (email, OAuth-style links) embed it. If you are LAN-only for now, http://YOUR_SERVER_IP:8222 works, but update it when you add TLS.
    • SIGNUPS_ALLOWED — leave true only long enough to create your account, then set it to false and restart. Open signups on a public URL is how people end up with stranger accounts in their vault server.
    • ADMIN_TOKEN — required to open the admin panel (at /admin). Generate it with openssl rand -hex 32. It is a master key to the admin interface: treat it like a password and keep it out of git.

    Step 2: Start it and create your account

    docker compose up -d
    docker compose logs -f vaultwarden

    Open http://YOUR_SERVER_IP:8222 and register. Your first account is an administrator of the implicit personal organization. Set a very strong master password here — it is the only key that decrypts your vault, and there is no “forgot password” that works the way you hope. Write it down offline.

    Immediately after: set SIGNUPS_ALLOWED: "false" and docker compose up -d again.

    Step 3: Connect the official Bitwarden apps

    This is the part that makes Vaultwarden feel free: every official Bitwarden client works against it.

    1. Browser extension (Firefox/Chrome/Edge/Safari): in the extension settings, set Self-hosted and enter the server URL (http://YOUR_SERVER_IP:8222 or your TLS URL). Log in with your Vaultwarden account.
    2. Mobile apps (iOS/Android): add a new organization / server, enter the same URL, log in.
    3. Desktop apps: same — set the server URL in settings before logging in.

    Everything syncs between them through your server. The extension autofills, the mobile app has the vault offline, and the desktop app is your admin fallback. You are now running your own password infrastructure with no configuration difference visible in the clients.

    Step 4: The admin panel

    Open http://YOUR_SERVER_IP:8222/admin and paste your ADMIN_TOKEN. Useful things in there:

    • Users — see every account, disable one you no longer recognize.
    • Settings → Global settings — the runtime view of every environment variable, editable without editing the compose file (changes apply on save; the compose values remain the source of truth on restart).
    • System — server version, pending updates, and a one-click update server button.

    Step 5: TLS and external access

    Browsers are increasingly strict about where they will send a master password. For a URL you use daily, put TLS in front — the two clean options from the Security & Networking series:

    • Cloudflare Tunnel — https://vault.example.com, no open ports, certificates handled. We document it in the Security & Networking series.
    • Reverse proxy with a real certificate — Caddy or Nginx Proxy Manager, same idea, more knobs.

    Then update DOMAIN in the compose file to the HTTPS URL, restart, and point all clients at it. Do not forward port 8222 on the router: a publicly scannable login page for your password vault is precisely what attackers probe first.

    Backups (the part that actually matters)

    Everything is in ./data — the SQLite database, attachments, and (if enabled) the encrypted audit log. A backup is copying that one folder. Because the contents are already encrypted with your master password, you can store the backup anywhere, including the same machine’s offsite target:

    restic -r s3:http://YOUR_MINIO_IP:9000/backups backup ~/stacks/vaultwarden/data

    One habit that saves you: export a full encrypted vault file from the Bitwarden app once a month (Vault → Export → Encrypted JSON) and keep it somewhere the server cannot reach. If the data volume is ever corrupted or lost, that file plus your master password is a complete restore. The server database is the source of truth; the encrypted export is the insurance policy.

    Resource usage (measured)

    StateRAM
    Idle~30–50 MiB
    Syncing a large vault (thousands of items)~80–120 MiB

    It will run on the smallest Pi you own, next to everything else, and you will forget it is there. That is the point.

    Updating

    docker compose pull && docker compose up -d

    Vaultwarden migrations run automatically on start. Check the admin panel’s system page after the first start to confirm the new version. As with any service guarding credentials, a one-line restic backup of ./data before a major update is free insurance.

    FAQ

    Is this secure? It is not Bitwarden Inc’s server.

    The encryption model is the same: zero-knowledge, AES-256, keys never leave your device. The risk profile shifts from “a company holds ciphertext and could be compelled” to “you hold the ciphertext and must not lose your master password”. For most people that is a net improvement. Read the project’s security page for the details, and note it is a community project — audit it if that matters to you.

    Can I migrate from bitwarden.com?

    Yes, in two steps. Export an encrypted JSON from the official app (never the unencrypted CSV to a file you store), then in the Vaultwarden-connected app use Import to bring that encrypted file in. Your items land in your self-hosted vault with their encryption intact.

    What about 2FA?

    Vaultwarden supports TOTP (authenticator apps) and YubiKey/WebAuthn per user, set under your profile. Turn on TOTP the day you create the account. The master password plus TOTP is the floor for a credential vault.

    Can I run it on the same box as everything else?

    Yes — it is one of the lightest services in this whole series. The only requirement is that ./data is included in whatever backup job protects the rest of the stacks.

    Where does this fit?

    Vaultwarden is the credentials layer: it is what you log into for Miniflux, Jellyfin, and every admin panel on this site. Reaching it from outside the house is the tunnel setup from the Security & Networking series, and its data folder is one line in a restic job — the 3-2-1 backup strategy guide walks through that loop.

  • Home Assistant in Docker: A Self-Hosted Smart Home Guide

    Home Assistant in Docker: A Self-Hosted Smart Home Guide

    Home Assistant is the operating system for smart home devices: it talks to hundreds of brands over local protocols (Zigbee, Z-Wave, MQTT, Wi-Fi) and keeps the automation logic on your own hardware instead of a cloud you do not control. It runs in one container, but unlike most services on this site it expects to reach your LAN directly — which changes how you set it up. This guide covers the Docker Compose install, the .env file that trips people up, adding devices, and keeping automations running.

    Intermediate · 12 min · Docker

    Why Home Assistant over a brand hub

    Brand hubs (Philips Hue app, Tuya, HomeKit) all share the same weaknesses: your automations live in their cloud, every device from a different vendor needs its own app, and when the cloud has a bad week your lights stop following your schedule. Home Assistant inverts that:

    • Local first. Zigbee, Z-Wave, Thread, MQTT and most Wi-Fi integrations talk straight to the radio or device. The cloud is optional.
    • One brain for all vendors. A Hue bridge, a Tuya plug (via the Tuya local integration), an ESPHome light and a Sonoff switch coexist in one UI, one automation engine, one voice interface.
    • Automations you can read. Every rule is a small YAML document on disk. No black-box “smart scenes” you cannot inspect.

    The honest caveat: Home Assistant is a platform, not a product. Setup takes longer than a brand app, and when something misbehaves the answer is “read the integration’s logs” rather than “call support”. For people who already run Docker for anything, that trade is worth it.

    Prerequisites

    • Docker + Compose plugin
    • A machine on your LAN with a stable IP (this guide assumes 8123 is free)
    • Optional: a Zigbee or Z-Wave USB stick if you have (or plan) radio-frequency devices

    Step 1: The compose file

    mkdir -p ~/stacks/homeassistant && cd ~/stacks/homeassistant

    Create docker-compose.yml:

    services:
      homeassistant:
        container_name: homeassistant
        image: ghcr.io/home-assistant/home-assistant:stable
        volumes:
          - ./config:/config
          - /etc/localtime:/etc/localtime:ro
        env_file: .env
        network_mode: host
        restart: unless-stopped
        # Uncomment for Zigbee (ZHA) or USB serial adapters:
        # devices:
        #   - /dev/ttyUSB0:/dev/ttyUSB0

    Two choices here are deliberate and worth understanding:

    • network_mode: host — Home Assistant needs to discover and reach many devices on your LAN (mDNS, Chromecast, bridges on fixed IPs). Container networking with published ports works for basic use but breaks a surprising number of integrations that rely on being a real LAN host. Host networking is the documented, lowest-friction option for the official image. The trade: it listens on all host interfaces, so keep it on a machine you trust on that LAN.
    • ./config:/config — your entire HA state lives here: automations, integrations, history (if you enable the SQL recorder), add-ons. This folder is what you back up and what makes a reinstall take minutes.

    Step 2: The .env file (the part that breaks installs)

    The official image requires a PASSWORD variable in an env file — and it must be at least 12 characters. If the file is missing or too short, the container starts and immediately exits, and the log will not obviously say why. Create .env next to the compose file:

    PASSWORD=change-me-to-at-least-12-chars

    Notes:

    • This password is not your web UI login password. It is an internal variable the image checks at startup; your actual account is created in the setup wizard. People conflate the two and end up “wrong password” loops.
    • Keep .env out of git and out of backups that leave the house (or encrypt them). The name is scary, but treat it like a credential anyway.
    • If you skip this file, docker compose up will fail with env file ... not found — that error is correct and is the feature working, not a bug.

    Step 3: Start it and run the wizard

    docker compose up -d
    docker compose logs -f homeassistant

    Wait for the line Home Assistant 2026.x.x, then open http://YOUR_SERVER_IP:8123. The onboarding wizard asks for your name, location (used for sunrise/sunset automations — keep it accurate, or set “precise location off” and enter coordinates manually if you prefer), and your account. That account is your administrator.

    Step 4: Add your first devices

    Go to Settings → Devices & Services → Add Integration. The practical starting points, in order of “works first try”:

    1. Hue Bridge — if you have any Philips lights, this is the gold standard: reliable, local, fast.
    2. ESPHome — if you ever build or buy flashed ESP devices. Once the device is flashed, HA’s ESPHome integration configures itself from the device.
    3. Matter — for new Matter-certified devices. HA acts as the Matter controller; the phone is just a commissioning remote.
    4. ZHA (Zigbee) — if you plug a Zigbee stick into the server, add the ZHA integration and pair from Settings → ZHA → Add Device. Remember to uncomment the devices: line in the compose file and restart before pairing.

    Each integration’s page lists its quirks (the Tuya local one, for example, needs your local credentials extracted from the device — the integration’s docs walk through it). When an integration “does not work”, the integration’s own documentation page is more current than any blog post; read the troubleshooting section before digging in logs.

    Step 5: Your first automation

    The classic starter, and the one that sells the platform:

    Lights in the hallway turn on at sunset when motion is detected, and turn off 5 minutes after the last motion.

    In Settings → Automations & Scenes → Create Automation, build it with the UI (no YAML needed): trigger Motion detected (your sensor), condition Sun has set, action Turn on light + Wait 5 minutes + Turn off light. Save it. It now runs entirely on your hardware, and you can open it any time to see exactly what it does.

    Once you trust it, add the one that changes your life: When everyone’s phone leaves the home Wi-Fi for the night, arm the lights/locks scenario. Phone presence detection works out of the box via Wi-Fi, no extra hardware.

    Keeping it reliable

    • Enable the recorder (Settings → Dashboard → recorder) if you want graphs and history. It adds a SQLite database to ./config; a NAS-backed volume or Postgres if you want it serious.
    • Back up ./config weekly — it is small (tens to low hundreds of MB) and contains your entire setup. A restic job pointed at MinIO is one command.
    • Update deliberately. HA ships a new release every two weeks. The container image is :stable, so docker compose pull && docker compose up -d is the upgrade path, but do it on a weekend, not a Tuesday — integrations occasionally break, and the fix is usually the next patch or a line in your automation.

    Access from outside

    Home Assistant’s own docs point at Nabu Casa — a managed, paid remote-access service — but the free, local-first equivalent is a tunnel (Cloudflare Tunnel, covered in the Security & Networking series) that gives you ha.example.com with zero open ports and full TLS. Pair it with a strong password and (ideally) MFA on the HA account, and you can check the house from anywhere without exposing port 8123.

    Resource usage (measured)

    StateRAM
    Idle, ~20 entities~300–400 MiB
    Recorder enabled, ~100 entities~500–700 MiB
    Active Zigbee network, ~200 entities~700 MiB–1 GiB

    It is a Python app with a database in its pocket — it does not run on 512 MB, and a 2 GB machine is the realistic minimum if you enable the recorder. A Pi 5 with 4–8 GB handles a real household comfortably.

    Updating

    docker compose pull && docker compose up -d

    FAQ

    Can I run it without host networking?

    Yes, for a simple setup: publish 8123:8123 and skip network_mode: host. You will lose some discovery-based integrations and may need to add static device entries by IP. If everything you run is a bridge (Hue) or a cloud integration, bridge mode is fine. The moment you add Zigbee radios, Chromecasts, or mDNS-dependent devices, host networking is the path of least resistance.

    What if a device only works through its cloud (Tuya, some Wi-Fi plugs)?

    Often there is a local integration (Tuya Local, Shelly, ESPHome flash) that takes the cloud out of the loop. The community is aggressively building these out. If an integration is cloud-only, your automation will depend on that vendor’s cloud — accept it knowingly or choose a device with a local option.

    Does the UI work on my phone without the app?

    The web UI is a full PWA; add it to your home screen and it behaves like an app, including offline-ish caching. The official apps add voice (Assist) and notifications; the web UI is enough for control and automations.

    How is this different from HomeKit?

    HomeKit is a standard for Apple devices; Home Assistant is a platform that can speak to HomeKit (expose your HA devices to Apple Home) while also speaking to everything else. Running HA as the brain and letting Apple Home be one of its outputs is the common power-user topology.

    Where does this fit?

    Home Assistant is the “physical world” layer of a self-hosted home: Jellyfin for the TV, Navidrome for the speakers, and HA as the thing that knows when the sun set and the house is empty. All of them reachable from outside the house via a tunnel or Tailscale — see the Security & Networking series.

  • 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/valkey/valkey:9
        container_name: immich-redis
        restart: unless-stopped
    
      immich-db:
        image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
        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 a Postgres build with the vector extensions compiled in — Immich needs pgvector and pgvecto.rs for blur search and the embedding store. As of Immich v3.2.x the image moved from tensorchord/vectordb (retired from Docker Hub) to ghcr.io/immich-app/postgres. Do not swap it for a plain 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:

  • MinIO in Docker: Self-Hosted S3 Object Storage (Compose Guide)

    MinIO in Docker: Self-Hosted S3 Object Storage (Compose Guide)

    MinIO is an S3-compatible object store you run in a single container: any app that speaks the S3 protocol — backups, media servers, databases, CI pipelines — can store and fetch files from it without changing a line of configuration. It runs in about 200 MB of RAM at idle, and a single-disk setup is enough for personal and small-team use. This guide covers the Docker Compose setup, first bucket, connecting clients, and the settings that matter.

    Beginner · 8 min · Docker

    Why you would want your own S3

    Three reasons come up constantly in self-hosted setups:

    • Backups need a second location. Restic, Borg, and Duplicacy all write natively to S3. A folder on the same machine is not a backup destination; an S3 bucket is at least a clean abstraction, and you can point it at a second machine later without touching your backup scripts.
    • Apps want S3, not folders. Nextcloud, some monitoring stacks, and a long tail of SaaS-style tools take an S3 endpoint, access key and secret key as configuration. MinIO gives them that endpoint on your own hardware.
    • Portability. Your data lives in a standard protocol. If you outgrow a single disk, you can move the bucket to a MinIO cluster or even to cloud S3 and most clients keep working.

    The honest caveat: single-node MinIO with one volume is not a replicated, self-healing storage system. It is a well-behaved S3 endpoint in front of your disk. Treat it as such: back the volume up like any other data directory.

    Prerequisites

    • Docker + Compose plugin
    • A free TCP port (this guide uses 9000 for the API and 9001 for the web console)
    • A folder for the data — put it on your largest disk; MinIO writes everything to it

    Step 1: The compose file

    mkdir -p ~/stacks/minio && cd ~/stacks/minio

    Create docker-compose.yml:

    services:
      minio:
        image: minio/minio:latest
        container_name: minio
        command: server /data --console-address ":9001"
        environment:
          MINIO_ROOT_USER: chikewa
          MINIO_ROOT_PASSWORD: CHANGE-ME-16-CHARS
        ports:
          - "9000:9000"
          - "9001:9001"
        volumes:
          - ./data:/data
        restart: unless-stopped

    Three notes on that file:

    • command: server /data — the data directory is a single path. MinIO supports multiple drives per node (pass several paths, space-separated) for larger single-node deployments.
    • MINIO_ROOT_USER / MINIO_ROOT_PASSWORD — the root credentials. The password must be at least 8 characters; use a long random one. You can create additional users later with their own keys (see Step 4), which is the right way to hand out access to apps.
    • --console-address ":9001" — the web UI. If you would rather not expose the console at all, remove port 9001 from ports and use mc (the CLI) instead.

    Step 2: Start it and open the console

    docker compose up -d
    docker compose logs -f minio

    Wait for the log line API: http://192.168.x.x:9000, then open http://YOUR_SERVER_IP:9001 and log in with the root credentials. The console shows buckets, usage, and a simple file browser — enough for day-to-day checking.

    Step 3: Create a bucket

    In the console, click Add Bucket, pick a name (lowercase, no spaces, e.g. backups), and leave the defaults. That is it — no ACL gymnastics needed for a private bucket. MinIO buckets are private by default; access is controlled by credentials, not by open listing.

    Step 4: Create a dedicated user for apps

    Do not hand the root keys to your backup job. In the console go to Access Keys → Add User (or use mc), create a user, then create an access key for it. Attach a policy that limits it to the bucket it needs:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket", "s3:DeleteObject"],
          "Resource": [
            "arn:aws:s3:::backups",
            "arn:aws:s3:::backups/*"
          ]
        }
      ]
    }

    Save that as a custom policy and attach it to the user. Now every app on your network uses its own key, and you can revoke one without touching the others.

    Step 5: Connect a client

    Any S3 client works. Three examples you will actually use:

    mc (MinIO CLI) — the official client, one command to configure:

    mc alias set myminio http://YOUR_SERVER_IP:9000 ACCESS_KEY SECRET_KEY
    mc mb myminio/backups
    mc cp big-file.iso myminio/backups/

    restic — backup to your own S3:

    restic init --repository s3:http://YOUR_SERVER_IP:9000/backups \
      --s3-provider minio --s3-access-key ACCESS_KEY --s3-secret-key SECRET_KEY \
      --s3-region us-east-1 --s3-no-verify-ssl
    restic -r s3:http://YOUR_SERVER_IP:9000/backups backup /home

    The us-east-1 region value is a dummy: MinIO accepts any region string, and --s3-no-verify-ssl is only needed while you are on plain HTTP inside your LAN. Once you put TLS in front (see below), drop that flag.

    aws CLI — if an app or script demands it:

    aws --endpoint-url http://YOUR_SERVER_IP:9000 s3 ls
    aws --endpoint-url http://YOUR_SERVER_IP:9000 s3 cp report.pdf s3://backups/

    Step 6: TLS and external access

    MinIO ships with a self-signed certificate automatically; clients can use HTTPS against it with no-verify, which is fine for testing but not for anything you leave on. The two clean paths, in order of preference:

    • Cloudflare Tunnel or Tailscale Funnel in front of MinIO — no open ports, TLS handled for you. We cover Cloudflare Tunnel in the Security & Networking series.
    • Reverse proxy (Caddy / Nginx Proxy Manager) with a real certificate — the same foundation you would use for any other exposed service.

    Do not forward port 9000 directly on your router. An S3 endpoint with a weak key is exactly the thing credential-stuffing bots look for.

    Resource usage (measured)

    From a single-disk, single-bucket setup on a Pi 5-class machine:

    StateRAM
    Idle~180–220 MiB
    Steady single-stream copy (1 GbE)~250 MiB, disk-bound at ~100 MB/s

    Throughput is your disk, not MinIO. A SATA SSD will saturate a gigabit line easily; a mechanical disk will not. If you need more, the single-node multi-drive setup (multiple paths in command) stripes across them.

    Updating

    docker compose pull && docker compose up -d

    MinIO stores its metadata inside the data volume; there is no separate database to migrate. Your buckets and objects are untouched by updates.

    FAQ

    Can I run MinIO on a Raspberry Pi?

    Yes, with the usual caveat that a Pi’s storage is the bottleneck. It is a perfectly good S3 endpoint for backups, metadata-heavy workloads, and small files. For large video libraries, put the data on a NAS-class machine and let the Pi run the apps that consume it.

    What happens to my data if the container breaks?

    Everything is plain files under ./data. You can copy that folder to another machine, point a fresh MinIO at it, and serve the same objects. That is the real safety property of single-node MinIO: the data is not locked in a proprietary format.

    Do I need versioning?

    Turn it on (console → bucket → versioning) if the bucket holds anything you do not want an accidental delete to destroy — backup repositories, irreplaceable originals. It costs you storage equal to whatever you overwrite.

    How is this different from Samba or a plain folder?

    A folder gives you a filesystem; S3 gives you an API. Anything that needs to store objects programmatically — backup software, web apps, pipelines — speaks S3, not CIFS. MinIO is the cheapest way to get that API on hardware you own.

    Where does this fit?

    MinIO is the storage layer for the rest of the stack: Miniflux keeps its database tiny, Jellyfin keeps its media in a folder, and MinIO is where the copies that survive a disk failure live. See the starter guide for the full picture.

  • What Hardware for a Home Server? Raspberry Pi vs Mini PC vs Desktop

    What Hardware for a Home Server? Raspberry Pi vs Mini PC vs Desktop

    The most expensive decision in self-hosting is the one you make before the first docker compose up: what hardware does the server run on? Get it wrong and you either pay for performance you never use, or spend every transcoding session wishing you had. This guide is the buying decision, not the assembly instructions. It compares the three realistic options for a first home server — a Raspberry Pi, a used mini PC, and a repurposed desktop — with the kind of numbers that are hard to get from a product page, because we measured them.

    Beginner · 10 min · Linux

    What a home server actually needs

    Before comparing machines, it is worth being precise about the workload, because “home server” is three different jobs that people merge into one.

    Job 1 is the always-on baseline: a handful of small containers (RSS, notes, a music server) idling 24/7. This is where power consumption is the real cost: a machine that idles at 6 W costs roughly four times less to run per year than one that idles at 24 W, on typical EU tariffs, and it will outlive its components because the disks and fans do the little work.

    Job 2 is bursty media work: transcoding, photo optimization, large initial scans. This is CPU- and disk-bound, and it is the job that punishes underpowered hardware. A machine that idles beautifully can still be the wrong machine if it cannot take a 1080p transcode without the rest of the house noticing.

    Job 3 is storage: files, photos, backups. This is where capacity and endurance matter more than anything else, and where the storage decision is separate from the computer decision. We cover storage at the end, because it is the one part of a home server that is genuinely hard to upgrade later.

    Option 1: Raspberry Pi

    The Raspberry Pi is the classic entry point for good reasons: it idles at about 3–5 W, it is cheap, it is quiet, and for Job 1 it is completely sufficient. A Pi running a dozen small containers is the right machine for a first server that is learning what it will actually be used for.

    The honest limits are Job 2 and storage. There is no hardware transcoding worth having, single- or dual-core performance is a fraction of a mini PC, and the microSD slot is the weakest storage path in the hobby — fine for the OS, wrong for a media library. The pricing situation is also worth knowing before you buy: after the 2025–2026 memory-price increases, the line-up we see is roughly $45 for a 1 GB Pi 5, $85 for an 8 GB Pi 4, and $205 for a 16 GB Pi 5, with a 16 GB board at the top. If a Pi is the right machine for you, the 8 GB model is the one to buy — 1 GB is a development toy, not a server.

    Our rule of thumb: buy the Pi to learn the workflow, and treat it as a trial of your actual needs. Most people who start on a Pi discover within a year exactly which job it cannot do, and that discovery is worth the price of admission.

    Option 2: the used mini PC (our default recommendation)

    For a server that will do all three jobs, the used mini PC is the best value in the hobby, and it is the class of hardware the Chikewa lab runs. The shape of the deal is consistent across the market: machines from the 2019–2023 corporate refresh cycle (the Intel NUC class, Dell, Lenovo, HP equivalents) sell used with a 4- or 6-core U-series or T-series CPU, 16 GB of RAM and a 256–512 GB NVMe, for a fraction of the new price.

    Why this class wins for most people. It idles at 10–15 W — far above a Pi, far below a desktop. Its cores handle the baseline containers with room to spare. And crucially, most of them have an Intel iGPU, which means hardware transcoding is available out of the box: the same Quick Sync path our Jellyfin guide documents. That is a feature the Pi simply does not have, and it is the feature that separates “direct play” from “stutter” when a client asks for a transcode.

    What to check before buying, in order:

    • Generation, not brand. Anything from Intel 8th generation (Coffee Lake, 2017) onward has four real cores and DDR4; anything older is fine for Job 1 only.
    • RAM: 16 GB if you can get it, 8 GB as the floor. Containers are cheap, but a media server with a big library and a few active streams eats RAM faster than you expect.
    • Two M.2 slots if you can find them. One for the OS, one for a faster media SSD. This is the single most common “I should have checked this” in used mini PC buying.
    • No visible corrosion, and a seller who will boot it for you. A used machine that will not POST is not a bargain, it is a repair project.

    The lab machine for this series is exactly this class: a 4-core i5-6500T (2.5 GHz, 3.1 GHz burst), 16 GB of RAM and a 233 GB NVMe drive. It idles Jellyfin at around 240 MiB, handles a 1080p transcode without breaking a sweat, and its disk numbers are below, because disk is where used hardware surprises you.

    Option 3: the repurposed desktop

    If you already own a desktop, or can get one for nothing, it is a legitimate server: maximum cores per euro, easy RAM upgrades, and storage space no mini PC matches. The case against it is the steady state: an old desktop idles at 40–80 W, which on a 24/7 schedule costs more per year than the machine cost secondhand, and it is louder than you remember.

    Our rule: a repurposed desktop is the right server for a heavy transcoding or backup workload you have already proven you need, and the wrong server for a first machine. Do not buy a desktop to start with; earn it.

    Storage: the decision that is hard to reverse

    The computer is replaceable; the data is not. Three principles, in order of importance.

    1. The 3-2-1 rule before any product choice. Three copies of what matters, on two different media, with one off-site or off-box. For a home server this usually means the original, a second disk in the same box, and a drive that physically leaves the house (or a remote backup target). No amount of fast storage compensates for two copies in one fire.

    2. Match the disk to the job. OS and databases want NVMe; media and bulk storage want big, cheap, low-power HDDs; photos and anything you are transcribing want SSD. Our lab numbers on the NVMe — 622 MB/s sustained writes, 1.1 GB/s reads — are what “fast enough for anything” looks like, and they come from a drive that costs a small fraction of the machine. For a first server, one NVMe for the OS and one large HDD for media is the configuration we would actually build.

    3. Never put the only copy of your data on the same drive as the OS. A corrupted filesystem takes the data down with the system that was supposed to serve it. A separate volume, a separate disk, ideally a separate machine for the second copy.

    The numbers we measured

    ComponentSpecMeasured
    CPUIntel i5-6500T, 4 cores, 2.5 GHz / 3.1 GHz burst173 MB/s single-core sha256
    RAM16 GB DDR413 GB available at rest
    Disk233 GB NVMe (8% used)622 MB/s write, 1.1 GB/s read (sustained, 512 MB)
    SwapNone configured—

    Context for the sha256 number: it is a single-core memory-bound load, which is roughly what a checksum-heavy backup job or a small transcode looks like to one core. Four of those cores working in parallel is why a 4-core U-series machine feels fast for server work even at modest clocks.

    Decision summary

    • Learning, low budget, low power: 8 GB Raspberry Pi, OS on a quality microSD, small SSD for anything persistent.
    • The default for a real first server: used 8th-gen-or-newer mini PC, 16 GB RAM, NVMe for OS + large HDD for media, iGPU for transcoding.
    • Heavy transcoding / backup you have already proven: repurposed desktop or a current-gen box with a discrete GPU.
    • Every option: 3-2-1 for the data that matters, and a second disk before the first one is full.

    Whichever you choose, the next step is the same: a clean Linux install, Docker, and the starter guide. The hardware only decides how much headroom you have when the stack grows.

    FAQ

    Is a Pi 5 enough for a family media server?

    For direct play of well-encoded content, yes. The moment a client needs a transcode, there is no hardware path to save it, and the CPU will spend the rest of the movie at 100%. It is a great learning machine and a limited media server, and it is worth being clear about which one you are buying.

    How much RAM do I actually need?

    8 GB runs a modest stack with headroom; 16 GB is the number we recommend for anything that will host a media library plus a few other services, because it is cheap used and it removes a whole class of “why is it swapping” questions. 32 GB is only justified with a big Plex/Jellyfin library plus a VM or two.

    Should I buy new instead of used?

    For the computer part of a home server, used is almost always the better deal: the performance gap between a two-year-old and a current mini PC is smaller than the price gap, and the failure modes (a disk, a fan) are the same either way. New money is better spent on storage, where reliability and warranty still matter.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardwarei5-6500T / 16 GB RAM / 233 GB NVMe
    SoftwareNVMe: 622 MB/s write, 1.1 GB/s read

    Last tested: 23 August 2026

  • Secure Your Home Server: SSH, Firewall and Docker Networks

    Secure Your Home Server: SSH, Firewall and Docker Networks

    A home server is a machine that other people and devices on your network rely on every day. That makes security not a feature but a foundation: if the box is reachable in the wrong ways, everything running on it — photos, files, media — is reachable too. This guide covers the three layers that matter most, in the order they should be built: SSH, the host firewall, and Docker network isolation. Everything here was run and verified in the Chikewa lab on a Debian 12 machine, and every command you see is a command that actually executed there.

    Intermediate · 12 min · Linux

    Why home server security is different

    A laptop connects to networks you control; a home server usually sits behind a router with a dynamic IP, runs services that must be reachable by design, and rarely gets the patch attention a phone or laptop does. The threat model is simple and worth stating plainly: scanners probe residential IP ranges constantly, and anything that answers on a public port gets attempted logins within minutes. The goal of this guide is not paranoia — it is making sure the only things that answer are the things you intend to expose, and that they answer with keys, not passwords.

    Layer 1: SSH

    SSH is how you administer the box, and it is the first thing attackers try. Hardening it is a ten-minute job with a permanent payoff. We verified every step on Debian 12 with OpenSSH 9.2p1.

    Keys before you touch the config

    Generate a keypair on your laptop — do this before you change anything on the server:

    ssh-keygen -t ed25519 -C "your-name@laptop"
    

    We used ed25519 in the lab: it is the modern default, faster and shorter than RSA. Copy the public half to the server:

    ssh-copy-id user@your-server
    

    Now test that key authentication works while your normal password session is still alive:

    ssh -o BatchMode=yes user@your-server "echo KEY-AUTH-OK"
    

    The -o BatchMode=yes flag disables password prompting, so this test can only succeed with a key. If it prints KEY-AUTH-OK, you are safe to lock the door. If it prints Permission denied (publickey, password) — which is exactly what happened in the lab until the public key was in place — fix the key first, because the next step removes the password fallback entirely.

    Locking out passwords

    Add a drop-in config (Debian reads /etc/ssh/sshd_config.d/ after the main file, so this wins without editing the original):

    sudo mkdir -p /etc/ssh/sshd_config.d
    sudo tee /etc/ssh/sshd_config.d/10-chikewa.conf <<'EOF'
    PermitRootLogin no
    PasswordAuthentication no
    PubkeyAuthentication yes
    EOF
    sudo /usr/sbin/sshd -t
    

    The sshd -t syntax check is not optional. A typo here does not just fail the reload — depending on timing it can lock you out of a server you cannot reach. Run it, read the result, and only then:

    sudo systemctl reload ssh
    

    Reload, not restart: it applies the new config to new connections without dropping the one you are sitting in. From this point, every login requires a key. Keep a second key on a different device — losing the laptop should not mean losing the server.

    Layer 2: The host firewall

    SSH hardening protects the door. The firewall decides which doors exist. On Debian the tool is UFW, and the posture we use in the lab is deny-incoming, allow-outgoing, with explicit exceptions:

    sudo ufw default deny incoming
    sudo ufw default allow outgoing
    sudo ufw allow OpenSSH
    sudo ufw enable
    sudo ufw status verbose
    

    Two things about this that are worth knowing before you type enable. First, enable it only after allow OpenSSH, or you will cut the very session you are typing in. Second, UFW manages its own iptables rules, and Docker manages its own; they coexist, but it means Docker-published ports can answer before the host firewall ever sees the packet. That is the whole reason for the next layer, and the most common source of the belief that “my firewall isn’t working.” It is working; it just is not the layer that answers port 8096.

    What to expose

    The honest answer for most people: nothing. A home server should be reachable from your LAN and from your phone via a private network, not from the public internet. The only port we would put on a residential box is 22, and even that is worth revisiting once you have a private-network option (see Layer 3 and our guide to running services, which shows the loopback-bound pattern). If a service genuinely must be public, it goes behind a reverse proxy with TLS and authentication — never a bare port forward.

    Layer 3: Docker network isolation

    Docker’s default bridge network connects every container to each other and gives them outbound internet access by design. That is convenient and, for a server that hosts strangers’ content, occasionally exactly what you do not want. Docker offers the fix as a flag on the network itself.

    Creating an internal network and putting a container on it:

    docker network create --internal internal-services
    docker run --rm --network internal-services alpine ping -c1 -W2 8.8.8.8
    

    On a default network the ping succeeds. On an --internal network it fails — which is what we verified in the lab: the container starts fine, but every outbound connection is refused. The network literally has no route out. Combine that with loopback-bound ports (the 127.0.0.1:8096:8096 pattern from our Jellyfin guide) and you have a container that can be reached only from the host, which in turn is reached only from your LAN or private network.

    The mental model to keep: default bridge is for containers that need the internet (webhooks, updates, APIs); internal is for containers that serve you and no one else. Sorting your stack into those two buckets is more security work than any single rule, and it takes five minutes with docker network ls.

    Verifying the whole stack

    Security that you cannot observe is security you cannot trust. Three checks we run after every change:

    # What is actually listening, and where?
    ss -tlnp
    
    # Is the firewall where it should be?
    sudo ufw status verbose
    
    # Do the container networks behave?
    docker network ls
    docker network inspect internal-services --format '{{range .Containers}}{{.Name}} {{end}}'
    

    The first command is the one that surprises people. In the lab, after binding Jellyfin to loopback, ss -tlnp showed 127.0.0.1:8096 — and the same box’s default bridge carried 172.17.0.1/16, an address space your whole LAN can route to if a port is ever published to it. Knowing which interface an IP lives on is the difference between “is this exposed?” and a guess.

    What this does not cover

    This is the foundation, deliberately. It does not cover a reverse proxy with TLS, which is the natural next step for any public service and belongs in its own guide; it does not cover backing up the config volumes that now hold your server’s identity (the ssh config, the UFW rules, the Docker named volumes); and it does not cover Tailscale-style private networking in depth, although the loopback pattern above is exactly the pattern you would pair with it. If a guide on remote access appears in this series, that is where it picks up.

    FAQ

    Do I really need UFW if Docker already has its own rules?

    Yes, for the non-Docker parts of the box: SSH, anything you run outside containers, and as a second opinion on what is reachable. But do not expect it to police Docker-published ports — that is Docker’s job, and the loopback-binding pattern is the right tool there.

    I lost access. Now what?

    This is why the key-first, test-before-lock sequence exists. If you are already locked out and you have no second key, the recovery path is physical or console access to the machine, or a cloud provider’s serial console — not a password reset over the network, because that is precisely what the config now refuses.

    Should I change port 22?

    It stops the lowest-effort scanners, which is real but small; it also creates a non-standard you will have to remember and document. We keep 22 and rely on keys plus the firewall. Security through port obfuscation buys you noise reduction, not protection.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core i5-6500T / 16 GB RAM
    SoftwareOpenSSH 9.2p1

    Last tested: 23 August 2026