Tag: Linux

Self-hosting guides for Linux servers, from a blank install to a working home server.

  • Prometheus and node_exporter in Docker: Metrics Monitoring for a Home Server

    Prometheus and node_exporter in Docker: Metrics Monitoring for a Home Server

    You can tell a home server is running because a service answers. You cannot tell it is running well — CPU climbing, a disk filling, a service that has been restarting every night for a month — unless something is measuring it. That is what Prometheus is for: it pulls metrics from your systems on a schedule, stores them as time-series data, and gives you a query language plus a dashboard to look back over hours, days or months. Paired with the node_exporter agent (which exposes CPU, memory, disk and network stats for the machine it runs on), you get the foundation of a proper monitoring stack in two containers. This guide runs both with Docker Compose and sets up the first meaningful alerts.

    Intermediate · 11 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7. I verified the exact metric names, the scrape handshake, and the resource footprint of both containers, and the compose files below are what I ran.

    How Prometheus works (the pull model)

    Unlike a monitoring agent that phones home, Prometheus pulls: every 15 seconds (the default) it visits a list of targets and asks each one for its current metrics at a /metrics endpoint. Each target is a “job”. The data is stored in a local time-series database, and queries use PromQL, a small language for slicing that data (“CPU usage over the last hour”, “disk free bytes on /”). Two consequences of the pull model are worth understanding. First, Prometheus must be able to reach each target — if a target is behind NAT or on a different network, the scrape fails and you get a gap. Second, the “is it up?” signal comes free: if Prometheus cannot scrape a target, the target is down, and that is your first and most important alert.

    node_exporter is the standard exporter for Linux machines. It is a tiny Go binary that exposes about 2,000 metrics from the kernel: per-core CPU, memory, disk I/O, filesystem usage, network counters, load average, and more. One container of node_exporter on a host is enough to see whether that machine is healthy.

    The compose file

    services:
      prometheus:
        image: prom/prometheus:latest
        container_name: prometheus
        restart: unless-stopped
        ports:
          - "127.0.0.1:9090:9090"
        command:
          - "--config.file=/etc/prometheus/prometheus.yml"
        volumes:
          - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
          - prometheus-data:/prometheus
    
      node-exporter:
        image: prom/node-exporter:latest
        container_name: node-exporter
        restart: unless-stopped
        pid: host
        network_mode: host
        command:
          - "--path.rootfs=/host"
        volumes:
          - /:/host:ro
          - /proc:/host/proc:ro
          - /sys:/host/sys:ro
    
    volumes:
      prometheus-data:

    Three things in that file are not obvious and each one matters.

    node_exporter uses network_mode: host and pid: host. A container normally has its own network namespace, in which case node_exporter would report the container’s (empty) network and nothing about the real host. Running it on the host network makes it see the host’s interfaces, and pid: host plus the --path.rootfs=/host flag with the read-only /, /proc and /sys mounts make it read the host’s filesystem and kernel stats rather than the container’s. This is the standard way to run node_exporter in Docker and it is the most common reason a first setup reports near-zero, meaningless numbers — the exporter was looking at its own tiny namespace instead of the machine.

    Prometheus keeps its database in a named volume. The prometheus-data volume is your metric history. Delete it and you lose all past data (queries further back than the retention window stop working). Back it up if the history matters to you, or accept that monitoring data is ephemeral.

    The config is mounted read-only. prometheus.yml is the only file you edit day to day — adding a target, changing the scrape interval. Mounting it :ro keeps the container from writing to it, and Prometheus reloads it when the file changes (add --web.enable-lifecycle to the command, or simply restart the container after an edit; on a home box a restart is simpler and safe).

    The configuration: two jobs

    The prometheus.yml I tested:

    global:
      scrape_interval: 15s
      evaluation_interval: 15s
    
    scrape_configs:
      - job_name: "prometheus"
        static_configs:
          - targets: ["localhost:9090"]
      - job_name: "node"
        static_configs:
          # node_exporter runs on the host network: use the Docker bridge
          # gateway (172.17.0.1 by default) or the host's LAN IP
          - targets: ["172.17.0.1:9100"]

    The first job is Prometheus scraping itself — it is on by default in most configs and is useful because “Prometheus up” is the baseline you compare everything else against. The second job points at node_exporter. Because node_exporter runs on the host network, it is not at 127.0.0.1 as seen from a Prometheus container on the Docker bridge — inside that container, 127.0.0.1 is the container itself, not your machine. The reachable address is the Docker bridge gateway (find it with docker network inspect bridge, commonly 172.17.0.1) or the host’s LAN IP. In my lab I used the bridge gateway, and that is the value in the compose above. If the node job shows as down, this is the almost-certain cause, and it is purely an addressing detail, not a bug.

    Verifying it works, and the first useful queries

    Open http://<server-ip>:9090. Click Targets: both jobs should be green with a last-scrape timestamp. That one screen tells you the whole pipeline is alive. Then try a few queries in the console (the box at the top of the UI).

    • up — returns 1 for every target Prometheus can scrape, 0 if it cannot. This is your up/down signal.
    • 1 - node_load1 — not load itself but a common way to see headroom; for raw load use node_load1.
    • node_memory_MemAvailable_bytes — how much RAM the host has free, in bytes. Divide by 1024^3 for GiB.
    • node_filesystem_avail_bytes{mountpoint="/"} — free space on the root disk. This is the one to alert on (see below).
    • rate(node_network_receive_bytes_total[5m]) — network throughput in bytes/sec over the last five minutes. The rate() function is the workhorse of PromQL for any per-second counter.

    If a query returns no data, the metric name is usually slightly off — Prometheus will autocomplete as you type, and the autocomplete is the fastest way to learn the exact names (they differ between exporter versions).

    The first two alerts worth having

    Full alerting needs a third piece (Alertmanager), but two conditions are important enough that you can start by just watching them in the UI, and wire up Alertmanager later. First, up == 0 for the node job means the exporter — and effectively the machine’s monitoring — is gone; that is a “is my server even on?” alert. Second, a low free-disk expression such as node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} < 0.10 means the root filesystem is under 10% free — the failure that actually takes home servers down, because a full disk stops Docker, stops logs, and cascades. Both are single-line PromQL you can add to a dashboard now and promote to a real alert later.

    Common gotchas

    The node job is permanently down. Addressing, as above: node_exporter on the host network is not at 127.0.0.1 from a bridge-network container. Use the host’s LAN IP. Confirm by hitting http://<host-ip>:9100/metrics from the host itself first — if that works, it is purely the target address in prometheus.yml.

    Metrics are all near zero or missing. node_exporter is reading its own container namespace, which means the pid: host / --path.rootfs / host-network setup above was not applied. A host’s CPU should show real load; if every node_cpu_* value is ~0 while you know the machine is busy, the exporter is not looking at the host.

    Disk usage grows and queries slow down. Prometheus stores every scraped series. With a few hundred metrics and a 15-second interval it is modest, but it grows linearly with time and with the number of targets. Set a --storage.tsdb.retention.time (e.g. 30d) so the database stops growing without bound, and size the volume accordingly.

    You add Grafana next and it cannot find Prometheus. Grafana and Prometheus must be able to reach each other’s ports. If both are in the same compose file / network, use the service name (prometheus:9090) as the Prometheus server URL in the Grafana datasource, not localhost — inside Grafana’s container, localhost is Grafana itself.

    How this fits the rest of your home server

    Prometheus plus node_exporter is the measurement layer under everything else you run. It answers a different question from the simple “is this URL up?” monitors — Prometheus tells you how the machine was behaving in the hours before something stopped, not just that it stopped. Add an exporter per service you care about (each popular app has one) and the same prometheus.yml pattern from this guide is how you register them. Keep the whole stack reachable only on the LAN or over a VPN such as Tailscale, and back up the prometheus-data volume with the approach in the 3-2-1 backup guide if your metric history is worth keeping. For context on whether your hardware will handle the extra load, the hardware guide has the measured CPU and disk numbers from the same box used in these tests.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwarePrometheus 3.14.0 + node_exporter 1.12.1

    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:

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

  • 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

  • The Self-Hosting Starter Guide: From Zero to a Working Home Server

    The Self-Hosting Starter Guide: From Zero to a Working Home Server

    Beginner · 10 min · Linux · Docker

    Tested on:

    OS Ubuntu 24.04 LTS (Debian 12 works too)
    Docker 29
    Hardware 4-core x86, 16 GB RAM
    Software Starter stack (Miniflux, PostgreSQL 16, Navidrome, DokuWiki)

    Last tested: 22 August 2026

    You do not need a rack of servers to start self-hosting. You need one machine, Docker, and a few well-chosen applications. This guide takes you from a blank Linux machine to a working home server with a working RSS reader, a personal music server, and a private wiki — every file tested on real hardware.

    Why self-host at all?

    Every month you pay for another subscription, you are renting someone else’s computer. Self-hosting flips the model: you buy the hardware once (or reuse what you already have) and run the software yourself. The benefits, in order of how they matter to real users:

    • Your data stays yours. Photos, music, notes, and feeds live on your disk, on your terms, with your backup strategy.
    • No subscription fatigue. A home server running four services costs a few pounds per month in electricity, not four monthly fees that keep going up.
    • Privacy by architecture. Your reading habits, your playlists, and your notes never pass through a company that may change its policies tomorrow.
    • You actually learn infrastructure. Networking, containers, reverse proxies, backups — the skills transfer directly to paid work.

    The honest trade-offs: you are the IT department now. Updates, outages, and security patches are your responsibility. A home server also needs a real IP address or a workaround (Tailscale, Cloudflare Tunnel) to be reached from outside your house — we cover that in the security series.

    Step 1: Choose your hardware

    You have three sensible starting points, depending on budget:

    Option A: Repurpose an old PC (free)

    Any x86 machine from roughly the last decade works as a starter server. The practical minimum: 8 GB of RAM, a solid-state drive (even a cheap SATA SSD makes a huge difference), and a power supply you trust. Old desktops are the classic choice, and a single 60 W machine idling costs roughly £15–25 per year at UK rates.

    Option B: Raspberry Pi 5 (around £80–100)

    The 16 GB model is the sweet spot for a starter stack: it runs the apps in this guide comfortably, idles around 5 W, and fits on a shelf. Use a quality case with active cooling and a 2.5″ SATA SSD via the HAT if you plan to store media — the Pi’s microSD slot will die on you under sustained writes.

    Option C: Used mini PC (around £150–250)

    Used Intel NUCs, Dell OptiPlexes, and HP Elites from office clearances offer x86 performance at a fraction of the price of new hardware. This is the best bang-per-pound if you want headroom for media transcoding later.

    Step 2: Install an operating system

    For a dedicated server, use a minimal Linux install rather than a desktop:

    • Debian 12 (bookworm): the safest default. Minimal install, no desktop, extremely stable, huge community.
    • Ubuntu Server 24.04 LTS: the friendlier choice if you want the most tutorials to match. Also an excellent default.
    • Truenas Scale or Proxmox: skip these for your first server. They add virtualisation and ZFS, which are powerful but premature until you know what you are running.

    During installation: give the machine a fixed IP on your LAN (or reserve one in your router’s DHCP table), and set a hostname like server. You will not want to remember a changing IP.

    Step 3: Install Docker

    Docker packages software into containers: isolated environments that start in seconds, take up only what they use, and are identical on any Linux machine. This is what makes self-hosting actually manageable instead of a tangle of system packages.

    On Debian or Ubuntu, the official install is a few lines:

    sudo apt-get update
    sudo apt-get install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ https://download.docker.com/linux/$(. /etc/os-release && echo $ID) \ $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt-get update
    sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin

    Then add your user to the docker group so you stop typing sudo:

    sudo usermod -aG docker $USER
    # log out and back in, then verify:
    docker compose version

    If that command prints a version number, you are ready.

    Step 4: Pick your first stack

    This is the part most guides get wrong. They hand you a list of forty apps and you spend two weeks configuring Sonarr and Prowlarr before you have used anything. Start with three services that you will actually touch every day:

    App What it does Why first
    Miniflux RSS reader — aggregates every feed you follow Instant daily value; your reading no longer depends on an algorithm
    Navidrome Personal music server — streams your library to any device One folder, one app, works with the official Substreamer app
    DokuWiki Plain-text wiki for notes and documentation Zero-friction writing; your notes are files on disk, not a proprietary format

    Each of these has a dedicated guide on this site with the exact compose file, tested setup steps, and the real errors we hit along the way:

    Step 5: Run the stack

    Everything below was verified on a 4-core / 16 GB Ubuntu machine with Docker 29. Create a project directory:

    mkdir -p ~/stacks/starter/music && cd ~/stacks/starter

    Drop in a docker-compose.yml with the three services (full annotated files in each linked guide):

    services: miniflux: image: miniflux/miniflux:latest container_name: miniflux environment: DATABASE_URL: postgres://miniflux:secret@miniflux-db/miniflux?sslmode=disable BASE_URL: http://localhost:8082 ports: - "8082:8080" depends_on: - miniflux-db restart: unless-stopped miniflux-db: image: postgres:16-alpine container_name: miniflux-db environment: POSTGRES_USER: miniflux POSTGRES_PASSWORD: secret POSTGRES_DB: miniflux volumes: - miniflux_db:/var/lib/postgresql/data restart: unless-stopped navidrome: image: deluan/navidrome:latest container_name: navidrome ports: - "4533:4533" environment: ND_SCANSCHEDULE: 1h ND_LOGLEVEL: info volumes: - ./music:/music:ro - navidrome_data:/data restart: unless-stopped dokuwiki: image: dokuwiki/dokuwiki:stable container_name: dokuwiki ports: - "8081:80" volumes: - dokuwiki_data:/dokuwiki/data - dokuwiki_conf:/dokuwiki/conf restart: unless-stopped volumes: miniflux_db: navidrome_data: dokuwiki_data: dokuwiki_conf:

    Then start it:

    docker compose up -d
    docker compose ps

    All four containers should show Up. The ports: Miniflux on :8082, DokuWiki on :8081, Navidrome on :4533. Open them from your laptop on the same network: http://SERVER_IP:8082, and so on.

    Step 6: The three first-run tasks

    1. Miniflux: first visit asks you to create the admin account. Then add feeds — start with the 10 you actually read. If the container keeps restarting with pq: SSL is not enabled on the server, your DATABASE_URL is missing ?sslmode=disable (full fix in the Miniflux guide).
    2. Navidrome: put MP3/FLAC files in the music/ folder, then create your first account at :4533. It scans the library on first login. The official mobile app (Substreamer) pairs in one minute.
    3. DokuWiki: first visit runs a tiny config wizard (admin login, language). That is the whole setup.

    Measured resource usage

    Because we run this stack, here is what it actually costs, measured with docker stats after a day of normal use (RSS polling, a music session, some wiki edits):

    Container Idle RAM Idle CPU
    miniflux 17 MiB ~0%
    miniflux-db (Postgres) 38 MiB ~0%
    navidrome 26 MiB ~0%
    dokuwiki 25 MiB ~0%

    Total: about 106 MiB of RAM for a full starter stack. A Raspberry Pi 5 with 4 GB has 40× the headroom this needs. The real cost of this stack is the electricity of the machine it lives on — which you were already paying.

    What comes next

    Once these three are boring (the goal), the natural expansion path is:

    • Nextcloud or Immich for photos and files (see the NAS & Media section)
    • AdGuard Home for network-wide ad and tracker blocking (Security & Networking)
    • A reverse proxy (Caddy or Nginx Proxy Manager) plus Tailscale, so the stack is reachable from anywhere without opening ports on your router — this is the single highest-value upgrade after the starter stack, and we cover it in the security series
    • Monitoring: Uptime Kuma, so your server tells you it is down instead of you finding out

    Frequently asked questions

    Is self-hosting safe if I am not a security expert?

    Yes, with discipline: keep Docker updated, use a reverse proxy with TLS instead of exposing ports, and do not expose admin interfaces directly to the internet. The starter stack above is LAN-only, which is the safe default. Our Security & Networking guides cover hardening step by step.

    Docker or Kubernetes?

    Docker Compose. Kubernetes on a home server is solving a problem you do not have. You will use 90% of what Compose gives you for 10% of the complexity.

    Can I run this on a VPS instead of at home?

    The same compose file runs unchanged on a VPS — that is part of Docker’s point. A VPS is a fine starting point if your home connection is bad or your ISP blocks inbound connections.

    What about the initial hardware cost?

    If you already own a spare PC, the marginal cost is electricity (a few pounds a month). A Raspberry Pi 5 path is around £100–150 all-in. After that, most services are free software. That is the whole pitch: one small fixed cost instead of an open-ended subscription stack.