| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Appliance Recipe: Build Your Own Cloud — Replace Every Cloud Service

You're paying twelve different companies a monthly fee to store your data on their computers. Google reads your documents. Dropbox holds your files hostage behind a storage tier. LastPass gets breached and shrugs. Slack charges you per seat to talk to your own team.

What if you just... stopped? One box. One afternoon. Every service you're renting, running on hardware you own, backed by a filesystem that actually protects your data instead of just promising to.

This recipe builds a complete self-hosted cloud stack on kldload with ZFS. File sync, photos, passwords, chat, video calls, git hosting, object storage, monitoring — all of it, running on a Mini PC in your closet, accessible from anywhere through WireGuard, backed by hourly snapshots, and replicated offsite automatically. No subscriptions. No surprise price hikes. No "we've updated our privacy policy" emails ever again.

Why ZFS is the secret weapon here:

  • Per-service datasets — every app gets its own isolated storage with tuned recordsize and quotas. Nextcloud can't eat Immich's disk space.
  • Automatic snapshots — every service gets point-in-time recovery for free. Accidentally delete your photo library? Roll back in seconds.
  • Compression — saves 30-50% on databases, email, and documents without you lifting a finger
  • Replicationsyncoid sends only changed blocks to a backup host. Your data survives a house fire.
  • Boot environments — upgrade the entire stack fearlessly. If it breaks, reboot into the previous environment.
  • Sovereignty — your data never leaves your network unless you choose to replicate it

This recipe is the practical payoff of everything in the masterclass collection. ZFS provides the storage layer (per-service datasets, snapshots, replication — see ZFS Masterclass). WireGuard provides the encrypted backplane (Backplane Masterclass). Docker on ZFS provides the container runtime (Docker & Podman on ZFS). nftables provides the firewall (nftables Masterclass). systemd manages everything (systemd Masterclass). You've read the theory — this page builds the thing.

What you're replacing

Cloud Service Self-hosted Alternative
Google Drive / DropboxNextcloud
Google Docs / Office 365Collabora Online (or OnlyOffice)
Gmail / OutlookKeep it. Self-hosted email is a mass grave of good intentions. You will fight spam filters, blacklists, and DMARC until you question your life choices. Just forward a custom domain to Gmail and move on.
Google PhotosImmich
1Password / LastPassVaultwarden (Bitwarden-compatible)
Notion / ConfluenceOutline or BookStack
Slack / TeamsMatrix (Synapse + Element)
ZoomJitsi Meet
GitHub (private repos)Gitea
Google CalendarNextcloud (CalDAV built in)
VPN (Mullvad, etc)WireGuard (already built in!)
Backblaze / S3MinIO on ZFS
Monitoring (Datadog)Grafana + Prometheus

The subscription math

Google One ($30/yr) + iCloud ($36/yr) + 1Password ($36/yr) + Notion ($96/yr) + Slack ($84/yr) + Zoom ($160/yr) + GitHub Teams ($48/yr) + Backblaze ($70/yr) = $560/year. A Mini PC costs $300 once and runs all of these simultaneously. It pays for itself before summer.

Think of it this way: you're renting twelve apartments when you could buy a house.

But what about reliability?

Fair question. Google has five-nines uptime and a team of thousands. You have a closet and a weekend. But here's the thing: ZFS snapshots + offsite replication + WireGuard give you disaster recovery that most small businesses would kill for. And when Google decides to discontinue your favorite service (pour one out for Google Reader), you're not scrambling for alternatives.

Your uptime won't match Google's. But your data will still be there when Google changes its mind.

Architecture

Here's what you're building. One machine, one reverse proxy, ten services, all sitting on ZFS datasets tuned for their specific workload. Caddy routes everything by hostname. Sanoid snapshots everything hourly. Syncoid replicates everything offsite. Simple.

