Category: Productivity

Self-hosted tools for notes, tasks and everyday productivity that keep your data yours.

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

  • Gitea in Docker: Self-Hosted Git Server (Compose Guide)

    Gitea in Docker: Self-Hosted Git Server (Compose Guide)

    Gitea is a fast, single-binary Git forge you run yourself: repositories, pull requests, issue tracking, code review, and CI hooks, in one container that idles at about 100 MB of RAM. If your code currently lives on a public platform and you would rather it live on hardware you own — or you just want a second remote that survives a provider’s policy change — this is the twenty-minute setup. This guide covers the Docker Compose install, the first repository, Git over SSH, and the settings that keep a personal forge sane.

    Beginner · 9 min · Docker

    Why self-host your Git

    • Your code is yours, on your disk. No ToS change, no account suspension, no “legacy” tier. A git push to your server is a file copy to a machine you control.
    • It is a free second remote. Even if you keep using a public platform for collaboration, having every project also pushed to Gitea is cheap off-box (or on-box, other partition) redundancy with zero service dependency.
    • Private by default, no pricing tier. Private repositories, unlimited collaborators, and no “who can see this” math at the plan boundary.

    The honest caveat: Gitea is the community’s forge, not GitHub’s. You do not get the marketplace, the huge ecosystem of third-party integrations, or free public CI minutes. For personal and small-team work it covers 95% of what those platforms do; for “I need 40 people and 30 integrations” you want the big platforms.

    Prerequisites

    • Docker + Compose plugin
    • Free ports: 3000 (web) and 222 (Git over SSH — the container’s internal port 22, remapped so it does not fight your server’s real SSH)

    Step 1: The compose file

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

    Create docker-compose.yml:

    services:
      gitea:
        image: gitea/gitea:latest
        container_name: gitea
        ports:
          - "3000:3000"
          - "222:22"   # SSH for git clone via SSH (change if 222 is busy)
        environment:
          - USER_UID=1000
          - USER_GID=1000
          - GITEA__database__DB_TYPE=sqlite3
          - GITEA__server__DOMAIN=git.example.com
          - GITEA__server__SSH_PORT=222
          - GITEA__server__ROOT_URL=https://git.example.com/
          - GITEA__security__INSTALL_LOCK=true
        volumes:
          - ./gitea:/data
          - /etc/timezone:/etc/timezone:ro
          - /etc/localtime:/etc/localtime:ro
        restart: unless-stopped

    Notes on the choices:

    • SQLite, not Postgres. For a personal or small-team forge, SQLite is the right default: zero extra containers, and Gitea’s own docs say it is fine for the scale at which people self-host. The moment you want multiple instances or very heavy CI load, swap GITEA__database__DB_TYPE to postgres and add a DB container — the data directory makes the migration path clean.
    • GITEA__server__SSH_PORT=222 — this is the port Git clients use, and it must match the host-side mapping (222:22). Get this wrong and the clone URLs Gitea suggests do not work, which is the single most common first-day bug.
    • GITEA__server__DOMAIN and GITEA__server__ROOT_URL — set these to the final public URL (ideally behind the Cloudflare Tunnel setup). They control the URLs Gitea prints in its UI and emails.
    • GITEA__security__INSTALL_LOCK=true — skips the web install wizard, since everything is set via environment. If you prefer the wizard, remove this line and it will guide you through the same settings on first visit.
    • USER_UID/GID — match your host user so files on the bind mount have sane ownership. On a fresh box, check id -u.

    Step 2: Start it and create your account

    docker compose up -d
    docker compose logs -f gitea

    Open http://YOUR_SERVER_IP:3000. With INSTALL_LOCK=true there is no wizard; log in as gitea (the default admin user the image creates for you — you will be asked to set its password on first login), then under Site Administration → Users create your real account and make it an administrator. Delete or demote the gitea account once yours works. Log in with the real account from here on.

    Step 3: First repository

    Top-right +New Repository → give it a name, keep it private, do not initialize with a README (you have existing code). Create it, and you get the clone URLs immediately. Two ways to use it:

    HTTPS with a token — fine for quick use: create a Personal Access Token (your avatar → Settings → Applications), then:

    git remote add mygitea https://YOUR_SERVER_IP:3000/you/myproject.git
    git push mygitea main

    SSH (the better default) — set up a key once and every clone is passwordless:

    1. Your avatar → Settings → SSH Keys → add your ~/.ssh/id_ed25519.pub.
    2. Clone using the SSH URL Gitea shows — note the port: git@YOUR_SERVER_IP:222:you/myproject.git (the colon before the path is part of the scp-style syntax; the port comes right after the host).
    3. To stop typing the port every time, add to ~/.ssh/config:
      Host gitea
        HostName YOUR_SERVER_IP
        Port 222
        User git
      Now git clone gitea:you/myproject.git just works.

    Step 4: The settings worth changing

    1. Disable open registration (Site Administration → Installation → Registration and login, or the env GITEA__service__DISABLE_REGISTRATION=true). A personal forge has no business letting strangers create accounts, even behind a tunnel.
    2. Require sign-in for everything (same section: REQUIRE_SIGNIN_VIEW=true). Anonymous browsing of your repositories is off by default for private ones, but making sign-in mandatory closes the anonymous corner entirely.
    3. Two-factor authentication for your account (Settings → Security). It is the same TOTP flow as the Vaultwarden guide — set it up while you are thinking about credentials.
    4. Default branch and push rules per repo: enforce a default branch name, and optionally reject pushes to main so everything goes through a pull request. For a solo developer, PR-to-main is a habit that pays off the day you want a second pair of eyes (or an agent) to review your changes.

    External access

    Same rule as every other service: no raw port forwarding of 3000. The two paths, in order of preference:

    • Cloudflare Tunnelgit.example.com192.168.x.x:3000, and update DOMAIN/ROOT_URL to match. HTTPS clones through the tunnel work fine.
    • Tailscale — SSH clones over the mesh, which is actually the most comfortable day-to-day: git clone from anywhere, no public surface at all.

    If you use SSH over the tunnel, remember the tunnel routes HTTP(S) — for raw SSH traffic the Tailscale path is simpler. In practice: HTTPS + tunnel for the web UI and HTTPS clones, Tailscale for SSH, or just pick one and live with it.

    Resource usage (measured)

    StateRAM
    Idle (SQLite, ~50 repos)~100–150 MiB
    Pushing a large repo (1 GB)~300 MiB, disk-bound

    It will share a 2 GB machine with the rest of the stack without complaint. Disk is the resource to watch: every clone on the server is a full copy of the history.

    Backups and updates

    Everything is under ./gitea — repositories under gitea/repositories/, the SQLite database inside gitea. The correct backup is the whole folder, copied while the service is idle (or use docker compose exec gitea git bundle per-repo for surgical backups):

    restic -r s3:http://YOUR_MINIO_IP:9000/backups backup ~/stacks/gitea

    Updates are the standard two-liner:

    docker compose pull && docker compose up -d

    Gitea runs database migrations on start; read the release notes for anything marked as a breaking change and back up ./gitea first — it is small, and it is the one folder on the machine that is not “re-downloadable”.

    FAQ

    Can I keep my existing GitHub repos in sync?

    Yes, and it is a good habit: add Gitea as a second remote on every project (git remote add mygitea ...) and git push --all mygitea after your normal pushes. Two minutes of setup, and your code now exists in two places, one of which you own.

    Does it handle big monorepos?

    Fine for hundreds of MB of history. For multi-GB histories, you get the same scaling behaviour as any Git implementation — shallow clones, partial clones, and LFS if you store binaries. Gitea supports Git LFS out of the box (the GITEA__lfs__ENABLED=true setting, with LFS files under ./gitea/lfs).

    What about CI/CD?

    Gitea has built-in Actions (a GitHub Actions-compatible runner) if you want pipelines on the same box. For lighter needs, webhooks from Gitea into whatever you already run are enough — it is the same webhook model as any other forge.

    Where does this fit?

    Gitea is the code layer of the stack: it pairs with the 3-2-1 backup strategy (the next article in this series — the ./gitea folder is a first-class backup target in MinIO), and it is reachable from anywhere via the Cloudflare Tunnel guide. Everything you push there is a second copy of the work — which is the entire point of having it.

    What’s next?

    The natural next steps from this guide:

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

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

    Beginner · 7 min · Docker · Wiki

    Tested on:

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

    Last tested: 22 August 2026

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

    Why DokuWiki in 2026

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

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

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

    Prerequisites

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

    Step 1: The compose file

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

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

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

    Step 2: Start and run the wizard

    docker compose up -d
    docker compose ps

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

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

    Step 3: The editor and page syntax

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

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

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

    Step 4: The settings worth changing

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

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

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

    Step 5: Backing up a plain-text wiki

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

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

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

    Resource usage (measured)

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

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

    Updating

    docker compose pull && docker compose up -d

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

    FAQ

    Can I import existing markdown or org-mode notes?

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

    Does it work offline / without internet?

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

    Multiple users?

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

    Where does this fit?

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