Category: Docker & Linux

Run self-hosted applications with Docker and Docker Compose on any Linux server, with tested compose files and real errors documented.

  • 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

  • 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

  • Caddy in Docker: Reverse Proxy with Automatic HTTPS for Self-Hosted Services

    Caddy in Docker: Reverse Proxy with Automatic HTTPS for Self-Hosted Services

    Every self-hosted service starts life behind a raw IP and port: http://192.168.1.42:8084. It works, but it is not the web you are used to — no HTTPS, no domain name, no sensible routing. A reverse proxy sits in front of your services and fixes all three: it owns your domain (or a local one), terminates TLS, and routes each hostname to the right container. Caddy is the reverse proxy I recommend for home servers because the configuration is a single readable file and, if you point a real domain at it, HTTPS certificates are obtained and renewed automatically with zero configuration. This guide runs Caddy in Docker Compose and walks through a Caddyfile that actually serves multiple self-hosted services.

    Intermediate · 10 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7. In the lab I test plain HTTP routing (no domain, no certificate), and I flag exactly where your setup will differ once you have a domain and a public IP.

    What Caddy does for you

    A reverse proxy receives requests on ports 80 (HTTP) and 443 (HTTPS) and forwards them to the services on your network based on the host name in the request. Three things make Caddy a good fit for a home server. First, the Caddyfile is plain, human-readable configuration — you will understand every line in the example below. Second, automatic HTTPS: if you configure a real domain, Caddy requests a Let’s Encrypt certificate for it on first use, stores it, and renews it before expiry. There is no cron job to write and no certificate to babysit. Third, it is a single small static binary in the caddy image — no database, no plugin system, fast to start and easy to restart when you edit the file.

    The flip side to understand: automatic HTTPS needs two things that a LAN-only test does not have — a domain that points at the machine, and inbound 80/443 reaching it. For purely internal use (you browse services by name on your own network), you can run Caddy in plain-HTTP mode, which is what the lab test below does.

    The compose file

    services:
      caddy:
        image: caddy:latest
        container_name: caddy
        restart: unless-stopped
        ports:
          - "127.0.0.1:8083:80"
          - "127.0.0.1:8443:443"
        volumes:
          - ./Caddyfile:/etc/caddy/Caddyfile:ro
          - caddy_data:/data
          - caddy_config:/config
        environment:
          - TZ=Europe/London
    
    volumes:
      caddy_data:
      caddy_config:

    Two volumes are the only persistent state. caddy_data stores the TLS certificates it obtains (empty in the lab test); caddy_config stores Caddy’s internal JSON representation of your Caddyfile. The Caddyfile itself is mounted read-only, so editing it on disk and reloading is the normal workflow. The port mapping here binds to loopback on remapped ports for the lab; on a real server with a domain you would map "80:80" and "443:443" so Caddy can receive HTTP (for certificate challenges) and HTTPS from the internet.

    A Caddyfile that routes several services

    Here is the file I tested. In the lab it responds to any host name on port 80 with a plain text marker, which proves the container is listening and routing. On a real deployment you replace the placeholder sites with your actual host names.

    # Lab: prove Caddy serves on :80 with a fixed response.
    :80 {
        respond "caddy-lab-ok" 200
    }

    The production-shaped version for a home server with a domain looks like this. Each block is one host name; Caddy matches the incoming Host header and proxies to the matching upstream. Upstream addresses use Docker service names when the services are on the same Docker network as Caddy (add Caddy to that network), or the server’s LAN IP otherwise.

    cloud.example.com {
        reverse_proxy nextcloud:80
    }
    
    vault.example.com {
        reverse_proxy vaultwarden:80
        encode zstd gzip
    }
    
    status.example.com {
        reverse_proxy uptime-kuma:3001
    }

    Notice how little is there. No certificate directives: because these are real domains, Caddy obtains and renews the certificates automatically. The encode line is optional compression. If you do not yet have a public domain, the common home approach is to use .local or .lan names with a plain-HTTP site block (the :80 style, or cloud.local without a certificate), and add the domain + HTTPS later when you want the service reachable outside the house.

    One routing detail that saves pain: put Caddy on the same custom Docker network as the services it proxies. Then reverse_proxy nextcloud:80 resolves via Docker’s built-in DNS. If Caddy is on the default bridge and your apps are on a custom network (or vice versa), the service name will not resolve and you will get 502s — the most common first-run error with a reverse proxy.

    Reloading the configuration

    Caddy watches /etc/caddy/Caddyfile inside the container. When you edit the mounted file, Caddy performs a zero-downtime reload automatically — you do not need to restart the container. You can also trigger it explicitly:

    docker exec caddy caddy reload --config /etc/caddy/Caddyfile

    If your edit has a syntax error, the reload fails and Caddy keeps serving the last valid configuration (it does not go down). Check docker logs caddy for the specific line that is wrong. This behaviour is why a reverse proxy is safer to configure live than, say, a system nginx on a production box — a typo does not take the site offline.

    Common gotchas

    502 Bad Gateway immediately after adding a site. Caddy is running but cannot reach the upstream. Almost always a network problem: Caddy and the target service are not on the same Docker network, so the service name does not resolve. Verify from inside Caddy: docker exec caddy getent hosts nextcloud. If that fails, join the networks (or use the LAN IP).

    Certificates never appear even though you configured a real domain. Caddy could not complete the HTTP-01 challenge: inbound port 80 is not reaching the container, the domain’s A record does not point at the machine, or a router in between is terminating/altering HTTP. Confirm curl -v http://yourdomain from the internet reaches Caddy before expecting a certificate. On CGNAT or without a public IP, automatic HTTPS via HTTP-01 will not work; use DNS-01 (with a DNS provider token in the Caddyfile) or serve it internally and reach the service over a VPN such as Tailscale.

    Your service redirects to http:// and loops. Some apps (Nextcloud in particular) detect they are behind a proxy and, unless told the external host, generate URLs with the wrong scheme or name. Set the app’s “trusted domain” and “overwrite protocol” to the public host name — for Nextcloud that means trusted_domains plus overwrite.cli.url in its config. If the loop persists, temporarily set trusted_proxies to your proxy’s address so the app sees the correct X-Forwarded-For.

    Port 80 is already used on the host. If you have another service publishing 80 (a host nginx, a Pi-hole with a web UI on 80), Caddy cannot also bind it. Remap Caddy to a different host port for testing, or move the other service off 80 — but note that Let’s Encrypt’s HTTP-01 challenge specifically needs 80, so a permanent remap breaks automatic HTTPS for the HTTP challenge.

    How this fits the rest of your home server

    Caddy is the front door that lets you run many services under one domain without a tangle of ports. Pair it with the SSH and firewall guide to keep only 80, 443 and SSH reachable, and everything else internal. If you would rather not expose ports at all, the Cloudflare Tunnel guide is the complementary no-port-forwarding path. For internal DNS so your service names resolve cleanly on the LAN, point your devices at a self-hosted resolver such as AdGuard Home or Pi-hole, and for the bigger picture of how to choose what to run, the self-hosting starter guide.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareCaddy 2.11.4

    Last tested: 3 September 2026

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

  • Navidrome in Docker: A Self-Hosted Alternative to Spotify (Compose Guide)

    Navidrome in Docker: A Self-Hosted Alternative to Spotify (Compose Guide)

    Beginner · 7 min · Docker · Music

    Tested on:

    OS Any Linux (verified on Debian 12)
    Docker 29.7
    Hardware 4-core x86, 16 GB RAM (a 2 GB Raspberry Pi 5 runs it fine)
    Software Navidrome (latest, deluan image)

    Last tested: 22 August 2026

    Navidrome is the self-hosted music server that actually replaces Spotify for people who already own their music: it reads a folder of MP3s or FLACs, serves them to any device over a fast web interface, and runs in a single container with about 26 MB of RAM at idle. This guide covers the Docker Compose setup, the official mobile apps, and the settings that matter.

    Why Navidrome over the alternatives

    There are a few options for streaming your own library. Here is how they compare on the axes that actually matter day to day:

    Navidrome Jellyfin (audio) Coherence
    Setup effort 1 container, 1 folder 1 container, more config 1 container + DLNA client
    Idle RAM (measured) ~26 MiB ~300 MiB+ (multi-service) ~40 MiB
    Mobile apps Official (Substreamer) + many Official app Client apps only
    Transcoding On the fly, fast (Go) Yes, heavier Limited
    Video No — audio only Yes Yes (DLNA)

    The honest rule: if you want video and audio in one stack, use Jellyfin (we cover it in the NAS & Media series). If you only need music, Navidrome is lighter, faster, and its mobile app experience is the best of the group.

    Prerequisites

    • Docker + Compose plugin
    • Your music in one folder (MP3, FLAC, OGG, M4A, OPUS, WAV, WMA all work). Keep the folder structure you like — Navidrome reads artist/album metadata from the files, so filenames and folders mostly do not matter for playback, only for how the UI groups things when tags are missing.
    • A free TCP port (this guide uses 4533, the default)

    Step 1: The compose file

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

    Create docker-compose.yml:

    services:
      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
    
    volumes:
      navidrome_data:

    Notes on the three choices in that file:

    • ./music:/music:ro — your library is mounted read-only. Navidrome never needs to write to your music; keeping it read-only is free safety.
    • ND_SCANSCHEDULE: 1h — re-scan the library hourly, so new files appear without action. Set to never if your library rarely changes and you want zero background work.
    • navidrome_data:/data — the internal database (play counts, playlists, users) lives in a volume, so updates and container rebuilds never touch it.

    Step 2: Start it and load the library

    docker compose up -d
    docker compose logs -f navidrome

    You will see it scan the folder on first start — one line per track, and the time depends on library size (a few thousand tracks take a couple of minutes; 50,000 can take ten or more). Stop following the log with Ctrl-C once it finishes.

    Open http://YOUR_SERVER_IP:4533. First visit asks you to create the first user, which becomes the administrator. Log in and your library is there, grouped by artist and album, with artwork pulled from embedded tags or Navidrome’s own art fetching.

    Step 3: Connect the official mobile app

    The official client is called Substreamer (Android and iOS). Setup takes about a minute:

    1. Install Substreamer from your app store.
    2. Add a server: http://YOUR_SERVER_IP:4533 (or the public URL once you have a reverse proxy — see below), then your username and password.
    3. It fetches the library and behaves like a normal music app: browse, search, playlists, gapless playback, background play.

    Non-official clients also work well: the community Navidrome clients on both platforms, and any app that speaks the Subsonic API. Navidrome deliberately implements the Subsonic protocol, which is why so many third-party apps just work.

    Step 4: The settings worth changing

    Most of Navidrome works well untouched, but these five are worth a look in Settings → Server and Settings → Player:

    1. Transcoding. On by default: if a client requests 128 kbps MP3, Navidrome transcodes FLAC on the fly. That is the right default for phones on data. On a fast LAN you can raise the quality or let the client request the original format.
    2. Cover art source. Navidrome can fetch missing artwork from the web. Fine for MP3s with weak tags; for a carefully tagged FLAC library, keep it off to avoid wrong art being cached.
    3. Session timeout. Defaults are generous; tighten if you ever expose the UI publicly.
    4. Playlist sharing. Users can share playlists with each other — useful if you run this for a household.
    5. Play counts and “last played.” On by default, and the data powers the “recently played” views in the app. Nothing to configure, just know it exists.

    Step 5: Reaching it from outside your network

    Two honest options:

    • Tailscale (our recommendation): install Tailscale on the server and on your phone. The server gets a stable address inside your private mesh, no ports opened on the router, traffic encrypted end to end. This is the lowest-risk way to listen to your library on the train.
    • Reverse proxy with TLS: Caddy or Nginx Proxy Manager in front of Navidrome, with a domain name. More setup, but it also serves as the foundation for every other service you expose later. We cover both paths in the Security & Networking series.

    Do not forward port 4533 directly on your router. An exposed music server with a weak password is a classic credential-stuffing target.

    Resource usage (measured)

    From the same verified stack as our starter guide:

    State RAM
    Idle (no streams) 26 MiB
    One stream, FLAC ~40–60 MiB

    A 2 GB Pi 5 can comfortably run Navidrome plus several other services. Transcoding is the only CPU-heavy operation, and it is per-stream, so a single listener on a small machine is not a problem.

    Updating

    docker compose pull && docker compose up -d

    Navidrome runs a quick upgrade/migration on start. Your library is untouched (it is just a folder); your database lives in the volume.

    FAQ

    Will it stream lossless over my home network?

    Yes. Over a LAN the app can play original FLAC files directly. Transcoding only kicks in when a client requests a lower format (typically data connections or older devices).

    Does it support gapless playback?

    The web interface does not do gapless playback; Substreamer does, which is why the app is the recommended client for classical or concept albums.

    Can I add podcasts?

    No — Navidrome is music only. Pair it with an RSS reader like Miniflux and you have a complete, private media stack.

    What if my tags are a mess?

    Fix the tags in the files (Beets, Kid3, or MusicBrainz Picard), then force a rescan from the admin panel. Navidrome does not write tags back to your files, so cleanup tools are safe to run at any time.

    Where does this fit?

    Navidrome is one of the three services in our self-hosting starter guide. For video, see the Jellyfin guide in the NAS & Media section (in the pipeline).

  • How to Self-Host Miniflux with Docker: Compose File and the Postgres SSL Fix

    How to Self-Host Miniflux with Docker: Compose File and the Postgres SSL Fix

    Beginner · 7 min · Docker · PostgreSQL

    Tested on:

    OS Any Linux (verified on Debian 12)
    Docker 29.7
    Hardware 4-core x86, 16 GB RAM
    Software Miniflux (latest) + PostgreSQL 16

    Last tested: 22 August 2026

    Miniflux is the fastest, lightest RSS reader you can run for yourself: a single Go binary plus a database, idling at about 17 MB of RAM. This guide deploys it with Docker Compose on any Linux machine — and documents the exact error that stops most first-timers, because that is the error we hit too.

    What is Miniflux, and why it beats the alternatives

    Miniflux is a minimalist RSS reader written in Go. You point it at your feeds, and it polls, stores, and serves them in a fast interface. The account model is deliberately simple: create users, add feeds, read. No accounts, no cloud, no subscription.

    Compared to the other common self-hosted readers:

    Miniflux FreshRSS Selfoss
    Idle RAM (measured) ~17 MiB ~80–150 MiB (PHP-FPM) ~50 MiB
    Setup complexity 1 container + Postgres 1 container (PHP) + optional DB 1 container + DB
    Mobile clients Any RSS app + built-in mobile view Any RSS app Limited
    Update cadence Frequent, stable Frequent Slower

    If you want a heavy-featured reader with plugins, FreshRSS is a fine choice. For a low-maintenance daily driver, Miniflux is hard to beat.

    Prerequisites

    • A machine with Docker and the Compose plugin (docker compose version should print a version)
    • A free TCP port on your LAN (this guide uses 8082 — change it if yours is taken)

    Step 1: The compose file

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

    Create docker-compose.yml:

    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
    
    volumes:
      miniflux_db:

    Three things in that file matter, and two of them trip people up:

    1. ?sslmode=disable at the end of the DATABASE_URL — the current image attempts an SSL connection by default. Without this parameter the container enters a restart loop (see Step 3).
    2. secret as the password is fine for a LAN-only deployment, but if this server ever reaches the internet behind a proxy, change it to something real.
    3. BASE_URL is used for redirects and feed links. Set it to whatever address you will actually type in the browser.

    Step 2: Start it

    docker compose up -d
    docker compose ps

    Both containers should show Up. Wait ten seconds, then open http://YOUR_SERVER_IP:8082 (or http://localhost:8082 if you are on the same machine).

    Step 3: The restart loop (and the fix)

    If instead you see Restarting in docker compose ps, check the logs:

    docker logs miniflux

    If the output repeats pq: SSL is not enabled on the server, the container is talking to Postgres with SSL on and Postgres has it off. The fix is the ?sslmode=disable parameter on the DATABASE_URL line, which the compose file above already includes. If you are reading this guide because of that error, that one parameter is the fix.

    Edge case: schema version mismatch

    A second, rarer message is the database schema is not up to date: current=v0 expected=vNNN. This happens when a fresh database is created but the first container run aborts before migrations. Running the image once with migrations forced clears it:

    docker run --rm --network miniflux_default \
      -e DATABASE_URL="postgres://miniflux:secret@miniflux-db/miniflux?sslmode=disable" \
      -e RUN_MIGRATIONS=true miniflux/miniflux:latest

    That command runs the migrations and then starts the server (leave it running until the DB is migrated, or stop it after a few seconds) — after which the compose-managed container starts cleanly. (We hit this during testing; it is not part of the normal path, but it is the other error that appears in every Miniflux+Postgres thread.)

    Step 4: First-run setup

    The first visit shows the setup page. Create your admin account — Miniflux generates an initial password it shows you once (or sets one you choose, depending on version).

    Then:

    1. Add your first feed: paste an RSS URL into the “Add feed” field. If a site has no visible RSS link, try appending /feed, /rss, or /atom.xml to its URL, or use a feed-directory site to find it.
    2. Add the 10–15 feeds you actually read. Resist adding 200 — a full feed list you never finish is the same as not reading.
    3. Check “Polling interval” in settings: the default is hourly, which is plenty. Faster polling on a small machine just costs CPU for no reading benefit.

    Step 5: Use it from your phone

    Miniflux speaks the standard /api/v1 RSS reader API. Any of these clients work:

    • Reeder (iOS/macOS) — connect to your server URL, use the API
    • NetNewsWire (macOS/iOS) — same
    • Fluss (Android) — the most polished free option on Android
    • The built-in mobile view — Miniflux serves a decent mobile UI at /m, which is honestly good enough for daily use

    Resource usage (measured)

    Container Idle RAM Notes
    miniflux 17.2 MiB Go binary, one process
    postgres:16-alpine 37.6 MiB Alpine image keeps it small

    Both together: under 55 MiB at idle. A 1 GB Raspberry Pi can run this and still have room for a few more services.

    Updating

    Miniflux migrates its own schema on startup, so updating is:

    docker compose pull
    docker compose up -d

    Your data lives in the miniflux_db volume and survives every update. Back it up with docker run --rm -v miniflux_db:/data miniflux/miniflux:latest psql ... or, more simply, docker exec miniflux-db pg_dumpall > backup.sql on a cron schedule. (We have a dedicated backups guide in the pipeline.)

    FAQ

    Does Miniflux work without Postgres?

    It also supports SQLite and MySQL, but Postgres is the recommended production database and the one used here. SQLite is fine for a single-user test, but the image’s default expectations are Postgres-centric.

    Can I add multiple users?

    Yes — from the admin panel. Each user gets their own feeds and reading state, which makes Miniflux work as a small family reader.

    Should I expose port 8082 to the internet?

    No. Keep it on the LAN and reach it remotely via Tailscale or a reverse proxy with authentication — see the Security & Networking guides. RSS readers are a magnet for credential-stuffing bots the moment they are publicly reachable.

    Where does this fit in a bigger setup?

    In our self-hosting starter guide, Miniflux is one of the three first services, alongside Navidrome and DokuWiki.