┌─────────────────────────────────────────────────────────────────────────────┐
│                     kldload Build Your Own Cloud                                │
│                                                                             │
│  WireGuard mesh (encrypted overlay — access from anywhere)                  │
│  ┌────────────────────────────────────────────────────────────────────────┐  │
│  │  Caddy (reverse proxy — automatic HTTPS for all services)             │  │
│  │  :80 / :443 → routes to each service by hostname                      │  │
│  └──┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬────────────┘  │
│     │      │      │      │      │      │      │      │      │               │
│     ▼      ▼      ▼      ▼      ▼      ▼      ▼      ▼      ▼               │
│  nextcloud immich vault  gitea matrix jitsi  mail  minio grafana            │
│  :8080    :2283  :8222  :3000 :8008  :8443  :25    :9000 :3000              │
│                                              :587                           │
│                                              :993                           │
│  ┌────────────────────────────────────────────────────────────────────────┐  │
│  │  ZFS Pool (rpool)                                                     │  │
│  │  ├── rpool/services/nextcloud     recordsize=1M    compression=lz4    │  │
│  │  ├── rpool/services/immich        recordsize=1M    compression=off    │  │
│  │  ├── rpool/services/vaultwarden   recordsize=8K    compression=lz4    │  │
│  │  ├── rpool/services/gitea         recordsize=8K    compression=lz4    │  │
│  │  ├── rpool/services/matrix        recordsize=8K    compression=lz4    │  │
│  │  ├── rpool/services/minio         recordsize=1M    compression=off    │  │
│  │  ├── rpool/services/grafana       recordsize=8K    compression=lz4    │  │
│  │  └── rpool/services/caddy         recordsize=16K   compression=lz4    │  │
│  └────────────────────────────────────────────────────────────────────────┘  │
│                                                                             │
│  Sanoid snapshots → hourly/daily/monthly retention                          │
│  Syncoid replication → backup host over WireGuard                           │
│  nftables firewall → only 80/443/51820 exposed                              │
└─────────────────────────────────────────────────────────────────────────────┘

Step 1: Install kldload

Debian is the pick here. It's boring in the best way — stable, well-supported by every self-hosted project, and the APT ecosystem means you're never hunting for packages. CentOS or Rocky work too if you prefer RPM, but the Docker ecosystem leans Debian.

cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=cloud
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NET_METHOD=dhcp
EOF
kldload-install-target --config /tmp/answers.env

The dataset layout is the most important decision in this entire recipe. One dataset per service means: you can snapshot Nextcloud without snapshotting Immich, set recordsize=8k on the database and 128k on file storage, quota each service independently, and replicate only the services you care about to your offsite backup. If you skip this and dump everything in one directory, you lose every ZFS advantage. This takes 30 seconds to set up and pays dividends forever.

Step 2: ZFS dataset layout

Why one dataset per service?

Every service gets its own ZFS dataset. Not because we're neat freaks — because it gives you superpowers. Independent snapshots mean you can roll back Nextcloud without touching Immich. Independent quotas mean a runaway Gitea repo can't eat your photo storage. And tuned recordsize means PostgreSQL gets 8K blocks (matching its page size) while Nextcloud gets 1M blocks (optimized for large files). Already-compressed media like photos and video skip ZFS compression entirely — no point compressing a JPEG.

It's like giving every tenant their own apartment instead of one big shared house. They can't flood each other's bathrooms.
# Parent dataset — not mounted directly
zfs create -o canmount=off -o mountpoint=none rpool/services

# Nextcloud — large files (documents, synced folders)
zfs create -o mountpoint=/srv/nextcloud -o recordsize=1M \
    -o compression=lz4 -o atime=off rpool/services/nextcloud

# Nextcloud database (PostgreSQL)
zfs create -o mountpoint=/srv/nextcloud-db -o recordsize=8K \
    -o compression=lz4 -o logbias=throughput rpool/services/nextcloud-db

# Immich — photos and video (already compressed, skip ZFS compression)
zfs create -o mountpoint=/srv/immich -o recordsize=1M \
    -o compression=off -o atime=off rpool/services/immich

# Immich database
zfs create -o mountpoint=/srv/immich-db -o recordsize=8K \
    -o compression=lz4 -o logbias=throughput rpool/services/immich-db

# Vaultwarden — small SQLite database
zfs create -o mountpoint=/srv/vaultwarden -o recordsize=8K \
    -o compression=lz4 rpool/services/vaultwarden

# Gitea — git repos and database
zfs create -o mountpoint=/srv/gitea -o recordsize=8K \
    -o compression=lz4 rpool/services/gitea

# Matrix Synapse — chat history database
zfs create -o mountpoint=/srv/matrix -o recordsize=8K \
    -o compression=lz4 rpool/services/matrix


# MinIO — S3-compatible object storage
zfs create -o mountpoint=/srv/minio -o recordsize=1M \
    -o compression=off -o atime=off rpool/services/minio

# Grafana + Prometheus
zfs create -o mountpoint=/srv/grafana -o recordsize=8K \
    -o compression=lz4 rpool/services/grafana

# Caddy — TLS certs and config
zfs create -o mountpoint=/srv/caddy -o recordsize=16K \
    -o compression=lz4 rpool/services/caddy

# Verify the layout
zfs list -r rpool/services -o name,mountpoint,recordsize,compression

Step 3: Reverse proxy — Caddy

Every service runs on its own port. Nobody wants to remember 192.168.1.100:2283 is photos and 192.168.1.100:8222 is passwords. Caddy fixes this — it sits in front of everything, routes immich.home.lab to port 2283, vaultwarden.home.lab to port 8222, and handles TLS automatically. One config file. No nginx.conf nightmares. No Apache virtual hosts from 2004.

