Tag: docker

  • Scrutiny: catching failing hard drives before they die

    Hard drives rarely die without warning: they usually report trouble through S.M.A.R.T. long before they fail. The problem is that nobody reads those reports. Scrutiny reads them for me, and shows the health of every drive in my server on one simple web page.

    What is S.M.A.R.T.?

    Every modern hard drive and SSD keeps self-monitoring data: reallocated sectors, read errors, temperature, power-on hours, wear levels and more. Some of these numbers are strong predictors of failure. Scrutiny collects them on a schedule, stores the history, and flags drives as passed, warning or failed. Its thresholds are based on real-world failure rates published by Backblaze, rather than just the manufacturer’s own limits.

    Setting it up

    I use the “omnibus” image, which bundles the collector, web UI and database in one container. Here’s a compose file based on mine:

    services:
      scrutiny:
        container_name: scrutiny
        image: ghcr.io/analogj/scrutiny:master-omnibus
        restart: unless-stopped
        cap_add:
          - SYS_RAWIO
          - SYS_ADMIN
        ports:
          - "8085:8080"
        volumes:
          - /run/udev:/run/udev:ro
          - ./config:/opt/scrutiny/config
          - ./influxdb:/opt/scrutiny/influxdb
        devices:
          - /dev/sda
          - /dev/sdb
          - /dev/nvme0
    • devices lists every drive to monitor. Find yours with lsblk, and include all of them, especially the big data and backup drives you care about most.
    • SYS_RAWIO lets Scrutiny read S.M.A.R.T. data from SATA drives. SYS_ADMIN is needed for NVMe drives.
    • /run/udev lets it identify drives properly.
    • ./influxdb stores the history, so you can watch trends over time.

    Browse to http://<server-ip>:8085 and you’ll see a card for each drive.

    Reading the dashboard

    • Passed drives need nothing.
    • Warning means an attribute has crossed a threshold that’s linked to higher failure rates. Check that the drive is in your backups and keep an eye on it.
    • Failed means plan a replacement now, and make sure the data is backed up before anything else.

    Click a drive to see every attribute and its history. The ones I watch most are reallocated sector count, pending sectors and uncorrectable errors on hard drives, and “percentage used” on SSDs and NVMe drives.

    Notifications

    A dashboard you never open doesn’t help much. Scrutiny can send alerts through dozens of services (Discord, email, Telegram, ntfy and more) using notification URLs in config/scrutiny.yaml. Set one up so a failing drive actually tells you.

    Tips

    • USB enclosures don’t always pass S.M.A.R.T. data through. If an external drive shows no data, check Scrutiny’s docs for the right device type.
    • Scrutiny is not a backup. It gives you time to act, but drives can still fail suddenly. See my backup plan.
  • My home lab dashboard: Homarr and Glances

    Once you’re running more than a handful of self-hosted apps, remembering which port each one lives on gets old fast. Two tools fix that for me: Homarr, a start page with a tile for every service, and Glances, which shows how the server itself is coping.

    Diagram: a browser on the home network opens the Homarr dashboard, which has tiles for Immich, Nextcloud, Vaultwarden, Plex, Portainer and Technitium, and links to Glances, which reads CPU, memory, disk and Docker stats from the home server.
    Homarr gets me to every service; Glances shows the health of the server underneath.

    Homarr: the front door to everything

    Homarr is a self-hosted dashboard. You build it in the browser by dragging tiles around, with no config files to edit. Each tile links to one of your services and can show whether it’s up. Widgets can add extras like the weather, a calendar or stats from your apps.

    I set it as the home page on my browsers. Photos, files, passwords, Portainer, DNS: everything is one click away, and a red status dot tells me straight away if something has fallen over.

    Setting up Homarr

    Homarr needs a secret key to encrypt the credentials it stores for integrations. Generate one and put it in a .env file next to the compose file:

    mkdir -p /opt/stacks/homarr && cd /opt/stacks/homarr
    echo "SECRET_ENCRYPTION_KEY=$(openssl rand -hex 32)" > .env

    Then compose.yaml:

    services:
      homarr:
        container_name: homarr
        image: ghcr.io/homarr-labs/homarr:latest
        restart: unless-stopped
        ports:
          - "7575:7575"
        environment:
          - SECRET_ENCRYPTION_KEY=${SECRET_ENCRYPTION_KEY}
        volumes:
          - ./appdata:/appdata
          - /var/run/docker.sock:/var/run/docker.sock   # optional: lets Homarr see your containers
    docker compose up -d

    Open http://<your-server-ip>:7575, create your admin account, and start adding tiles. The Docker socket mount is optional. It lets Homarr list your containers and add them to the board quickly, but it also gives Homarr a lot of power over your server, so leave it out if you don’t need that.

    Keep it on your home network

    A dashboard is basically a map of your network, so I don’t expose mine to the internet. It goes through Nginx Proxy Manager with an access list that only allows local addresses, so I get a friendly name on my home network and it’s invisible from outside.

    Glances: is the server OK?

    Glances is a system monitor. Think top or htop, but showing everything at once: CPU, load, memory, network, disk activity, free space on every drive, temperatures, the busiest processes and every Docker container’s CPU and memory use. Run it in web mode and you get all of that in a browser tab.

    It’s my first stop when something feels slow. Is it Immich chewing through a big import? Is a drive filling up? Glances answers that in seconds.

    Setting up Glances

    This is my /opt/stacks/glances/compose.yaml:

    services:
      glances:
        container_name: glances
        image: nicolargo/glances:latest-full
        restart: unless-stopped
        pid: host
        network_mode: host
        environment:
          - GLANCES_OPT=-w -C /glances/conf/glances.conf
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock:ro
          - /etc/os-release:/etc/os-release:ro
          - ./glances.conf:/glances/conf/glances.conf:ro
          - /media/ExtStorage:/media/ExtStorage:ro

    A few lines are worth explaining, because they’re what let a container see the whole machine:

    • pid: host shows the server’s real processes, not just the container’s.
    • network_mode: host shows real network traffic, and serves the web UI on port 61208.
    • The read-only docker.sock adds per-container stats.
    • /etc/os-release shows the host’s OS name instead of the container’s.
    • Mount any extra drives you want to track (read-only), like my external storage drive.
    • -w starts web server mode.

    Running in a container, Glances also sees a lot of internal mounts that clutter the disk list. A small glances.conf hides them:

    [fs]
    hide=/etc/.*,/var/run/.*,/run/.*,/glances/.*

    Start it with docker compose up -d and browse to http://<your-server-ip>:61208.

    Security note: the Glances web UI has no login by default, and it shows a lot about your machine. Keep it on your home network, or start it with --password to require one.

    Putting them together

    Add a Glances tile to your Homarr board, and you have one page that gets you to every service and to the health of the server underneath. With more than one server, run the same Glances compose file on each and give each its own tile.

    New to Docker? Start with Getting started with Docker and Portainer.

  • Watchtower: automatic Docker updates, done safely

    Keeping a dozen Docker containers up to date by hand gets tedious. Watchtower does it for me: every night it checks for new images, updates the containers that need it, and tells me what it did. The trick is knowing what not to let it update.

    What Watchtower does

    Watchtower runs as a container with access to the Docker socket. On a schedule, it checks the registry for a newer version of each container’s image. If there is one, it pulls it and recreates the container with exactly the same settings, then optionally removes the old image.

    My setup

    services:
      watchtower:
        image: containrrr/watchtower:latest
        container_name: watchtower
        restart: unless-stopped
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
        environment:
          - TZ=Europe/Dublin
          - WATCHTOWER_SCHEDULE=0 0 4 * * *
          - WATCHTOWER_CLEANUP=true
          - WATCHTOWER_INCLUDE_STOPPED=true
          - WATCHTOWER_REVIVE_STOPPED=false
          - WATCHTOWER_NOTIFICATIONS=shoutrrr
          - WATCHTOWER_NOTIFICATION_URL=discord://<token>@<webhook-id>
          - WATCHTOWER_NOTIFICATIONS_HOSTNAME=homeserver
    • WATCHTOWER_SCHEDULE is a cron expression with a seconds field first. 0 0 4 * * * means 4 am every day, when nobody’s using anything.
    • WATCHTOWER_CLEANUP deletes the old image after updating, so your disk doesn’t slowly fill up.
    • INCLUDE_STOPPED / REVIVE_STOPPED update stopped containers too, but leave them stopped.
    • Notifications go to a Discord channel through Shoutrrr, so each morning I can see what changed. Shoutrrr also supports email, Telegram, ntfy and others. Treat the notification URL like a password: anyone with it can post to your channel.

    If Watchtower starts failing with a Docker API version error after a Docker upgrade, setting DOCKER_API_VERSION to a version your Docker engine supports fixes it.

    Choosing what not to auto-update

    Automatic updates are great for simple apps, but some containers shouldn’t change without you watching. Watchtower is controlled with labels on each container:

    labels:
      # never update this container automatically
      - com.centurylinklabs.watchtower.enable=false
    
      # check for updates and notify me, but don't apply them
      - com.centurylinklabs.watchtower.monitor-only=true

    How I use them:

    • Databases (Postgres for Immich; MariaDB for Nextcloud, WordPress and Joplin) are either excluded or monitor-only. Database upgrades can need migration steps, and a surprise major version bump can break the app that depends on it.
    • Immich’s database and Redis are excluded completely, because Immich pins specific versions for a reason.
    • Vaultwarden is excluded. My password vault gets updated deliberately, after I’ve read the release notes.
    • Everything else, such as dashboards, monitoring tools and media servers, updates automatically.

    Is auto-updating a good idea?

    For a home lab, I think so, with these exclusions. Most updates are security and bug fixes, and getting them automatically beats forgetting for months. The rules that keep it safe:

    1. Exclude anything stateful or critical.
    2. Get notifications, so you know what changed if something breaks.
    3. Have backups, so a bad update is an inconvenience, not a disaster. See my backup plan.
  • UrBackup: backing up every computer in the house

    My home server doesn’t just back up itself. It also backs up the other computers in the house. UrBackup is the open-source server that does it, quietly, every day, with no subscription.

    What UrBackup does

    UrBackup is a client/server backup system. You run the server on one machine, install a small client on each computer you want protected, and the server pulls backups over the network on a schedule:

    • File backups: incremental and full copies of chosen folders, with deduplication so unchanged files aren’t stored twice.
    • Image backups (Windows): a full disk image you can restore a whole PC from, even onto a new drive.
    • A web interface for status, settings and browsing or downloading backed-up files.

    I use it to back up my Windows PC and a Raspberry Pi, with everything stored on the large external drive in my server.

    My setup

    services:
      urbackup:
        image: uroni/urbackup-server:latest
        container_name: urbackup
        restart: unless-stopped
        network_mode: host
        environment:
          - PUID=1000
          - PGID=1000
          - TZ=Europe/Dublin
        volumes:
          - /opt/stacks/urbackup/data:/var/urbackup
          - /media/ExtStorage/backups/urbackup:/backups
    • network_mode: host lets clients on your network discover the server automatically, using UDP broadcasts.
    • /backups is where the backups themselves go. Point it at a big disk.
    • /var/urbackup holds the server’s own database and settings. Keep it on a reliable disk too.

    The web interface runs on port 55414: http://<server-ip>:55414.

    Adding a computer

    1. Download the client for Windows, Linux or macOS from the UrBackup website and install it.
    2. On the same network, the client finds the server by itself, and it appears in the web interface within a minute or two.
    3. In the client (Windows tray icon), choose which folders to back up, or let it use the defaults.
    4. Adjust the schedule and how many backups to keep under Settings in the web interface.

    Restoring

    • Single files: browse the backups in the web interface and download what you need, or restore from the client.
    • A whole Windows PC: boot from the UrBackup restore image on a USB stick and restore an image backup to the disk.

    Test a restore now and then. A backup you’ve never restored from is only a hope.

    One important caveat

    UrBackup stores its backups on my server, so if something happens to the server itself, the PC backups go with it. That’s why they’re part of my 3-2-1 backup plan, which copies everything important to a second drive kept away from the house.

  • Replacing Pi-hole with Technitium DNS, on two servers

    For a long time I ran Pi-hole as my network’s ad and malware sink. It’s a great project, but I’ve since replaced it with Technitium DNS Server, running on two machines so that one going down never takes the whole house offline.

    What a DNS sinkhole does

    Every time a device opens a website or an app phones home, it first asks a DNS server to turn a name like ads.example.com into an IP address. A DNS sinkhole checks each name against blocklists of known ad, tracking and malware domains. If the name is on a list, it simply doesn’t give a real answer, so the ad, tracker or malicious download never loads. This works for every device on the network, including smart TVs and phones where you can’t install an ad blocker.

    Why I moved from Pi-hole to Technitium

    Pi-hole does blocking brilliantly. Technitium does blocking too, but it’s also a complete DNS server, which suits a growing home lab:

    • Encrypted upstream DNS built in: queries leave my network over DNS-over-HTTPS, with no extra resolver containers to bolt on.
    • Wildcard blocklists: blocking a domain blocks all its subdomains, so modern lists stay smaller and more effective.
    • Proper local zones: I can give my own machines and services real names on the home network.
    • Apps: optional add-ons like a searchable query log stored in SQLite.
    • A clean web interface with a dashboard of queries, blocked requests and top clients.

    Why two servers?

    When all your DNS goes through one box, that box becomes a single point of failure. Reboot it for updates, or have it crash, and every device in the house “loses the internet”, even though the connection is fine. So I run Technitium on two separate machines, and the router hands out both as DNS 1 and DNS 2.

    Diagram: every device is given two DNS servers, Technitium 1 and Technitium 2; both check blocklists for ads, trackers and malware, giving no answer for blocked domains and forwarding allowed queries upstream to Cloudflare and Google over DNS-over-HTTPS.
    Two Technitium servers share the load and cover for each other.

    Setting it up

    1. Free up port 53 (Ubuntu)

    Ubuntu’s built-in systemd-resolved already listens on port 53, which a DNS server needs. Turn off its stub listener:

    sudo sed -i 's/^#\?DNSStubListener=.*/DNSStubListener=no/' /etc/systemd/resolved.conf
    sudo systemctl restart systemd-resolved

    Once Technitium is running, point the server at itself (127.0.0.1) and at your second DNS server, so it keeps working even if the local one is down.

    2. The compose file

    Save as /opt/stacks/technitium/compose.yaml, and put the admin password in a .env file rather than in the compose file:

    services:
      technitium:
        container_name: technitium
        image: technitium/dns-server:latest
        network_mode: host
        environment:
          TZ: 'Europe/Dublin'
          DNS_SERVER_DOMAIN: 'dns-server'
          DNS_SERVER_ADMIN_PASSWORD: ${DNS_ADMIN_PASSWORD}
          DNS_SERVER_LOG_USING_LOCAL_TIME: 'true'
          DNS_SERVER_WEB_SERVICE_HTTP_PORT: '5380'
          DNS_SERVER_WEB_SERVICE_LOCAL_ADDRESSES: '0.0.0.0'
          DNS_SERVER_ENABLE_BLOCKING: 'true'
        volumes:
          - ./etc-technitium:/etc/dns
          - ./logs:/var/log/technitium
        cap_add:
          - NET_BIND_SERVICE
        restart: unless-stopped
    echo "DNS_ADMIN_PASSWORD=pick-a-strong-password" > .env
    chmod 600 .env
    docker compose up -d

    network_mode: host lets Technitium answer DNS on port 53 directly, and see real client IPs in its logs. The password variable only sets the admin password on first start. After that, change it in the web UI.

    3. Encrypted upstream DNS

    Open http://<server-ip>:5380 and log in. Under Settings → Proxy & Forwarders, use the quick-select to add Cloudflare and Google with the DNS-over-HTTPS protocol. I use both:

    https://1.1.1.1/dns-query
    https://8.8.8.8/dns-query

    4. Add blocklists

    Under Settings → Blocking, make sure blocking is enabled and add your blocklist URLs. These are the ones I use:

    • HaGeZi Multi and HaGeZi Pro++: well-maintained lists covering ads, tracking, malware, phishing and scams, in wildcard format (github.com/hagezi/dns-blocklists).
    • StevenBlack unified hosts with the fake-news and gambling extensions (github.com/StevenBlack/hosts). There’s also a variant that adds adult content, which is handy on a family network.

    Technitium downloads and refreshes the lists automatically. Start with one or two lists and add more gradually. Aggressive lists like Pro++ occasionally block something you need, which the Allowed tab fixes in seconds.

    5. Build the second server

    On the second machine, repeat steps 1 and 2. Then use Settings → Backup Settings on the first server and Restore Settings on the second to copy the forwarders, blocklists and allow-list across.

    Keep the two in sync. Devices don’t strictly use “DNS 1 first, DNS 2 as backup”. They often spread queries across both. If the servers have different blocklists, ads will mysteriously appear on some page loads and not others. Whenever you change a list on one, make the same change on the other.

    6. Point your network at them

    In your router’s DHCP settings, set the primary DNS to server 1 and the secondary to server 2. Devices pick up the change the next time they renew their lease, or straight away if you reconnect them.

    7. Test it

    # a normal domain should resolve on both servers
    dig +short @<server-1-ip> example.com
    dig +short @<server-2-ip> example.com
    
    # an ad domain should come back empty (blocked)
    dig +short @<server-1-ip> doubleclick.net

    Then the real test: stop Technitium on one server and keep browsing. If everything still loads, your redundancy works.

    Was it worth switching?

    Yes. Ads and known-bad domains are blocked for every device in the house, DNS queries leave the network encrypted, and rebooting a server for updates no longer means everyone shouting that the Wi-Fi is broken.

    New to Docker? Start with Getting started with Docker and Portainer.

  • Nextcloud: my own Dropbox and Google Drive

    Nextcloud is my replacement for Dropbox and Google Drive: file sync and sharing, running on my own server, with the files stored on my own disks.

    What Nextcloud gives you

    • Desktop sync clients for Windows, macOS and Linux, and mobile apps for Android and iOS
    • Sharing links with passwords and expiry dates
    • Optional apps for calendars, contacts, notes, tasks, photos and office documents
    • No storage plan to pay for: the limit is your hard drive

    My setup

    Nextcloud runs as four containers: the app, a MariaDB database, Redis for caching and file locking, and a cron container for background jobs. My compose file looks like this (passwords removed):

    services:
      db:
        image: mariadb:10.11
        restart: always
        command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
        volumes:
          - db:/var/lib/mysql
        environment:
          - MARIADB_RANDOM_ROOT_PASSWORD=yes
          - MYSQL_PASSWORD=change-me
          - MYSQL_DATABASE=nextcloud
          - MYSQL_USER=nextcloud
        labels:
          - com.centurylinklabs.watchtower.monitor-only=true
    
      redis:
        image: redis:alpine
        restart: always
    
      app:
        image: nextcloud:apache
        restart: always
        ports:
          - "8080:80"
        volumes:
          - /media/ExtStorage/nextcloud:/var/www/html/data
          - nextcloud:/var/www/html
        environment:
          - MYSQL_PASSWORD=change-me
          - MYSQL_DATABASE=nextcloud
          - MYSQL_USER=nextcloud
          - MYSQL_HOST=db
          - REDIS_HOST=redis
          - TRUSTED_PROXIES=192.168.0.10
          - OVERWRITEPROTOCOL=https
          - OVERWRITEHOST=nextcloud.example.com
        depends_on:
          - db
          - redis
    
      cron:
        image: nextcloud:apache
        restart: always
        volumes:
          - nextcloud:/var/www/html:z
        entrypoint: /cron.sh
        depends_on:
          - db
          - redis
    
    volumes:
      nextcloud:
      db:

    A few choices worth explaining:

    • Data on a big disk. The app lives in a Docker volume, but the actual files (/var/www/html/data) are on my large external storage drive.
    • Database settings. READ-COMMITTED and row-based binary logging are what Nextcloud recommends for MariaDB.
    • The cron container runs Nextcloud’s background jobs every five minutes. Set Background jobs to Cron in the admin settings so Nextcloud uses it.
    • Behind a reverse proxy. OVERWRITEPROTOCOL and OVERWRITEHOST tell Nextcloud its public address is HTTPS. TRUSTED_PROXIES must be the IP address of your reverse proxy. Get it wrong and Nextcloud thinks every visitor is the proxy, which confuses its brute-force protection.
    • Watchtower monitor-only on the database, so I’m told about updates but apply them myself.

    Useful commands

    Nextcloud’s admin tool, occ, runs inside the app container as the web server user:

    docker exec -u www-data nextcloud-app-1 php occ status
    docker exec -u www-data nextcloud-app-1 php occ files:scan --all
    docker exec -u www-data nextcloud-app-1 php occ db:add-missing-indices

    The files:scan command is handy if you copy files straight into the data folder, rather than through Nextcloud.

    Tips

    • Check Administration settings → Overview after every update. Nextcloud lists exactly what it wants fixed.
    • Update one major version at a time. Nextcloud can’t skip major versions, so don’t jump from, say, 28 to 30 in one go.
    • Put it behind Nginx Proxy Manager with HTTPS, and set a sensible upload size limit in the proxy for big files.
    • Back up both the data folder and the database. See my backup plan.
  • Nginx Proxy Manager and Let’s Encrypt: HTTPS for every self-hosted app

    Every service I expose to the internet, including this blog, my photos and my password manager, sits behind Nginx Proxy Manager. It gives each one its own web address and a free, automatically renewed HTTPS certificate, all from a simple web interface.

    What a reverse proxy does

    At home you have one public IP address, but lots of services, each on its own port. A reverse proxy sits in front of them all. Visitors connect to it on the standard web ports (80 and 443), and it looks at the domain name they asked for, photos.example.com or vault.example.com, and forwards the request to the right app on your network. It also handles the HTTPS certificates, so the apps themselves don’t have to.

    Diagram: visitors on the internet connect over HTTPS to the home router, which forwards ports 80 and 443 to Nginx Proxy Manager. NPM handles SSL with Let's Encrypt, picks the app by domain name, and forwards to Immich, Vaultwarden, Nextcloud or WordPress over plain HTTP on the home network.
    One public IP, many apps, each with its own HTTPS certificate.

    Why Nginx Proxy Manager

    Nginx is a superb proxy, but its config files can be fiddly. Nginx Proxy Manager (NPM) wraps it in a web interface: add a proxy host, tick a few boxes, and request a Let’s Encrypt certificate with one click. I run mine on a separate small machine from my main server.

    Setting it up

    Here’s a basic compose file:

    services:
      npm:
        image: jc21/nginx-proxy-manager:latest
        restart: unless-stopped
        ports:
          - "80:80"     # HTTP
          - "443:443"   # HTTPS
          - "81:81"     # admin interface (keep this on your LAN)
        volumes:
          - ./data:/data
          - ./letsencrypt:/etc/letsencrypt

    Start it with docker compose up -d, browse to http://<server-ip>:81, and log in. Follow the setup prompts and change the default admin details straight away.

    Then, outside NPM:

    1. Forward ports 80 and 443 on your router to the NPM machine. Never forward port 81.
    2. Point your domain at home. Create a DNS A record for each subdomain, or a wildcard, pointing at your public IP.

    Adding a service

    1. Hosts → Proxy Hosts → Add Proxy Host.
    2. Domain name: e.g. photos.example.com.
    3. Forward to: the app’s local IP and port, usually over plain http.
    4. Turn on Block Common Exploits, and Websockets Support for apps that need live updates, such as Immich, Vaultwarden and Homarr.
    5. On the SSL tab, choose Request a new certificate, and turn on Force SSL and HTTP/2.

    Save, and within a few seconds the service is live on HTTPS. NPM renews the certificates automatically before they expire.

    Features I use

    • Access lists. I have a lan-only list that only allows local addresses. Attaching it to a proxy host gives me a nice name on my home network, while the service stays invisible from the internet. It’s great for dashboards like Homarr.
    • Redirection hosts. My bare domain permanently redirects (301) to the www version of this site.
    • Custom upload limits. Under a host’s Advanced tab, client_max_body_size raises the upload size. You need this for big video uploads to Immich or Nextcloud.

    Security tips

    • Only expose what you need. If a service is just for you, keep it LAN-only, or use a VPN.
    • Keep the admin port (81) off the internet.
    • Be careful with HSTS. It tells browsers to insist on HTTPS for a long time, which is hard to undo if you make a mistake. Turn it on only once everything works.
    • Keep NPM updated. It’s the front door to everything behind it.
  • Vaultwarden: self-hosting my password manager

    My passwords live in a vault on my own server, not with a cloud company. Vaultwarden makes that easy: it’s a lightweight, self-hosted server that works with all the official Bitwarden apps and browser extensions.

    What is Vaultwarden?

    Bitwarden is a popular open-source password manager. Vaultwarden is an independent re-implementation of the Bitwarden server, written in Rust. It’s small enough to run happily alongside everything else on a home server, and because it speaks the same language as Bitwarden, you use the normal Bitwarden apps on your phone, desktop and browser. You just point them at your own server.

    • Passwords, passkeys, secure notes and cards, synced across all your devices
    • Family and organisation sharing
    • Sends (secure, expiring shares) and attachments
    • No subscription, and your vault never leaves your house

    My setup

    This is my compose file, saved as /opt/stacks/vaultwarden/compose.yaml:

    services:
      vaultwarden:
        image: vaultwarden/server:latest
        container_name: vaultwarden
        restart: always
        environment:
          DOMAIN: "https://vaultwarden.example.com"
        volumes:
          - ./vw-data:/data
        ports:
          - 88:80
        labels:
          - com.centurylinklabs.watchtower.enable=false
    • DOMAIN must match the public HTTPS address you’ll use. Some features break if it doesn’t.
    • Everything Vaultwarden stores, including the database, attachments and keys, lives in ./vw-data.
    • The Watchtower label stops automatic updates. For something as important as my password vault, I’d rather update deliberately after reading the release notes. See my post on Watchtower.

    HTTPS is not optional

    The Bitwarden web vault uses browser cryptography features that only work over HTTPS, so Vaultwarden needs a proper certificate. I put it behind Nginx Proxy Manager with a Let’s Encrypt certificate, with Websockets Support turned on so changes sync to other devices instantly.

    Lock it down

    • Turn off public sign-ups once your own accounts exist, otherwise anyone who finds your server can create an account on it. Add SIGNUPS_ALLOWED: "false" to the environment section and recreate the container. You can still invite family members from the admin page.
    • Protect the admin page. It’s disabled unless you set ADMIN_TOKEN. If you enable it, use a hashed token. Running docker exec -it vaultwarden /vaultwarden hash generates one.
    • Use two-factor login on your account. An authenticator app is supported out of the box.
    • Use a strong master password. It’s the one password you can’t recover. Vaultwarden can’t reset it for you.

    Back it up

    Your vault is only as safe as your backups. Copy the whole vw-data folder regularly: the database (db.sqlite3), rsa_key.pem, and the attachments and sends folders. Stopping the container first, or using SQLite’s backup command, guarantees a consistent copy. I cover my approach in my backup plan.

    The good news is that Bitwarden apps keep an encrypted copy of your vault on each device. If the server is down for a while, you can still look up your passwords. You just can’t sync changes until it’s back.

    Verdict

    Vaultwarden is one of the most useful things I self-host. It’s light, rock solid, and it means my passwords are under my control while I still get the polished Bitwarden apps everywhere.

  • How Immich replaced Google Photos for my family

    For years every photo my family took went straight to Google Photos. It worked, but it meant paying for storage and handing over every memory to someone else’s servers. These days everything goes to Immich, running on a small server in my house.

    Why I moved

    • Privacy: our photos live on hardware I own, not in an ad company’s data centre.
    • No storage subscription: the only limit is the size of my disks.
    • It’s genuinely as good: automatic phone backup, a timeline, face recognition, map view, shared albums and smart search (“dog on a beach” actually works).

    My setup

    Nothing exotic. Immich runs in Docker alongside the rest of my self-hosted services:

    • Hardware: a small-form-factor PC with an Intel Core i5-6500T and 16 GB of RAM. Face recognition and smart search run on the CPU. It’s slow on the first big import, but fine afterwards.
    • Library: about 25,800 photos and 1,500 videos, roughly 200 GB, shared between 5 family accounts.
    • Remote access: Nginx Proxy Manager with a Let’s Encrypt certificate, so the phone apps back up from anywhere.
    • Updates: Watchtower keeps most of my containers updated, but I exclude Immich’s database and Redis. Immich moves fast and occasionally has breaking changes, so I read the release notes before updating.
    Diagram: family phones connect over the internet to Nginx Proxy Manager, which forwards to the Immich server; Immich uses machine learning, PostgreSQL and Redis, stores photos on disk, and the library is copied to backups.
    How the pieces of my Immich setup connect.

    Quick guide: setting up Immich

    You’ll need a Linux machine with Docker and Docker Compose installed, and plenty of disk space. Always check the official install guide too, as details change between releases.

    1. Download the compose file

    mkdir ~/immich-app && cd ~/immich-app
    wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
    wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env

    2. Edit the .env file

    The important lines are where your photos and database are stored, and the database password (letters and numbers only):

    UPLOAD_LOCATION=./library
    DB_DATA_LOCATION=./postgres
    TZ=Europe/Dublin
    IMMICH_VERSION=release
    DB_PASSWORD=change-me-to-something-long

    Point UPLOAD_LOCATION at your biggest disk. Keep the database on a local SSD, not a network share.

    3. Start it

    docker compose up -d

    Browse to http://<your-server-ip>:2283. The first account you create becomes the admin. Add accounts for the rest of the family from the admin settings.

    4. Install the phone app

    Install Immich from the App Store or Google Play, enter your server address, log in, and turn on backup for the albums you want. New photos then upload automatically, just like Google Photos.

    5. Bring your Google Photos library across

    Export everything with Google Takeout (select only Google Photos, and choose large archive sizes). Then use immich-go, a community tool that understands Takeout’s quirks. It restores the dates, locations and albums that Google stores in separate JSON files:

    Diagram: Google Photos is exported with Google Takeout as zip archives, processed by immich-go to restore dates, GPS and albums, then uploaded to Immich.
    The migration path from Google Photos.
    immich-go upload from-google-photos \
      --server=http://<your-server-ip>:2283 \
      --api-key=YOUR_API_KEY \
      /path/to/takeout-*.zip

    Create the API key in Immich under Account Settings → API Keys. Once everything has imported, give the machine-learning jobs time to finish (Administration → Jobs) before judging face recognition and search.

    6. Access it from outside your home (optional)

    I use Nginx Proxy Manager: a proxy host pointing at port 2283, a Let’s Encrypt certificate, and Websockets Support turned on. Large video uploads also need the upload size limit lifted, so add this under the proxy host’s Advanced tab:

    client_max_body_size 50000M;

    If you’d rather not expose anything to the internet, a VPN such as Tailscale or WireGuard works just as well.

    7. Back it up!

    Self-hosting means you are now the one responsible for your photos. Immich automatically dumps its database into UPLOAD_LOCATION/backups, so back up the whole UPLOAD_LOCATION folder to another disk, and ideally keep one copy offsite. A single drive failure shouldn’t cost you a decade of memories.

    Was it worth it?

    Absolutely. Day to day, the family barely noticed the switch: photos still back up from their phones, and they can still search and share them. The difference is that it’s all ours. If you have a spare PC and some disk space, Immich is one of the best self-hosted projects to start with.

  • Getting started with Docker and Portainer

    Almost everything on my home server, including this blog, my photos (Immich) and my password manager, runs in Docker. Docker is the foundation, and Portainer is the dashboard I use to keep an eye on it all. Here’s how to set both up.

    What is Docker?

    Docker runs applications in containers. A container bundles an app with everything it needs (libraries, runtime, config) so it runs the same way on any machine, isolated from everything else on the server.

    Diagram: one server runs Linux, with Docker Engine on top, which runs isolated containers for Immich, Vaultwarden, Nextcloud, Plex, WordPress and Portainer.
    Each app runs in its own container on the same Docker Engine.

    Why that matters for a home server:

    • No dependency hell: one app needing PHP 8.3 and another needing an older version doesn’t matter, because each brings its own.
    • Easy to try things: spin an app up, and if you don’t like it, delete the container and it’s gone without leaving any mess behind.
    • Everything is a text file: with Docker Compose, each app is described in one compose.yaml, so rebuilding a server is mostly copying folders back.

    Part 1: Installing Docker

    These steps are for Ubuntu or Debian. Docker’s official install docs cover other distros.

    1. Install Docker Engine and Compose

    Docker’s convenience script adds their official repository and installs the engine plus the Compose plugin in one go:

    curl -fsSL https://get.docker.com -o get-docker.sh
    sudo sh get-docker.sh

    Prefer not to pipe scripts from the internet? The docs also show the manual apt repository method, which ends up in the same place.

    2. Run Docker without sudo

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

    If hello-world prints its welcome message, Docker is working. One caution: membership of the docker group is effectively root access to the machine, so only add accounts you trust.

    3. Pick a home for your stacks

    I keep one folder per app under /opt/stacks, each with its own compose.yaml and data folders. It makes backups and moves simple:

    /opt/stacks/
    ├── immich/compose.yaml
    ├── portainer/compose.yaml
    ├── vaultwarden/compose.yaml
    └── wordpress/compose.yaml
    sudo mkdir -p /opt/stacks
    sudo chown $USER:docker /opt/stacks

    What is Portainer?

    Portainer is a web dashboard for Docker. It doesn’t replace Docker; it’s a friendly interface on top of the same engine you use from the command line. It runs as a container itself and talks to Docker through the Docker socket.

    Diagram: your browser connects to Portainer on port 9443; Portainer talks to the Docker Engine through the Docker socket, which manages all containers.
    Portainer controls Docker through the Docker socket.

    What I actually use it for:

    • At-a-glance status: which containers are running, stopped or unhealthy.
    • Logs and console: read a container’s logs or open a shell inside it from the browser, which is handy from a laptop or phone.
    • Quick actions: start, stop, restart, or recreate a container with a fresh image.
    • Housekeeping: finding and removing unused images and volumes that slowly eat disk space.
    • Stacks: paste a compose file into the web editor and deploy it, without touching the terminal.

    Part 2: Installing Portainer

    1. Create the compose file

    This is the exact file I run, saved as /opt/stacks/portainer/compose.yaml:

    services:
      portainer:
        container_name: portainer
        image: portainer/portainer-ce:lts
        restart: unless-stopped
        ports:
          - "9443:9443"
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
          - portainer_data:/data
    
    volumes:
      portainer_data:
        name: portainer_data
    • portainer-ce:lts is the free Community Edition, on the long-term-support release for fewer surprises.
    • 9443 is Portainer’s HTTPS web interface.
    • The docker.sock mount is what lets Portainer control Docker.
    • portainer_data stores Portainer’s own settings and users.

    2. Start it

    cd /opt/stacks/portainer
    docker compose up -d

    3. Create your admin account (quickly!)

    Browse to https://<your-server-ip>:9443. Your browser will warn about the certificate, because Portainer uses a self-signed one, so accept it and continue. Create the admin user straight away: for security, Portainer locks the setup screen if no admin is created within a few minutes. If that happens, run docker restart portainer and try again.

    4. Connect the local environment

    Choose Get Started to use the local Docker environment. You’ll see every container on the machine, including ones you started from the command line. Stacks deployed with docker compose show up too. Portainer marks them as created outside Portainer, with limited control, which suits me fine: I keep the compose files in /opt/stacks as the source of truth and use Portainer for monitoring, logs and restarts.

    A word on security

    Anything with access to docker.sock can control every container, and through them the whole host. So I don’t expose Portainer to the internet, even though everything else goes through my reverse proxy. It’s only reachable on my home network (or over a VPN when I’m away). Use a strong password, and turn on two-factor authentication if you’re on a Portainer edition that supports it.

    What next?

    With Docker and Portainer running, you’re ready to self-host just about anything. A great first project is replacing Google Photos with Immich.