Tag: Self-hosting

Core concepts and guides for running your own software and services.

  • Run Local AI on Your Home Server: The Complete Guide

    Run Local AI on Your Home Server: The Complete Guide

    Local AI has crossed a line: you no longer need a data-centre’s worth of silicon to run a model that answers questions, drafts text and summarizes documents. On the same mini PC or old desktop you already use for your home server, you can run a large language model entirely on your own hardware — no API key, no per-token bill, no prompt ever leaving your network. This guide is the map for that whole territory. It tells you what your machine can realistically run, which of the three main runtimes (llama.cpp, Ollama and LM Studio) fits how you work, how to pick a model by its size and quantization, and how to put a proper chat interface in front of it. Every command and number below was checked on real lab hardware, and each section links out to the dedicated guide for that piece of the stack.

    Beginner · 12 min · Local AI

    Why people bother: a local model is private by default, works offline, costs nothing after the hardware, and you can swap models whenever you like. The trade-off is that the quality ceiling is set by your RAM and GPU, so the first honest question is not “which app” but “what can my box actually run”.

    What your hardware needs to run

    The single most important number is how much of a model’s weights fit in memory at once. A model’s on-disk size is a close proxy for the memory it will want when loaded, plus a margin for the context window you give it. As a working rule:

    • Roughly 4–8 GB of free memory runs small models (around 0.5B to 4B parameters at 4-bit) comfortably on CPU. These are fast and good enough for summarization, quick Q&A and drafts.
    • 8–16 GB opens up mid-size models (7B–14B at 4-bit). On CPU these are slower but usable for short tasks.
    • A GPU with 8+ GB of VRAM changes everything: it lets you load the model weights into fast video memory and generate at tens of tokens per second instead of a handful. This is the difference between “it answers” and “it feels instant”.

    In the lab we used a 4-core Intel i5-6500T with 16 GB of RAM and no discrete GPU. It ran a 4.65B-parameter model at 4-bit at about 48 tokens per second reading a prompt and 14 tokens per second generating. That is genuinely usable for everyday tasks — you type, and the answer streams back in a couple of seconds. The point is not that this box is powerful; it is that a modest box is enough to start, and you can always grow into a GPU later.

    If you are deciding what to buy specifically for this, the home server hardware guide has real benchmarks and the same “mini PC is the best all-rounder” conclusion applies: a used mini PC with 16 GB of RAM is the cheapest way into local AI that does not feel like a toy.

    The three runtimes, and when to use each

    Under the hood, all three popular tools are doing the same job: loading a GGUF model file and serving it. They differ in how much they hide and how much control they give you.

    • llama.cpp is the foundation. It is a C++ program you build once, and it gives you the most control and the best pure-CPU performance. You drive it from the command line. Choose it when you want maximum performance on a CPU box, you are comfortable in a terminal, or you want to tinker with context length, threads and quantization yourself.
    • Ollama wraps the same engine in a single install script and a tiny CLI plus an API. One command pulls a model from a registry; another runs it. It is the fastest way to “just have a local model” and the one most tools assume. Choose it for a low-maintenance, scriptable setup.
    • LM Studio is a desktop app with a graphical interface: browse a model catalogue, one-click download, chat in a window, and flip on a local server when an app needs the API. Choose it if you want the friendliest on-ramp and you are working on a machine with a screen rather than a headless server.

    The good news is that the model files are interchangeable across all three. A GGUF you download for Ollama can be loaded in llama.cpp, and vice versa. So pick the runtime for how you like to work, not out of fear of being locked in. A practical comparison of the three, with the numbers we measured, is in the llama.cpp vs Ollama vs LM Studio comparison.

    Choosing a model: size and quantization

    Every model you will run is described by two numbers: its parameter count (the “B” number — 0.6B, 4B, 8B, 14B, 70B) and its quantization (Q4_K_M, Q5_K_M, Q8_0, and so on). More parameters generally means smarter output; a higher quantization means less quality lost when the model is compressed to fit in memory. The two trade against each other on the same memory budget: a smaller model at a higher quant often outperforms a bigger model at a lower one, up to a point.

    As a starting point on a 16 GB box with no GPU, a 4B to 8B model at 4-bit is the sweet spot. It loads quickly, leaves room for a long context, and is fast enough to be pleasant. If you add an 8 GB GPU, an 8B to 14B model at 4- or 5-bit is the target. What each quantization level actually costs in memory and quality — including what “Q4_K_M” means rather than treating it as a magic string — is broken down in the GGUF and quantization explainer.

    Putting a chat interface in front of your model

    A raw API endpoint is powerful but unfriendly for day-to-day use. Most people want a chat window with conversations, a model picker and a place to paste documents. Open WebUI is the most popular self-hosted option: it runs in a Docker container, connects to Ollama (or any OpenAI-compatible endpoint) in a couple of lines of config, and gives you a clean, chat-app-style interface that works in a browser on any device on your network. It is the natural “front door” once your model is running, and it is what we set up in the lab. The full setup, with a working two-container compose file and the real gotchas, is in the Open WebUI in Docker guide.

    Keeping it safe on your network

    Because a local model has no account system of its own, the security of the whole thing comes down to how you expose it. The defaults are your friend: bind the model server and the web UI to 127.0.0.1 (loopback) so only your own machine can reach them, or to a private Docker network. To use the UI from your laptop or phone, put it behind your VPN rather than opening a port to the internet — Tailscale in Docker is the simplest way to reach a service on your home network from anywhere without punching holes in your router. If you do want the UI reachable across the house, the home server security guide covers firewalls and Docker network isolation, and Caddy in Docker shows how to front it with HTTPS if you need a proper domain. One rule matters more than all the rest: a model server is a compute box, not a public service — never expose its port directly to the open internet.

    A practical order to follow

    When we set this up in the lab, this was the order that avoided the most dead ends:

    1. Decide the budget. Check your free RAM and whether you have a GPU. This sets which model size you are realistic about.
    2. Pick the runtime. Terminal and control: llama.cpp. Low-maintenance and scriptable: Ollama. Desktop and graphical: LM Studio.
    3. Run one small model first. A 0.5B to 4B model at 4-bit. Get one real answer back before you spend time on anything bigger. This is the fastest way to prove the whole pipeline works.
    4. Measure it. Note tokens per second. Now you have a baseline to compare bigger models against, instead of guessing.
    5. Grow. Move to a bigger or better-quantized model, add a longer context, and only then add a GPU if the CPU speed stops being acceptable.
    6. Add the front door. Once the model is happy, put Open WebUI in front of it so the whole house can actually use it.

    Each of those steps is its own guide. Start with the runtime that matches how you like to work, run a small model, measure it, and build up from there. That sequence — small and real first, bigger later — is what keeps the whole thing from becoming a pile of downloaded model files you never actually use.

    How this fits the rest of your home server

    Local AI is just another resident on the box, and it plays by the same rules as the rest of your stack. It wants a fair share of RAM and, if you give it a GPU, the whole GPU while it is loaded; keep an eye on the box with the Prometheus and node_exporter setup so a chatty model does not quietly eat memory your media server needs. Back up the models folder and the runtime’s state with the same approach as the 3-2-1 backup strategy — a model is a few gigabytes of file, and re-downloading it is the only thing slower than losing it. And because the model lives on the server and the people using it live on their phones and laptops, the self-hosting starter guide is the right place to recap how all of this sits together on a machine you already run. That is the whole shape of it: the hardware you chose, the runtime you picked, one small model running, a measured baseline, and a chat window in front — all on hardware you already own.

  • Uptime Kuma in Docker: Monitor Every Self-Hosted Service and Get Notified

    Uptime Kuma in Docker: Monitor Every Self-Hosted Service and Get Notified

    You can run a lot of self-hosted services and still find out they are down only when you try to use them — or worse, when a family member asks why the photo app is “broken”. Uptime Kuma fixes that with the simplest possible contract: you give it a list of things to check (a URL, a port, a ping, an API keyword) and an interval, it checks them on schedule, and when one fails it notifies you through whichever channel you configured — email, Telegram, Discord, push, and dozens more. It is a single container, it keeps its state in one folder, and its web UI doubles as the status board you can share with the people who use your services. This guide runs Uptime Kuma with Docker Compose and walks through adding the first monitors and wiring up a notification that actually reaches your phone.

    Beginner · 10 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7. I added a real HTTP monitor, a TCP port monitor, and a keyword monitor, verified the up and down states (including forcing a failure), and confirmed the notification pipeline, so the steps below reflect what actually happened in the lab.

    What Uptime Kuma checks, and how

    A “monitor” in Uptime Kuma is one thing to watch plus how often to watch it. The useful monitor types for a home server are:

    • HTTP(s) — the workhorse. It requests a URL and considers the monitor up if it gets the expected status code (200 by default, configurable). You can also require a keyword in the response body, which turns it into a “the page loads but is it actually working?” check.
    • TCP port — connects to a host and port. Use it for services that do not have an HTTP front: a database, a mail server, a game server.
    • Ping — ICMP. The coarsest check: “is this machine reachable at the network level?” It is the right tool for the server itself and for upstream routers.
    • Keyword in a page / API JSON — HTTP plus a deeper assertion. Point it at a health endpoint and require the word ok, for example.

    Each monitor has an interval (how often to check), a timeout (how long to wait before calling it down), and a “retries” concept built into the alerting: a single failed check does not immediately page you, the monitor has to fail a number of times in a row before it flips to down and triggers a notification. That retry window is what stops a one-second network blip from filling your phone with alerts, and it is the single setting worth understanding before you tune anything else — set it too low and you get alert fatigue, too high and you hear about real outages minutes late.

    The other thing that makes Kuma practical for a home setup is that the state is visible, not just alerted. The main page is a live board of every monitor with its current status and a response-time graph. You can share that page (it has a simple auth) as a personal status page for your household, which is a nice answer to “is the server okay?” without anyone needing to know what a server is.

    The compose file

    services:
      uptime-kuma:
        image: louislam/uptime-kuma:latest
        container_name: uptime-kuma
        restart: unless-stopped
        ports:
          - "127.0.0.1:3001:3001"
        volumes:
          - ./appdata:/app/data
        environment:
          - TZ=Europe/London

    One container, one volume. The appdata folder is the entire state: your monitors, your notification settings, your auth, the response-time history. Back up that one folder and you can rebuild the container and lose nothing. The port is 3001 (Kuma’s default); bind it to loopback in the lab and expose it to your LAN or behind your VPN on a real deployment. There is no database container to manage — Kuma keeps its data in SQLite inside appdata, which is part of why a single container is enough.

    First boot and the admin account

    Run docker compose up -d, then open http://<server-ip>:3001. The first thing you are asked to do is create the admin account — the username and password for the whole UI. Do this before anything else and write the credentials down; there is no separate “first user” flow later, and if you lose them the recovery path is to reset the auth in the config inside appdata, which is doable but annoying. After login you land on the (empty) monitor board with a big “Add New Monitor” button.

    Confirm the container is healthy by checking docker logs uptime-kuma for the line showing the server listening on 3001, and that the data directory initialized. If the page loads but you cannot save any monitor, the appdata volume is not writable by the container’s user — the same class of problem as a read-only config, and the log will say so.

    Adding your first three monitors

    These are the three I added in the lab, and together they cover most of what a small home server needs:

    1. An HTTP monitor for a web service. Add New Monitor → HTTP(s). Enter the URL (in the lab, a small self-hosted web service on the home network). Leave the expected status at 200. Set the interval to 60 seconds. Save. It flips to up within the first check. This is the template for every web UI you run — one monitor per service you care about.
    2. A TCP port monitor for a non-HTTP service. Add New Monitor → Port. Enter the host and port (in the lab, the Miniflux port already running on the box). It goes up as soon as the first successful connect lands. Use this for anything that has a socket but no meaningful HTTP page.
    3. A keyword monitor for a health endpoint. Add New Monitor → HTTP(s), enable “Keyword in response”, and require a word that only appears when the service is genuinely healthy. In the lab I pointed one at a health endpoint and required the expected token; the monitor stayed up while the token was present. This is the check that catches the sneaky failure where a page returns 200 but the app behind it is actually erroring.

    Then verify the failure path, because a monitor that never alerts you about anything is worse than no monitor — it creates false confidence. In the lab I stopped the service behind one HTTP monitor and watched Kuma: after the configured retries, the monitor turned red, the response graph showed the failure, and the down notification was generated. When I started the service again, it flipped back to up and sent the recovery notification. That down-and-up pair is the behavior you want, and testing it once tells you the whole pipeline (check → retry → alert → recover) works before you rely on it.

    Notifications: the part that makes it useful

    A monitor that only turns red in a web UI you happen to be looking at is half a solution. Go to the notification settings and add at least one channel that reaches you where you actually are. The setup for each is a few fields:

    • Telegram — create a bot with BotFather to get a token, then give Kuma the token and your chat ID. This is the most reliable “it reached my phone” option and the one I set up in the lab; the test notification arrived as a normal bot message.
    • Email (SMTP) — point it at an SMTP server and a destination address. Fine, but check your provider’s rate limits if you have many monitors; a mass outage can generate a burst of sends.
    • Discord / Slack / Signal / push (ntfy, Pushover, etc.) — all supported, all the same shape: a token or webhook plus a destination.

    Every notification channel has a “Send Test” button — use it. A channel that passes a test is a channel you can trust during a real outage. The alerting flow is: a monitor fails enough times in a row → Kuma sends a “down” notification naming the monitor → when it recovers, a “up” notification. You can also set “maintenance windows” to silence alerts during a planned restart, so a deliberate reboot does not page you.

    Common gotchas

    Monitor is down but the service is clearly up. Usually a network-path problem, not a service problem: Kuma checks from inside its container, so if the target is only reachable at a link-local or container-internal address, Kuma cannot reach it even though your browser (on the host) can. Use an address that is routable from the container — the host’s LAN IP or a Docker-network service name if they share a network. The second usual cause is the expected status code: a service that returns 301/302 (a redirect) will look “down” if you only accept 200; either follow the redirect to the final 200 or add the redirect code to the accepted list.

    Constant flapping (up, down, up, down). The interval or retry settings are too aggressive for a service that is genuinely slow or intermittent, or the target is on the edge of its timeout. Raise the timeout, and increase the number of required failures before alerting. Flapping is the main source of alert fatigue, and it is almost always a tuning problem, not a broken monitor.

    You lose your monitors after a container rebuild. The appdata volume was not persisted (a fresh container got a fresh data directory). Confirm the volume is a named volume or a bind mount that survives, and that it actually contains the SQLite database and config after a restart. Back it up with the approach in the 3-2-1 backup guide — it is small, and it holds your entire monitoring configuration.

    Notifications fire but you are not sure which monitor. The default message names the monitor, but if you have many, give them clear, distinct names (the service and what it checks) rather than “Monitor 1”. Clear names are what make a 2 a.m. alert actionable instead of a puzzle.

    How this fits the rest of your home server

    Uptime Kuma is the “tell me when it breaks” layer, and it is the fastest win in this whole week of guides: one container, a few monitors, one notification channel, and you stop finding out about outages by accident. It pairs with the rest of the stack in a natural division of labor — Kuma for “is it up and answering”, a metrics stack like Prometheus for “how is the machine performing”, a DNS resolver like AdGuard Home as one of the first things to monitor, and a VPN like Tailscale as the safe way to reach Kuma’s status page from outside the house. If you are building the server from scratch, the starter guide shows where monitoring fits in the overall order of setup, and the hardware guide has the baseline resource numbers so you can see that Kuma itself is a drop in the bucket next to the services it watches.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareUptime Kuma 1.23.17

    Last tested: 3 September 2026

  • Syncthing in Docker: Peer-to-Peer File Sync Between Your Devices

    Syncthing in Docker: Peer-to-Peer File Sync Between Your Devices

    You have a laptop, a phone, a desktop and a home server, and the same documents live on more than one of them. Something has to keep them equal. Syncthing does that without a central server: the devices form a direct peer-to-peer mesh, each one runs the same open-source app, and folders you mark for syncing replicate between the peers you choose — over your LAN when the devices are home, or over an encrypted direct connection (or a relay, as a fallback) when they are not. This guide runs Syncthing in Docker on the home-server side, explains the one configuration file it needs, and walks through pairing a second device so you can watch a real folder sync in both directions.

    Beginner · 11 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7, including a two-node sync test — two containers acting as two devices, with a file pushed from one and confirmed on the other — so the pairing and folder-sharing steps below are what actually happened, not a paraphrase of the docs.

    Why Syncthing is different from a sync “service”

    Cloud sync products keep a copy of your files on their servers and move them through those servers. Syncthing has no center: each device runs the same program, devices discover each other (on the LAN via local broadcast, across the internet via a global discovery server that only carries addresses, never file content), and the file data itself goes peer to peer, encrypted. The practical consequences: your files are not stored by a third party, syncing works over a normal home network with no accounts, and if two of your devices are online they will sync even if the rest of your infrastructure is down. The trade-off is that you manage the pairing yourself — there is no “sign in and it appears” magic, you explicitly add each device and each folder.

    The compose file

    services:
      syncthing:
        image: syncthing/syncthing:latest
        container_name: syncthing
        restart: unless-stopped
        ports:
          - "127.0.0.1:8384:8384"
          - "127.0.0.1:22000:22000"
          - "127.0.0.1:22000:22000/udp"
        environment:
          - TZ=Europe/London
        volumes:
          - ./config:/var/syncthing/config
          - ./sync:/sync

    Three pieces to understand. Ports 8384 and 22000: 8384 is the web UI; 22000 is the actual sync traffic (TCP and UDP) between devices. For a home server you usually want the UI on loopback or behind your VPN, and 22000 reachable by your other devices — on the LAN that just means the port is open to your subnet. The config directory: the official syncthing/syncthing image keeps all its state — your device key, the other devices, the folder list, its own GUI certificate — in /var/syncthing/config, and I mount that as ./config. Leave it empty on first boot: Syncthing generates its device key and writes a default config.xml there, and from then on that one directory is the node’s entire identity. Back up config/ and you can rebuild the container and keep the same device, the same peers, and the same folders. The sync folder: ./sync is where the shared files live on the host. Point it at wherever you actually want the shared files (a NAS share, a dedicated directory).

    A detail that trips people up: the device ID is not something you type in. On first boot the container logs a line like Calculated our device ID (device=XXXXXXX-...) and that long hex string is what other devices use to recognize you. Note it down when you pair. If you ever see your device ID change on every restart, the container is not writing config/ back (a permissions problem on the mounted directory), and every peer sees a brand-new device each time — which is the classic “pairing keeps breaking” symptom.

    First boot and the web UI

    Run docker compose up -d and open http://<server-ip>:8384. On a fresh config directory the UI walks you through setting the GUI username and password and accepting the generated device ID — that long hex string is this node’s identity, and it is what other devices use to recognize you when you pair. Confirm the container is healthy and the sync engine is actually running (not just the UI): docker logs syncthing should show the key being generated, the device ID being calculated, the TCP and QUIC listeners starting on 22000, and the GUI listening on 8384. In the lab I also saw it join a public relay on startup, which is the fallback path it will use for away devices — it is normal, not an error. A UI that loads but a sync engine that cannot persist config is the classic half-broken state, and it shows up in the log, not the browser.

    Pairing a second device: the real test

    For the lab test I ran two containers — call them A (the one above) and B — each with its own config.xml and its own sync directory, so they are genuinely two devices. The pairing flow, which is identical whether the second device is another container, a laptop, or a phone:

    1. On A, open Actions → Add Remote Device. Paste B’s device ID (from B’s UI). Give it a name. Accept.
    2. B gets a notification: “Device A wants to connect.” Accept it. (On a manual setup you add A’s ID to B the same way.) Once both sides accept, the devices are paired and appear as connected in each other’s UI.
    3. Now share a folder. On A, open the folder you want to sync (the lab’s “Lab Test” folder) and add B to the list of devices that receive it. On B, Syncthing offers to create the matching folder — accept it, choosing where on B the files should land.

    That is the whole model in one sentence: devices are paired globally, folders are shared per-device. A device you have paired can only see the folders you explicitly share with it. In the lab test I wrote a file into A’s folder, and within a couple of seconds it appeared in B’s — and when I edited it on B, the change propagated back to A. Both directions worked, which is the point of a “send/receive” folder type (the default): changes flow both ways, and conflicts are resolved by most-recent-wins with the losing version kept as a .sync-conflict copy rather than deleted.

    The folder type matters and is easy to get wrong. Send & Receive (default) syncs both directions — use it for a shared folder. Send Only pushes from this device and never applies changes from others — use it for a “distribution” folder, e.g. the server pushing a config folder to clients. Receive Only is the mirror: this device only ever takes. If you set a folder to Send Only on the server and expect edits from the laptop to land on the server, they will not — that is the configuration, not a bug.

    LAN versus internet: what actually happens to the traffic

    When both devices are on the same LAN, Syncthing uses the local broadcast discovery and connects directly over the private IPs — fast, and nothing leaves the house. When a device is away (your laptop at work), it uses the global discovery server to find the other device’s public address and attempts a direct encrypted connection; if the NATs on both sides block the direct path, it falls back to one of Syncthing’s public relays, and the data is still end-to-end encrypted (the relays carry ciphertext they cannot read). You can watch which path is in use in the UI’s connection status — “direct” versus “via relay” — and in my lab the two containers connected directly as expected. For a home server this means the server side does not need any inbound port forwarding for LAN sync; the 22000 port only matters for direct connections to devices that are away.

    Common gotchas

    Device ID resets on every container restart. The container cannot write config.xml back (ownership/permissions on the mounted file, or it is mounted read-only). Fix the ownership so the running user matches, and confirm the device ID is stable across a docker restart syncthing. An unstable ID means every other device sees a “new” device each time and pairing keeps breaking.

    Devices are paired but no files move. The folder is not shared with that device — pairing is not the same as sharing. Check the folder’s device list on the side that owns the files. The second usual cause is a folder-path mismatch: the folder exists on both devices but under different labels, so Syncthing treats them as unrelated. Folder IDs (not labels) are what match; if you recreated a folder, its ID changed and it no longer matches the peer’s copy.

    Constant “sync-conflict” files appearing. Two devices are editing the same file at the same time on a Send & Receive folder. Syncthing keeps both versions (the conflict copy is the one that would have been overwritten). If that is happening a lot, one of the folders should probably be Send Only or Receive Only so there is a single source of truth.

    Sync is slow when a device is away. You are on the relay path. Check the connection status; if direct connections keep failing it is usually a NAT/firewall blocking the outbound 22000 from one side. Allowing outbound 22000 (TCP/UDP) on the away device’s network usually restores the faster direct path.

    How this fits the rest of your home server

    Syncthing is the file-movement layer that keeps your devices and the server consistent without a cloud. It complements a self-hosted file server such as Nextcloud rather than replacing it: the file server is the shared, web-accessible store with a UI and share links, while Syncthing is the always-on, peer-to-peer replication between your machines. Many people run both, with Syncthing keeping the server’s copy current and the file server providing browser and share-link access. For deciding which of the server’s disks should hold the synced folders, the hardware guide has the measured disk throughput of the box used in these tests. Keep the UI on the LAN or behind a VPN such as Tailscale, and back up the config/ directory plus the sync folder with the 3-2-1 backup strategy — the config directory is the whole node identity, so it belongs in your backups.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareSyncthing v2.1.3 (container)

    Last tested: 3 September 2026

  • 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

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

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

  • 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