# Install Caddy
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
    gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
    tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy
cat > /srv/caddy/Caddyfile << 'CADDY'
# ─── Global options ─────────────────────────────────────────
{
    # For LAN-only: use internal CA (no Let's Encrypt)
    # Remove this block if you have a public domain
    local_certs
    auto_https disable_redirects
}

# ─── Nextcloud ──────────────────────────────────────────────
nextcloud.home.lab {
    reverse_proxy localhost:8080
    # Required headers for Nextcloud
    header {
        Strict-Transport-Security "max-age=31536000"
    }
    request_body {
        max_size 10G
    }
}

# ─── Immich (photos) ───────────────────────────────────────
immich.home.lab {
    reverse_proxy localhost:2283
    request_body {
        max_size 50G
    }
}

# ─── Vaultwarden (passwords) ──────────────────────────────
vaultwarden.home.lab {
    reverse_proxy localhost:8222
    # WebSocket support for live sync
    reverse_proxy /notifications/hub localhost:3012
}

# ─── Gitea (git hosting) ──────────────────────────────────
gitea.home.lab {
    reverse_proxy localhost:3000
}

# ─── Matrix (chat) ────────────────────────────────────────
matrix.home.lab {
    reverse_proxy /_matrix/* localhost:8008
    reverse_proxy /_synapse/* localhost:8008
}

# ─── Element (Matrix web client) ─────────────────────────
element.home.lab {
    reverse_proxy localhost:8088
}

# ─── Jitsi (video calls) ─────────────────────────────────
jitsi.home.lab {
    reverse_proxy localhost:8443
}


# ─── MinIO Console ───────────────────────────────────────
minio.home.lab {
    reverse_proxy localhost:9001
}

# ─── MinIO API (S3-compatible) ───────────────────────────
s3.home.lab {
    reverse_proxy localhost:9000
}

# ─── Grafana (monitoring) ────────────────────────────────
grafana.home.lab {
    reverse_proxy localhost:3001
}
CADDY

# Point Caddy at the config
mkdir -p /etc/caddy
ln -sf /srv/caddy/Caddyfile /etc/caddy/Caddyfile
systemctl enable --now caddy

For LAN use, add all the *.home.lab entries to your router's DNS or /etc/hosts on each client, pointing at the server's IP. For public access, replace .home.lab with your real domain and remove the local_certs block — Caddy will fetch Let's Encrypt certificates automatically.


Step 4: Docker Compose — the entire stack

This is the fun part. One docker-compose.yml to rule them all — ten services, one file, one docker compose up -d. Every container's data volume maps to its own ZFS dataset, so snapshots, compression, and quotas all work transparently. The containers don't even know they're on ZFS. They just think they have really, really reliable storage.

Why Docker and not Podman?

kldload ships with Podman, and it's great for single containers. But for a 15-container stack with shared networks, dependency ordering, and a single config file, Docker Compose is still the standard. Every self-hosted project publishes a docker-compose.yml. None of them publish a Podman quadlet. When that changes, we'll update this recipe. Until then, pragmatism wins.

# Install Docker (kldload ships with podman, but Docker Compose
# is the standard for multi-service stacks)
apt install -y docker.io docker-compose-v2

# Configure Docker to use ZFS storage driver
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'EOF'
{
  "storage-driver": "overlay2",
  "log-driver": "journald",
  "default-address-pools": [
    {"base": "172.20.0.0/14", "size": 24}
  ]
}
EOF
systemctl enable --now docker

# Generate strong secrets for all services (run once)
mkdir -p /srv/homelab
cat > /srv/homelab/.env << EOF
NC_DB_PASS=$(openssl rand -base64 32)
NC_REDIS_PASS=$(openssl rand -base64 32)
NC_ADMIN_PASS=$(openssl rand -base64 32)
IMMICH_DB_PASS=$(openssl rand -base64 32)
VAULT_ADMIN_TOKEN=$(openssl rand -base64 32)
GITEA_DB_PASS=$(openssl rand -base64 32)
MATRIX_DB_PASS=$(openssl rand -base64 32)
MINIO_ROOT_PASSWORD=$(openssl rand -base64 32)
GRAFANA_ADMIN_PASS=$(openssl rand -base64 32)
EOF
chmod 600 /srv/homelab/.env
echo "Secrets generated — saved to /srv/homelab/.env"
cat > /srv/homelab/docker-compose.yml << 'COMPOSE'
# ═══════════════════════════════════════════════════════════════
# kldload Build Your Own Cloud — complete self-hosted stack
# ═══════════════════════════════════════════════════════════════

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

# ─── Nextcloud ──────────────────────────────────────────────
services:
  nextcloud-db:
    image: postgres:16-alpine
    container_name: nextcloud-db
    restart: unless-stopped
    networks: [backend]
    volumes:
      - /srv/nextcloud-db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: ${NC_DB_PASS:-changeme-nextcloud-db}

  nextcloud-redis:
    image: redis:7-alpine
    container_name: nextcloud-redis
    restart: unless-stopped
    networks: [backend]
    command: redis-server --requirepass ${NC_REDIS_PASS:-changeme-redis}

  nextcloud:
    image: nextcloud:29-apache
    container_name: nextcloud
    restart: unless-stopped
    depends_on: [nextcloud-db, nextcloud-redis]
    networks: [frontend, backend]
    ports:
      - "8080:80"
    volumes:
      - /srv/nextcloud:/var/www/html
    environment:
      POSTGRES_HOST: nextcloud-db
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: ${NC_DB_PASS:-changeme-nextcloud-db}
      REDIS_HOST: nextcloud-redis
      REDIS_HOST_PASSWORD: ${NC_REDIS_PASS:-changeme-redis}
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${NC_ADMIN_PASS:-changeme-nextcloud}
      NEXTCLOUD_TRUSTED_DOMAINS: nextcloud.home.lab
      OVERWRITEPROTOCOL: https

  # Collabora for document editing inside Nextcloud
  collabora:
    image: collabora/code:latest
    container_name: collabora
    restart: unless-stopped
    networks: [frontend, backend]
    ports:
      - "9980:9980"
    environment:
      aliasgroup1: https://nextcloud.home.lab:443
      extra_params: --o:ssl.enable=false --o:ssl.termination=true
    cap_add:
      - MKNOD

  # ─── Immich (photo management) ────────────────────────────
  immich-db:
    image: tensorchord/pgvecto-rs:pg16-v0.2.1
    container_name: immich-db
    restart: unless-stopped
    networks: [backend]
    volumes:
      - /srv/immich-db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: immich
      POSTGRES_USER: immich
      POSTGRES_PASSWORD: ${IMMICH_DB_PASS:-changeme-immich-db}
      POSTGRES_INITDB_ARGS: '--data-checksums'

  immich-redis:
    image: redis:7-alpine
    container_name: immich-redis
    restart: unless-stopped
    networks: [backend]

  immich-server:
    image: ghcr.io/immich-app/immich-server:release
    container_name: immich-server
    restart: unless-stopped
    depends_on: [immich-db, immich-redis]
    networks: [frontend, backend]
    ports:
      - "2283:2283"
    volumes:
      - /srv/immich:/usr/src/app/upload
    environment:
      DB_HOSTNAME: immich-db
      DB_DATABASE_NAME: immich
      DB_USERNAME: immich
      DB_PASSWORD: ${IMMICH_DB_PASS:-changeme-immich-db}
      REDIS_HOSTNAME: immich-redis

  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:release
    container_name: immich-ml
    restart: unless-stopped
    networks: [backend]
    volumes:
      - /srv/immich/model-cache:/cache

  # ─── Vaultwarden (password manager) ──────────────────────
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    networks: [frontend]
    ports:
      - "8222:80"
      - "3012:3012"
    volumes:
      - /srv/vaultwarden:/data
    environment:
      DOMAIN: https://vaultwarden.home.lab
      SIGNUPS_ALLOWED: "true"
      WEBSOCKET_ENABLED: "true"
      ADMIN_TOKEN: ${VW_ADMIN_TOKEN:-changeme-vaultwarden-admin}

  # ─── Gitea (git hosting) ─────────────────────────────────
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: unless-stopped
    networks: [frontend]
    ports:
      - "3000:3000"
      - "2222:22"
    volumes:
      - /srv/gitea:/data
    environment:
      GITEA__database__DB_TYPE: sqlite3
      GITEA__server__DOMAIN: gitea.home.lab
      GITEA__server__ROOT_URL: https://gitea.home.lab
      GITEA__server__SSH_DOMAIN: gitea.home.lab
      GITEA__server__SSH_PORT: 2222

  # ─── Matrix Synapse (chat server) ───────────────────────
  matrix:
    image: matrixdotorg/synapse:latest
    container_name: matrix
    restart: unless-stopped
    networks: [frontend, backend]
    ports:
      - "8008:8008"
    volumes:
      - /srv/matrix:/data
    environment:
      SYNAPSE_SERVER_NAME: matrix.home.lab
      SYNAPSE_REPORT_STATS: "no"

  # Element web client for Matrix
  element:
    image: vectorim/element-web:latest
    container_name: element
    restart: unless-stopped
    networks: [frontend]
    ports:
      - "8088:80"
    volumes:
      - /srv/matrix/element-config.json:/app/config.json:ro

  # ─── Jitsi Meet (video calls) ───────────────────────────
  jitsi-web:
    image: jitsi/web:stable
    container_name: jitsi-web
    restart: unless-stopped
    networks: [frontend]
    ports:
      - "8443:443"
    environment:
      ENABLE_LETSENCRYPT: "0"
      PUBLIC_URL: https://jitsi.home.lab
      TZ: UTC

  jitsi-prosody:
    image: jitsi/prosody:stable
    container_name: jitsi-prosody
    restart: unless-stopped
    networks: [backend]
    environment:
      PUBLIC_URL: https://jitsi.home.lab

  jitsi-jicofo:
    image: jitsi/jicofo:stable
    container_name: jitsi-jicofo
    restart: unless-stopped
    depends_on: [jitsi-prosody]
    networks: [backend]

  jitsi-jvb:
    image: jitsi/jvb:stable
    container_name: jitsi-jvb
    restart: unless-stopped
    depends_on: [jitsi-prosody]
    networks: [frontend, backend]
    ports:
      - "10000:10000/udp"

  # ─── MinIO (S3-compatible storage) ─────────────────────
  minio:
    image: minio/minio:latest
    container_name: minio
    restart: unless-stopped
    networks: [frontend]
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - /srv/minio:/data
    environment:
      MINIO_ROOT_USER: ${MINIO_USER:-admin}
      MINIO_ROOT_PASSWORD: ${MINIO_PASS:-changeme-minio}
    command: server /data --console-address ":9001"

  # ─── Monitoring: Prometheus ────────────────────────────
  prometheus:
    image: prom/prometheus:latest
    container_name: prometheus
    restart: unless-stopped
    networks: [backend]
    ports:
      - "9090:9090"
    volumes:
      - /srv/grafana/prometheus:/prometheus
      - /srv/grafana/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=90d'

  # ─── Monitoring: Grafana ──────────────────────────────
  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    restart: unless-stopped
    depends_on: [prometheus]
    networks: [frontend, backend]
    ports:
      - "3001:3000"
    volumes:
      - /srv/grafana/data:/var/lib/grafana
    environment:
      GF_SERVER_ROOT_URL: https://grafana.home.lab
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASS:-changeme-grafana}

  # ─── Monitoring: Node Exporter ────────────────────────
  node-exporter:
    image: prom/node-exporter:latest
    container_name: node-exporter
    restart: unless-stopped
    networks: [backend]
    pid: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--path.rootfs=/rootfs'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'

  # ─── Monitoring: cAdvisor (container metrics) ─────────
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    container_name: cadvisor
    restart: unless-stopped
    networks: [backend]
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
    privileged: true
COMPOSE

Environment file

cat > /srv/homelab/.env << 'EOF'
# ── Change ALL of these before first boot ──
NC_DB_PASS=your-strong-nextcloud-db-password
NC_REDIS_PASS=your-strong-redis-password
NC_ADMIN_PASS=your-strong-nextcloud-admin-password
IMMICH_DB_PASS=your-strong-immich-db-password
VW_ADMIN_TOKEN=your-strong-vaultwarden-admin-token
MINIO_USER=admin
MINIO_PASS=your-strong-minio-password
GRAFANA_PASS=your-strong-grafana-password
EOF

chmod 600 /srv/homelab/.env

Bring it all up

cd /srv/homelab && docker compose up -d

# Watch logs to verify everything starts clean
docker compose logs -f --tail=50

Step 5: Service configuration

Everything is running, but most services need a one-time setup. This is the "clicking through wizards" phase — the last time you'll touch most of these configs.

Nextcloud + Collabora

Nextcloud is the centerpiece — your Dropbox/Google Drive replacement. Collabora gives it Google Docs-style document editing right in the browser. After first start, access https://nextcloud.home.lab and log in with your admin credentials:

# Install the Nextcloud Office app via occ
docker exec -u www-data nextcloud php occ app:install richdocuments

# Point Nextcloud at the Collabora server
docker exec -u www-data nextcloud php occ config:app:set richdocuments \
    wopi_url --value="https://nextcloud.home.lab:9980"

# Enable CalDAV/CardDAV for calendar and contacts
docker exec -u www-data nextcloud php occ app:install calendar
docker exec -u www-data nextcloud php occ app:install contacts

# Set background jobs to cron
docker exec -u www-data nextcloud php occ background:cron

# Add cron job for Nextcloud
cat > /etc/cron.d/nextcloud << 'EOF'
*/5 * * * * root docker exec -u www-data nextcloud php -f /var/www/html/cron.php
EOF

Immich (photo management)

This is the one that makes people's jaws drop. Access https://immich.home.lab and create your first user. Immich looks and feels like Google Photos — auto-detection, facial recognition, maps, timeline view — except your photos live on your ZFS pool instead of Google's data centers. Upload your entire photo library via the web UI or the Immich mobile app (iOS and Android). It even does automatic phone backup, just like Google Photos, except without the "we trained an AI on your family photos" part.

Vaultwarden (password manager)

LastPass got breached. 1Password wants $5/month/person forever. Vaultwarden is a lightweight Bitwarden-compatible server that uses every official Bitwarden client (browser extension, mobile app, desktop app) — just point them at your server URL. Access https://vaultwarden.home.lab and create your account. Then lock it down:

# Disable signups after creating your account
docker compose -f /srv/homelab/docker-compose.yml exec vaultwarden \
    sh -c 'sed -i "s/SIGNUPS_ALLOWED=true/SIGNUPS_ALLOWED=false/" /data/.env'
docker compose -f /srv/homelab/docker-compose.yml restart vaultwarden

Gitea (git hosting)

Access https://gitea.home.lab and complete the initial setup wizard. Clone over SSH on port 2222:

# Clone a repo via SSH
git clone ssh://git@gitea.home.lab:2222/youruser/yourrepo.git

# Or configure your ~/.ssh/config
cat >> ~/.ssh/config << 'EOF'
Host gitea.home.lab
    Port 2222
    User git
EOF

Matrix Synapse + Element

Generate the Synapse config, then create the Element config:

# Generate Synapse homeserver config
docker run --rm \
    -v /srv/matrix:/data \
    -e SYNAPSE_SERVER_NAME=matrix.home.lab \
    -e SYNAPSE_REPORT_STATS=no \
    matrixdotorg/synapse:latest generate

# Create Element web config
cat > /srv/matrix/element-config.json << 'EOF'
{
    "default_server_config": {
        "m.homeserver": {
            "base_url": "https://matrix.home.lab",
            "server_name": "matrix.home.lab"
        }
    },
    "brand": "Home Lab Chat",
    "default_theme": "dark"
}
EOF

# Restart Matrix and Element
docker compose -f /srv/homelab/docker-compose.yml restart matrix element

# Create your first user
docker exec -it matrix register_new_matrix_user \
    http://localhost:8008 -c /data/homeserver.yaml -a

Jitsi Meet (video calls)

No accounts. No "your meeting has reached the 40-minute limit." No "please download our desktop app." Jitsi works out of the box at https://jitsi.home.lab. Create a room name, share the link, done. For WireGuard remote access, ensure UDP port 10000 is forwarded to the server (video calls need direct UDP for quality).

MinIO (S3-compatible storage)

Access the MinIO console at https://minio.home.lab. Create buckets and use any S3-compatible client:

# Install the MinIO client
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc
chmod +x /usr/local/bin/mc

# Configure the client
mc alias set homelab https://s3.home.lab admin your-strong-minio-password

# Create a bucket and upload files
mc mb homelab/backups
mc cp /path/to/important-file.tar.gz homelab/backups/

# Use as a backup target for any S3-compatible tool
# restic, duplicity, rclone all support S3

Grafana + Prometheus

# Create the Prometheus config
cat > /srv/grafana/prometheus.yml << 'EOF'
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'node'
    static_configs:
      - targets: ['node-exporter:9100']

  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']

  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']
EOF

# Restart prometheus to pick up the config
docker compose -f /srv/homelab/docker-compose.yml restart prometheus

Access Grafana at https://grafana.home.lab, add Prometheus as a data source (http://prometheus:9090), and import dashboards:

  • Node Exporter Full (dashboard ID: 1860) — CPU, memory, disk, network
  • Docker Container Monitoring (dashboard ID: 893) — per-container resource usage
  • ZFS pool health — custom dashboard using node_exporter ZFS metrics

This is where ZFS justifies the entire setup. Your backup strategy isn't "install a backup tool and configure it" — it's zfs snapshot + syncoid. Snapshots are atomic and instant. Replication is incremental — only changed blocks transfer. Combined with WireGuard for the transport, you get encrypted offsite backups of every service, every hour, with zero application-level backup configuration. Nextcloud doesn't know it's being backed up. PostgreSQL doesn't know. ZFS handles it at the block level, below all of them. See the ZFS Masterclass for send/receive deep dive.

Step 6: Backup strategy

A self-hosted cloud without backups is just a more complicated way to lose your data. This is the part that separates "I run Nextcloud" from "I run infrastructure." Sanoid takes snapshots automatically. Syncoid replicates them offsite. If your primary server catches fire, you boot the backup, import the pool, and every service comes back up exactly where it was. Not "we'll restore from last night's tarball" — exactly where it was, down to the last database transaction.

# Install sanoid (snapshot management) — already included in kldload
# Configure snapshot policy for all service datasets
cat > /etc/sanoid/sanoid.conf << 'SANOID'
[rpool/services]
    use_template = services
    recursive = yes

[template_services]
    # Keep 24 hourly, 30 daily, 12 monthly snapshots
    hourly = 24
    daily = 30
    monthly = 12
    yearly = 0
    autosnap = yes
    autoprune = yes
SANOID

# Enable the sanoid timer (runs every 15 minutes)
systemctl enable --now sanoid.timer

# Verify snapshots are being created
sanoid --cron --verbose
zfs list -t snapshot -r rpool/services

Offsite replication with syncoid

# Replicate all service data to a backup host over WireGuard
# The backup host needs: kldload installed, WireGuard connected, SSH key auth
cat > /etc/cron.d/homelab-replicate << 'EOF'
# Replicate all services every 6 hours
0 */6 * * * root syncoid -r --no-sync-snap rpool/services 10.200.0.2:rpool/services 2>&1 | logger -t homelab-replicate
EOF

Disaster recovery

# If the primary server dies:
# 1. Boot the backup server from kldload USB (or it's already running)
# 2. The replicated pool already has all service data
# 3. Install Docker and the compose file:
apt install -y docker.io docker-compose-v2
cp /path/to/backup/docker-compose.yml /srv/homelab/
cp /path/to/backup/.env /srv/homelab/

# 4. Start the stack — every service picks up where it left off
cd /srv/homelab && docker compose up -d

# Time from disaster to recovery: ~15 minutes
# Data loss window: at most 6 hours (last replication interval)

Step 7: Security

You're running ten services with your personal data on hardware connected to the internet. Security isn't optional — it's the difference between "self-hosted cloud" and "free data buffet for script kiddies." Three layers: WireGuard (network), nftables (firewall), fail2ban (brute-force protection).

WireGuard for remote access

WireGuard is already built into kldload — you don't need to install anything. Set up a mesh so you can access all services from your phone, laptop, or any remote location. The beautiful part: once WireGuard is up, every *.home.lab address just works, wherever you are in the world.

# Generate keys (if not already done during install)
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
chmod 600 /etc/wireguard/private.key

cat > /etc/wireguard/wg0.conf << EOF
[Interface]
Address = 10.200.0.1/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/private.key)
# DNS for .home.lab resolution
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE

