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
- Replication —
syncoidsends 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
What you're replacing
| Cloud Service | Self-hosted Alternative |
|---|---|
| Google Drive / Dropbox | Nextcloud |
| Google Docs / Office 365 | Collabora Online (or OnlyOffice) |
| Gmail / Outlook | Keep 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 Photos | Immich |
| 1Password / LastPass | Vaultwarden (Bitwarden-compatible) |
| Notion / Confluence | Outline or BookStack |
| Slack / Teams | Matrix (Synapse + Element) |
| Zoom | Jitsi Meet |
| GitHub (private repos) | Gitea |
| Google Calendar | Nextcloud (CalDAV built in) |
| VPN (Mullvad, etc) | WireGuard (already built in!) |
| Backblaze / S3 | MinIO 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.
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.
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
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.
# 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
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 bill
| Item | Cost | Notes |
|---|---|---|
| Mini PC (Intel N100, 16GB RAM) | ~$200 | Beelink, MinisForum, etc. |
| 2x 4TB NVMe SSD (ZFS mirror) | ~$300 | Crucial P3 Plus or similar |
| Total hardware | $500 | One-time cost. Not per month. Not per seat. Once. |
| Electricity (~35W idle) | ~$3/mo | Less than a single cloud subscription |
| kldload | Free | Forever |
| All software | Free | Open 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