Author: Chikewa

  • 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 Tunnelgit.example.com192.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 Tunnelhttps://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/redis:alpine
        container_name: immich-redis
        restart: unless-stopped
    
      immich-db:
        image: tensorchord/vectordb:pg16-v0.4.2-triton
        container_name: immich-db
        environment:
          POSTGRES_PASSWORD: CHANGE-ME-DB-PASSWORD
          POSTGRES_USER: immich
          POSTGRES_DB: immich
        volumes:
          - immich_db_data:/var/lib/postgresql/data
        restart: unless-stopped
    
    volumes:
      immich_db_data:
      immich_config:

    Notes on the non-obvious parts:

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

    Step 2: Start the stack

    docker compose up -d
    docker compose logs -f immich

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

    Step 3: Create your account

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

    Step 4: Connect the mobile apps

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

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

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

    Step 5: Let it catch up

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

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

    Step 6: The features worth turning on

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

    Access from outside your network

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

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

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

    Resource usage (measured)

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

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

    Updating

    docker compose pull && docker compose up -d

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

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

    FAQ

    Will it replace Google Photos without data loss?

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

    Can I import an existing library from a folder?

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

    Does it work with a NAS?

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

    What about backups of Immich itself?

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

    Where does this fit?

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

    What’s next?

    The natural next steps from this guide:

  • 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

  • Jellyfin in Docker: A Self-Hosted Media Server for Movies and TV

    Jellyfin in Docker: A Self-Hosted Media Server for Movies and TV

    Jellyfin is the self-hosted answer to Netflix and Plex: an open-source media server that plays your own movies and TV from your own disk, with no subscription and no upload limits. It is also one of the most misunderstood services in the self-hosting world, because the difference between a smooth setup and a constant transcoding fight comes down to a few decisions you make before the first movie. This guide walks through the Docker Compose setup we run in the Chikewa lab, what the hardware actually has to do, and the configuration choices that matter.

    Beginner · 12 min · Docker

    What is Jellyfin?

    Jellyfin streams video, music, photos and podcasts from folders on your server to any device on your network or on the internet. It was forked from the old free version of Plex in 2018, and unlike Plex it is fully open source: no premium tier, no server-side limits, no account required. The server does the heavy lifting (metadata scraping, transcoding, photo optimization) and thin clients on phones, TVs, browsers and media players do the playback.

    Two properties make it a good first “big” self-hosted service. First, it is a single container with no database dependency: the only thing you point it at is a folder of media files. Second, its default configuration is genuinely reasonable, which means the gap between “it started” and “it is actually usable” is small. The things that do trip people up are documented below, because they tripped us.

    Requirements

    Anything that runs Docker runs Jellyfin. The realistic floor is the same as the rest of the stack: 2 GB of RAM and a few hundred MB of disk for the configuration database. The real constraint is not starting the server, it is transcoding. If a client asks for a format the hardware cannot decode natively, Jellyfin re-encodes it on the CPU, and that is where underpowered machines start stuttering.

    Our lab machine is a 4-core i5-6500T with 16 GB of RAM and an NVMe disk. It idles Jellyfin at around 240 MiB of RAM and can comfortably handle direct play for the family and one light transcode at a time. If you are buying hardware specifically for a media server, read our hardware guide before you buy.

    The Compose File

    The complete file, exactly as it runs in the lab:

    services:
      jellyfin:
        image: jellyfin/jellyfin:latest
        container_name: jellyfin
        ports:
          - "127.0.0.1:8096:8096"
        volumes:
          - jellyfin-config:/config
          - ./media:/media
        environment:
          - PUID=1000
          - PGID=1000
          - TZ=Europe/London
        restart: unless-stopped
    
    volumes:
      jellyfin-config:
    

    Three decisions in this file are worth understanding.

    The port binding

    Notice the port is published as 127.0.0.1:8096:8096, not 8096:8096. That single change makes the difference between “my media server is reachable from the living room” and “my media server is reachable from the entire internet”. Bound to loopback, Jellyfin answers only on the host itself; you reach it from other devices through a reverse proxy or a private network, and we cover both in our security guide. We verified with ss -tlnp that the socket listens on 127.0.0.1:8096 only.

    The volumes

    Two things need to survive container recreation. jellyfin-config is a named volume holding the SQLite database, plugin state and user settings — losing it means re-creating users and re-scanning the library. ./media is your actual movie and TV folder, mounted read-only in spirit (Jellyfin never needs to write to your media, only read it). Keep media on the fastest disk you have: scan times and seek performance for random playback both depend on it.

    The environment variables

    PUID and PGID make the container run as your regular user instead of root, which matters if you ever mount media from a share with restrictive permissions. TZ keeps the activity log and the trickplay schedule sane. Nothing else is required.

    First Run

    Start it and watch the logs:

    docker compose up -d
    docker compose logs -f jellyfin
    

    The first boot takes noticeably longer than the other services in this series. On our machine the image pull, the database migrations and the plugin load completed in under a minute, and the log ended with Core startup complete. The health endpoint is a good objective check:

    curl http://localhost:8096/health
    # Healthy
    

    Open http://localhost:8096 (or through your proxy) and create the admin account. The setup wizard then asks where your media lives: the path is /media, because that is the mount point inside the container, not the host path. This is the most common first-run mistake — entering the host path makes Jellyfin scan an empty directory and you get a server that runs perfectly with zero content.

    Library Setup

    Add your first library, choose “TV Shows” or “Movies”, point it at the right subfolder of /media and let it scan. Jellyfin pulls metadata from TMDb by default, which works well for English content; the first scan of a medium library (a few hundred titles) took a few minutes in our test, downloading posters and fan art for everything.

    Two settings pay for themselves quickly. Under the library, enable save image assets to the content folder if you want the metadata to survive a config loss. And check realtime monitoring so new files are picked up without a manual rescan.

    Hardware Transcoding

    Direct play means the client decodes the file as-is: cheap, fast, and what you want 95% of the time. Transcoding happens when a client cannot play the source format. Our lab image ships with ffmpeg 7.1.4, and the encoder list includes h264_qsv, av1_qsv and hevc_qsv — Intel Quick Sync. The image also bundles the i965 driver, and the host exposes /dev/dri when the CPU has an Intel iGPU, so Quick Sync transcoding is available out of the box on most mini PC hardware.

    To use it, pass the device through and enable hardware transcoding in the admin dashboard (Playback → Transcoding). The compose addition is:

        devices:
          - /dev/dri:/dev/dri
    

    Without the iGPU, transcoding falls back to the CPU, which on a 4-core i5 handles a single 1080p encode but will struggle with several. If transcoding quality matters to you, that is a hardware conversation, not a configuration one.

    A pitfall we hit: you cannot exec ffmpeg

    Our first attempt to test the transcoder was docker exec jellyfin ffmpeg -hwaccels, which failed confusingly. The Jellyfin entrypoint intercepts every argument and hands it to the .NET server, so arbitrary commands never reach a shell. To inspect the bundled ffmpeg you need a separate container from the same image, or check the running server’s logs, which print the full encoder and hwaccel list at startup. The list we captured is in our hardware guide, if you want to compare.

    First Login and Daily Use

    Once the library is scanned, add a user per household member (the admin account is a fine user too, but separate users keep watch states and parental controls clean). Install a client: the browser works everywhere, the Android and iOS apps are good, and most smart TVs either run a native app or play through a browser. From the TV we verified direct play of 1080p MKV without a single transcode, which is the whole point.

    Remote access is the next natural step. Because we bound the port to loopback, the two clean options are a reverse proxy with authentication or a private network like Tailscale; the security guide covers the decision. Do not solve “I want to watch at my parents’ house” by republishing port 8096 on 0.0.0.0.

    Updating

    Jellyfin images move fast. The safe update:

    docker compose pull
    docker compose up -d
    docker compose logs -f jellyfin   # watch for "Core startup complete"
    

    Configuration and the database live in the named volume, so updates are non-destructive. One thing to know: after a major version bump the logs show a batch of Entity Framework migration warnings on the first boot. In our test run they appeared, the migrations applied, and the server came up cleanly. They look alarming and are cosmetic; what you should actually watch for is a missing Core startup complete.

    Troubleshooting

    The server runs but the library is empty

    Nine times out of ten this is the host-path-instead-of-container-path mistake from the first run. The media folder inside the container is /media. Check the library path in the admin dashboard, not the compose file.

    Playback stutters on one device only

    That device is transcoding when it should direct play. Open the activity log during playback: it records every transcode with the codec and resolution. If you see transcodes for a format your client should support, the file’s codec or profile is outside the client’s native range — re-encode that file, or accept the transcode.

    High CPU during scans

    Large initial scans and photo optimization are CPU-heavy by design and settle down. If CPU stays high with no scans running, check the trickplay generation schedule (it runs daily and is optional) and the number of concurrent transcodes in the playback settings.

    Resource Usage

    Measured in the lab, steady state with a 200-title library and no active playback: 236 MiB RAM, negligible CPU. The disk footprint of the config volume is tens of MB; the media is, of course, your own. Add roughly 0.5–1 GB per active hardware transcode, more for CPU transcoding.

    FAQ

    Is Jellyfin legal?

    Jellyfin is a legal, open-source application. What you put on the server is your responsibility, exactly as with any storage device you own.

    Can it replace Plex for a household?

    For the core job — stream my library to my devices — yes, and without a premium subscription. You give up some of Plex’ polished remote streaming and its ecosystem of third-party integrations. For most home setups that trade is a win.

    What about music?

    Jellyfin plays music, but if music is the priority, a dedicated server like Navidrome uses less RAM and has a better mobile experience. We run both in the lab.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core i5-6500T / 16 GB RAM / NVMe
    SoftwareJellyfin 10.11.11 (ffmpeg 7.1.4)

    Last tested: 23 August 2026

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

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

    Beginner · 7 min · Docker · Wiki

    Tested on:

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

    Last tested: 22 August 2026

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

    Why DokuWiki in 2026

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

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

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

    Prerequisites

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

    Step 1: The compose file

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

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

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

    Step 2: Start and run the wizard

    docker compose up -d
    docker compose ps

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

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

    Step 3: The editor and page syntax

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

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

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

    Step 4: The settings worth changing

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

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

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

    Step 5: Backing up a plain-text wiki

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

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

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

    Resource usage (measured)

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

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

    Updating

    docker compose pull && docker compose up -d

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

    FAQ

    Can I import existing markdown or org-mode notes?

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

    Does it work offline / without internet?

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

    Multiple users?

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

    Where does this fit?

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