# Your phone
[Peer]
PublicKey = <PHONE_PUBLIC_KEY>
AllowedIPs = 10.200.0.10/32

# Your laptop
[Peer]
PublicKey = <LAPTOP_PUBLIC_KEY>
AllowedIPs = 10.200.0.11/32

# Backup server
[Peer]
PublicKey = <BACKUP_PUBLIC_KEY>
AllowedIPs = 10.200.0.2/32
Endpoint = backup.example.com:51820
PersistentKeepalive = 25
EOF

systemctl enable --now wg-quick@wg0

nftables firewall

cat > /etc/nftables.conf << 'NFTEOF'
#!/usr/sbin/nft -f
flush ruleset

table inet homelab {
    chain input {
        type filter hook input priority 0; policy drop;

        # Loopback
        iif lo accept

        # Established connections
        ct state established,related accept

        # ICMP (ping)
        ip protocol icmp accept
        ip6 nexthdr icmpv6 accept

        # SSH (LAN + WireGuard only)
        ip saddr { 10.0.0.0/8, 192.168.0.0/16, 10.200.0.0/24 } tcp dport 22 accept

        # HTTP/HTTPS (Caddy)
        tcp dport { 80, 443 } accept

        # WireGuard
        udp dport 51820 accept

        # Jitsi video (UDP)
        udp dport 10000 accept

        # Mail (if running public email)
        # tcp dport { 25, 587, 993 } accept

        # Drop everything else
        log prefix "nft-dropped: " limit rate 5/minute
        drop
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        ct state established,related accept
        # Allow WireGuard clients to reach services
        iifname "wg0" accept
    }

    chain output {
        type filter hook output priority 0; policy accept;
    }
}
NFTEOF

