Tag: Storage

Choosing storage for a home server: HDD vs SSD vs NVMe, with real numbers from the Chikewa lab.

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

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

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

    Beginner · 11 min · Docker

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

    Why Syncthing is different from a sync “service”

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

    The compose file

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

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

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

    First boot and the web UI

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

    Pairing a second device: the real test

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

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

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

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

    LAN versus internet: what actually happens to the traffic

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

    Common gotchas

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

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

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

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

    How this fits the rest of your home server

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

    Tested on:

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

    Last tested: 3 September 2026

  • Nextcloud in Docker: Self-Hosted Files, Photos and Office with PostgreSQL

    Nextcloud in Docker: Self-Hosted Files, Photos and Office with PostgreSQL

    Nextcloud is the self-hosted Dropbox/Google Drive: file storage you can browse in a web UI, edit in a built-in office suite, sync to devices with a desktop client, and share with links. It is also one of the heavier “simple” services to run, because the image is only the front half — it needs a real database (PostgreSQL or MariaDB) and, for decent performance, some configuration beyond the defaults. This guide runs Nextcloud with Docker Compose alongside a PostgreSQL container, walks through the install wizard, and points out the settings that separate a usable Nextcloud from a slow one.

    Beginner · 11 min · Docker

    Everything here was tested on a Debian 12 mini PC with Docker 29.7. The first boot of Nextcloud is the slowest first-run I have tested for this site, so the “how long is normal” section below is based on a real timed run, not a guess.

    The compose file: app plus database

    services:
      nextcloud:
        image: nextcloud:latest
        container_name: nextcloud
        restart: unless-stopped
        ports:
          - "127.0.0.1:8084:80"
        environment:
          - POSTGRES_HOST=nextcloud-db
          - POSTGRES_DB=nextcloud
          - POSTGRES_USER=nextcloud
          - POSTGRES_PASSWORD=nextcloud_change_me
          - NEXTCLOUD_TRUSTED_DOMAINS=localhost
          - TZ=Europe/London
        volumes:
          - nextcloud-data:/var/www/html
        depends_on:
          nextcloud-db:
            condition: service_healthy
    
      nextcloud-db:
        image: postgres:16-alpine
        container_name: nextcloud-db
        restart: unless-stopped
        environment:
          - POSTGRES_DB=nextcloud
          - POSTGRES_USER=nextcloud
          - POSTGRES_PASSWORD=nextcloud_change_me
        volumes:
          - nextcloud-db-data:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U nextcloud"]
          interval: 10s
          timeout: 5s
          retries: 5
    
    volumes:
      nextcloud-data:
      nextcloud-db-data:

    Two containers, two volumes. The nextcloud-data volume holds your files plus the config directory — it is the thing you back up. The nextcloud-db-data volume holds PostgreSQL. The healthcheck on the database container matters: depends_on with condition: service_healthy means Nextcloud only starts once PostgreSQL actually accepts connections, which prevents the most common broken state (the app starting, failing to reach the DB, and half-initializing). Change the password in both POSTGRES_PASSWORD lines before you start — they must match, and there is no in-between.

    First boot: the slow part, timed

    Run docker compose up -d. In my lab the PostgreSQL container became healthy in about 5 seconds. The Nextcloud container itself takes longer to become responsive because on first request it runs its setup: creating the database schema, generating the secret, and preparing the initial config. On this 4-core box with an NVMe drive the PostgreSQL container was healthy within seconds, and by the time I opened the browser the log was already showing the standard line — Next step: Access your instance to finish the web-based installation! — and the install page rendered on request. On a Raspberry Pi or a slow disk, give it several extra minutes before assuming anything is broken. If the page is still blank after five minutes, check docker logs nextcloud — a database authentication error will show there immediately, and it is the usual cause when the two passwords do not match.

    The install wizard asks for: the admin account (create a strong password), the database connection (pre-filled from the environment variables above — confirm and keep them), and that is it. You land in a working Nextcloud with the default apps enabled.

    The settings that make it actually usable

    Out of the box, Nextcloud works but is conservative. Four settings matter for a home deployment.

    1. The background job mode. By default Nextcloud runs its maintenance jobs (file scanning, preview generation, share cleanup) inline, on the same request that triggered them. On a small box this makes the UI stutter while a large folder is being indexed. The fix is to enable cron: on a Docker setup the standard approach is a small cron container, or an entry in your host cron, that runs occ background:cron every five minutes. If you do not add this, large uploads and scans will visibly slow the web UI.

    2. Preview generation. Thumbnails for images and video are generated on the fly the first time you view a folder. On a weak CPU this is the single most noticeable lag. You can cap preview resolution in the admin settings, or disable video previews entirely if you mostly store photos. The trade-off is storage: previews are cached files, and a large photo library will build a meaningful preview cache over time.

    3. The trash bin and versioning windows. Both are on by default (30 days). That is fine and worth keeping — it is your safety net against accidental deletion and overwrites. Understand that they consume extra storage: a file that has been edited several times keeps the old versions until the window expires.

    4. Trusted domains and protocol. If you serve Nextcloud through a reverse proxy (Caddy or nginx in front), set the public domain in trusted_domains and the external URL as overwrite.cli.url in the config, or you will get redirect loops and wrong share links. The environment variable NEXTCLOUD_TRUSTED_DOMAINS in the compose file above is the initial value; for anything beyond a single domain, edit the config file in the nextcloud-data volume.

    Performance: what to expect

    Nextcloud’s resource use scales with what you do, not just what you store. In my lab, idle with an empty account it held around 150 MiB of RAM (app container) plus the database at roughly 40 MiB. After uploading a few thousand files and generating previews, the app container grew into the 300–400 MiB range while busy and settled back down when idle. The practical rule: Nextcloud is comfortable on a 4-core box with 4 GB free for it. It will run on a Raspberry Pi, but preview generation and large syncs will be the painful parts. If you are deciding whether your hardware is up to it, the hardware guide has measured numbers for the exact mini PC used in these tests.

    Common gotchas

    The login page shows “The configuration is incomplete” or a redirect loop. The public URL the browser sees does not match trusted_domains, or the app thinks it is on HTTP when you are on HTTPS (or vice versa) behind a proxy. Set the trusted domain and overwrite.cli.url as described above, then clear your browser’s cached cookies for that host before testing.

    “Your web server does not seem to be correctly configured” warnings in the admin check. Nextcloud’s own web server self-test assumes Apache and flags things like the mod_headers module. Behind a reverse proxy in Docker, most of these warnings are false positives — the proxy, not the internal Apache, is what the internet sees. The ones worth acting on are the database (PostgreSQL version) and the PHP memory limit; the rest you can safely ignore in a containerized setup.

    Files do not sync to the desktop client. The sync client connects to the public URL you gave it, not to Docker internals. If you are using Nextcloud only on the LAN, point the client at http://<server-ip>:8084 (or your proxy domain). If you are behind a reverse proxy, use the proxy URL — and make sure the app’s overwrite.cli.url matches what the client uses, or WebDAV responses will reference the wrong host and the client will stall.

    Disk filling up faster than expected. The invisible consumers are versions, the trash bin, and the preview cache. In the admin settings you can see how much each occupies, and you can shorten the retention windows. There is also a occ command to trim versions and previews in one pass when you need space back fast.

    How this fits the rest of your home server

    Nextcloud becomes the file layer your other services plug into. It pairs with Jellyfin (point the media server at the Nextcloud-stored movies and TV, or keep media on a dedicated share), with the MinIO guide if you want S3-style object storage instead of or alongside WebDAV, and with the 3-2-1 backup strategy so the nextcloud-data volume — the one that actually contains your files — is backed up off-box. If you will be reaching Nextcloud from outside the house, do it over a VPN such as Tailscale rather than port-forwarding, and put a reverse proxy in front if you want a clean domain and automatic HTTPS.

    Tested on:

    OSDebian 12
    Docker29.7.2
    Hardware4-core / 16 GB
    SoftwareNextcloud 34.0.3 + PostgreSQL 16 (alpine)

    Last tested: 3 September 2026

  • The 3-2-1 Backup Strategy for a Home Server (with restic)

    The 3-2-1 Backup Strategy for a Home Server (with restic)

    Every other guide on this site ends with “back up the volume” — this one is what that actually means. The 3-2-1 rule (three copies, two different media, one off-site) is the floor, not the ceiling, for a self-hosted home, and restic is the tool that makes it boring: incremental, encrypted, deduplicated backups to S3 that run unattended and verify themselves. This guide builds the whole loop — what to back up, the restic setup, the cron job, and the restore test that separates a real backup strategy from a hope.

    Intermediate · 12 min · Docker

    The rule, translated to a home server

    • 3 copies of every file that matters: the live data, a local backup on a second disk, and an off-site copy.
    • 2 different media: your SSD and the backup disk are different devices; the off-site copy is a different medium entirely (S3, another machine, an encrypted drive in a different building).
    • 1 off-site: fire, flood, theft and ransomware that spreads over your LAN all take out everything on-site. Exactly one copy must live somewhere the house cannot reach.

    The version this site runs: restic on the server, three targets — a second local disk (fast restores of yesterday’s data), MinIO on a second machine (the “off-site” for a home network), and an encrypted local drive you take to a friend’s house or a bank box once a month (the real off-site). You do not need all three on day one; you need the off-site one eventually, and restic makes adding targets later a one-line change.

    Why restic (and not the obvious alternatives)

    resticBorgplain rsync to a disk
    Incremental + deduplicatedYesYesNo (full copies)
    Encrypted at restYesYesNo
    Native S3 targetYesYes (via restic/borgbase)No
    Self-check (verify)Built inBuilt inYou build it
    Retention policies (“keep last 7 daily”)One flagOne flagA script

    rsync-to-a-disk is not a backup strategy: it copies what is there, including deletions and ransomware, with no encryption and no version history. restic’s snapshot model — every backup is a named, restorable state of the file set — is what lets you roll back to “before the bad thing happened” instead of “before the last sync”.

    Step 1: What to back up (the inventory)

    Walk your stacks and write down the state directories. For the services on this site, the list is short and stable:

    StackPath to back upNotes
    Navidromethe navidrome_data volumeDB with play counts; the music folder is source data, see below
    Jellyfinconfig volumeMetadata/DB; the media library is source data
    MinifluxPostgres volumeSubscriptions and history
    DokuWikithe /data folderIt is plain-text files — trivial to verify by eye
    Home Assistant./configEntire setup, tens of MB
    Vaultwarden./dataEncrypted already, but back it up anyway
    Gitea./giteaRepositories + DB
    Immich./upload + DB dumpThe photos are the whole point

    Two distinctions keep this list from being a trap:

    • State vs source data. A service’s database and config are state — small, and the thing restic handles beautifully. Your music, photos and videos are source data — large, and often already stored in a library that is itself the archive. Back up the state everywhere; for source data, decide explicitly (photo library → Immich’s upload/ is the archive, so restic it too, or at least the DB dump; music you own on a card → a one-off full copy to the second disk is enough).
    • Named Docker volumes. Everything above that is a named volume (navidrome_data, Postgres data) lives in /var/lib/docker/volumes/<name>/_data. Either back that path directly, or bind-mount the stacks’ state into project folders (the convention used in every guide on this site, which is exactly why the inventory is a flat list of paths).

    Step 2: Install and initialize restic

    sudo apt install restic   # or your package manager's equivalent
    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass init
    restic -r s3:http://192.168.1.20:9000/backups \
      --s3-provider minio \
      --s3-access-key BACKUP_USER_KEY --s3-secret-key BACKUP_USER_SECRET \
      --s3-region us-east-1 \
      --password-file ~/.config/restic/pass init

    Three things to get right here:

    • The repository is a URL, and the same URL must be used for every command against it. Write both of yours into a file (e.g. ~/.config/restic/repos.txt) and copy from there. A one-character difference means “repository not found” at 2 a.m.
    • The password file. chmod 600 it. This password encrypts everything in the repository — losing it means losing the backups, so it lives in two places outside the backup targets (a password manager entry, and written paper). restic will not recover it for you; that is a feature.
    • A dedicated S3 user. From the MinIO guide: a non-root user with a policy scoped to the backups bucket. The backup job should be able to do exactly one thing.

    Step 3: The backup command

    Put the paths from the inventory in one file, ~/.config/restic/paths.txt (one per line; Docker volume paths included), and the actual job becomes:

    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass \
      backup $(cat ~/.config/restic/paths.txt | tr '\n' ' ') \
      --tag daily

    Run it once by hand and watch it work: the first run is a full backup (every file), every run after is incremental (only what changed). On a typical home stack the first run is a few GB; daily runs afterwards are usually under 100 MB.

    Step 4: Retention (forget, but keep the useful)

    Without retention, the repository grows forever. The pattern that covers every realistic disaster:

    restic -r /mnt/backup-disk/init --password-file ~/.config/restic/pass forget \
      --tag daily --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune

    Seven daily snapshots (any failure in the last week is restorable), four weekly (the “I broke the config in June” case), twelve monthly (year-over-year). Run forget after each backup. --prune reclaims the space from dropped snapshots; it is the slow step, which is why it runs once a day, not on every snapshot.

    Step 5: Put it on cron (and make it report)

    # /etc/cron.d/restic-backup  (or crontab -e as the backup user)
    15 3 * * *  backupuser  /home/backupuser/scripts/restic-daily.sh >> /var/log/restic-daily.log 2>&1

    With restic-daily.sh doing backup → forget → a short --files-from verify sample, and emailing you only on failure:

    #!/usr/bin/env bash
    set -euo pipefail
    REPO=/mnt/backup-disk/init
    PASS=~/.config/restic/pass
    PATHS=$(cat ~/.config/restic/paths.txt | tr '\n' ' ')
    
    restic -r "$REPO" --password-file "$PASS" backup $PATHS --tag daily
    restic -r "$REPO" --password-file "$PASS" forget --tag daily --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
    
    # Spot-check: verify a sample of files (full verify is the weekly job)
    if ! restic -r "$REPO" --password-file "$PASS" check --read-data-subset=0.01; then
      echo "restic check failed" | mail -s "BACKUP PROBLEM on $(hostname)" you@example.com
    fi

    Silence on success is the design: you want the one email a month that says something is wrong, not sixty that say all is well. (The weekly job — check --read-data on the full repository, plus the off-site S3 repository — is the same script pointed at the other URL.)

    Step 6: The restore test (the part everyone skips and needs)

    Why this is non-optional

    A backup you have never restored is a theory. The failure modes it catches are the real ones: the password file was “backed up” only to the machine that died, the paths file listed a folder that moved, the S3 policy silently rejects reads, the snapshot exists but the data chunks do not. Twenty minutes a quarter:

    1. Pick a real file you care about (a DokuWiki page, a photo from three weeks ago).
    2. restic -r $REPO --password-file $PASS restore last --target /tmp/restore-test
    3. Open it. The actual file, read by an actual human, on an actual day.
    4. Delete /tmp/restore-test. Done. You now know the strategy works.

    Write the date you last restored in a note next to the paths file. A “last verified” stamp is the difference between a strategy and a ritual.

    Resource usage (measured)

    OperationTypical cost
    First full backup (~10 GB of stacks state + photo lib)20–60 min, disk + network bound, ~200 MiB RAM
    Daily incremental (small changes)1–5 min, <100 MiB RAM
    check --read-data full verify (weekly)Re-reads everything: 1 h per 100 GB, schedule it overnight

    It runs on the same box as the stacks it protects and never notices it. The hardware it saves is the point.

    FAQ

    What about the “one off-site” if I do not have a second machine?

    Order of preference: a second machine running MinIO (even a Pi in a different room, or a cheap VPS) → an encrypted external drive on a rotation schedule (LUKS, take it off-site monthly) → a provider’s object storage (the last resort, because it is the one copy you do not control). The restic repository URL is the only thing that changes between these; the script, the retention, and the verify jobs are identical.

    Does restic protect against ransomware on the server?

    Partially, and honestly: restic snapshots are append-only from the server’s perspective, so files encrypted after the last snapshot are restorable to their pre-encryption state. What it does not protect against is a compromised backup user that deletes snapshots — which is why the off-site repository is the one you verify weekly, and why the S3 user’s policy should be read/write on the repository path only, no admin rights.

    Can I back up Docker containers themselves?

    Back up their state (volumes, per the inventory), not the containers — containers are disposable; docker compose up -d rebuilds them. The compose files are text; keep them in Gitea, which is itself a backup target. You end up with the elegant loop: the backup of your infrastructure is one of the things being backed up.

    What if a backup run fails?

    The email tells you. The usual causes, in order: the target disk is unmounted (the /mnt path is empty), the S3 target is unreachable (the second machine is down), or a path in the file no longer exists (restic exits non-zero and the mailer fires). Fix the cause, re-run the script by hand, and confirm the next snapshot lands. Do not let two consecutive daily runs fail silently — that is when a “backup” stops being one.

    Where does this fit?

    This is the article every other guide on this site points at: each stack’s “back up the volume” step resolves to a line in paths.txt here. The off-site target is MinIO, the compose files live in Gitea, and the whole thing is reachable for checking from anywhere via Cloudflare Tunnel — with the tunnel itself being just one more line in the inventory.

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

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

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

    Beginner · 8 min · Docker

    Why you would want your own S3

    Three reasons come up constantly in self-hosted setups:

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

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

    Prerequisites

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

    Step 1: The compose file

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

    Create docker-compose.yml:

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

    Three notes on that file:

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

    Step 2: Start it and open the console

    docker compose up -d
    docker compose logs -f minio

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

    Step 3: Create a bucket

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

    Step 4: Create a dedicated user for apps

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

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

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

    Step 5: Connect a client

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

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

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

    restic — backup to your own S3:

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

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

    aws CLI — if an app or script demands it:

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

    Step 6: TLS and external access

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

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

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

    Resource usage (measured)

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

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

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

    Updating

    docker compose pull && docker compose up -d

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

    FAQ

    Can I run MinIO on a Raspberry Pi?

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

    What happens to my data if the container breaks?

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

    Do I need versioning?

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

    How is this different from Samba or a plain folder?

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

    Where does this fit?

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

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

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

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

    Beginner · 10 min · Linux

    What a home server actually needs

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

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

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

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

    Option 1: Raspberry Pi

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

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

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

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

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

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

    What to check before buying, in order:

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

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

    Option 3: the repurposed desktop

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

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

    Storage: the decision that is hard to reverse

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

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

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

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

    The numbers we measured

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

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

    Decision summary

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

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

    FAQ

    Is a Pi 5 enough for a family media server?

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

    How much RAM do I actually need?

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

    Should I buy new instead of used?

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

    Tested on:

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

    Last tested: 23 August 2026