nft -f /etc/nftables.conf
systemctl enable nftables

fail2ban

apt install -y fail2ban

cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
banaction = nftables-multiport

[sshd]
enabled = true

[nextcloud]
enabled = true
port = 80,443
filter = nextcloud
logpath = /srv/nextcloud/data/nextcloud.log
maxretry = 5

[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /srv/vaultwarden/vaultwarden.log
maxretry = 5
EOF

# Nextcloud filter
cat > /etc/fail2ban/filter.d/nextcloud.conf << 'EOF'
[Definition]
failregex = ^.*Login failed: '.*' \(Remote IP: ''\).*$
ignoreregex =
EOF

# Vaultwarden filter
cat > /etc/fail2ban/filter.d/vaultwarden.conf << 'EOF'
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: \..*$
ignoreregex =
EOF

systemctl enable --now fail2ban

Step 8: Monitoring & alerting

You built a cloud. Now you need to know when it's unhappy before you notice your photos aren't syncing. Grafana, Prometheus, node_exporter, and cAdvisor are already running from the Docker Compose stack — you just need a health check script and some alerts.

ZFS health alerts

cat > /usr/local/bin/homelab-health << 'SCRIPT'
#!/bin/bash
# Quick health check for all services

echo "=== ZFS Pool Health ==="
zpool status -x

echo ""
echo "=== Dataset Usage ==="
zfs list -r rpool/services -o name,used,avail,compressratio,quota -S used

echo ""
echo "=== Snapshot Counts ==="
for ds in $(zfs list -r -H -o name rpool/services | tail -n +2); do
    count=$(zfs list -t snapshot -r -H "$ds" | wc -l)
    printf "%-40s %d snapshots\n" "$ds" "$count"
done

echo ""
echo "=== Docker Container Status ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | sort

echo ""
echo "=== Disk I/O (last 5 seconds) ==="
zpool iostat rpool 5 1

echo ""
echo "=== WireGuard Peers ==="
wg show wg0 2>/dev/null || echo "WireGuard not active"

echo ""
echo "=== Memory Usage ==="
free -h

echo ""
echo "=== Top Memory Consumers ==="
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}" | sort -k2 -hr | head -15
SCRIPT
chmod +x /usr/local/bin/homelab-health

Automated alerts

# ZFS pool health check — alert if degraded
cat > /etc/cron.d/homelab-alerts << 'EOF'
# Check ZFS health every 5 minutes
*/5 * * * * root zpool status -x | grep -q "all pools are healthy" || \
    echo "ZFS POOL DEGRADED on $(hostname)" | \
    mail -s "ZFS ALERT: $(hostname)" admin@home.lab 2>/dev/null; \
    logger -t zfs-alert "Pool degraded!"

# Check disk usage — alert at 80%
0 * * * * root zpool list -H -o name,cap | while read pool pct; do \
    num=${pct%%%}; [ "$num" -gt 80 ] && \
    logger -t disk-alert "$pool at ${pct} capacity"; done

# Check all containers are running
*/10 * * * * root docker compose -f /srv/homelab/docker-compose.yml ps --format json | \
    python3 -c "import sys,json; [print(f'DOWN: {c[\"Name\"]}') for c in json.load(sys.stdin) if c['State']!='running']" 2>/dev/null | \
    while read line; do logger -t container-alert "$line"; done
EOF

The cost comparison below is hardware vs subscriptions — not a kldload pricing page. kldload is free. The point: the one-time hardware cost is less than a year of cloud subscriptions. Your data stays on your hardware, on your network, backed up to your offsite location over your WireGuard tunnel. No terms of service changes. No price increases. No "this service is being discontinued." You own it.

The bill

Item Cost Notes
Mini PC (Intel N100, 16GB RAM)~$200Beelink, MinisForum, etc.
2x 4TB NVMe SSD (ZFS mirror)~$300Crucial P3 Plus or similar
Total hardware$500One-time cost. Not per month. Not per seat. Once.
Electricity (~35W idle)~$3/moLess than a single cloud subscription
kldloadFreeForever
All softwareFreeOpen source, no license keys, no "contact sales"

$500 once and $3/month in electricity. That's the total cost of complete ownership of your data, on storage that checksums every block, snapshots every hour, and replicates offsite automatically. Compare that to $560/year in subscriptions — subscriptions that can raise prices, change terms, get breached, or shut down at any time. Your cloud can't enshittify because you own it.


Appendix: Local DNS setup

For *.home.lab resolution on your LAN, add entries to your router's DNS or run a simple dnsmasq instance:

# Option A: /etc/hosts on each client (quick and dirty)
cat >> /etc/hosts << 'EOF'
192.168.1.100  nextcloud.home.lab
192.168.1.100  immich.home.lab
192.168.1.100  vaultwarden.home.lab
192.168.1.100  gitea.home.lab
192.168.1.100  matrix.home.lab
192.168.1.100  element.home.lab
192.168.1.100  jitsi.home.lab
192.168.1.100  minio.home.lab
192.168.1.100  s3.home.lab
192.168.1.100  grafana.home.lab
EOF

# Option B: dnsmasq on the server (all clients use server as DNS)
apt install -y dnsmasq
cat > /etc/dnsmasq.d/homelab.conf << 'EOF'
address=/.home.lab/192.168.1.100
EOF
systemctl restart dnsmasq
# Then set your router's DHCP to hand out 192.168.1.100 as DNS