Common Self-Hosted Mistakes and How to Avoid Them
The Backup Void: Why Your Data's Already Gone
You've spent months configuring your Jellyfin instance, tuning your Nextcloud deployment, organizing 50TB of media across ZFS pools. Then your Docker host corrupts on a bad update, your SSD fails without warning, or a ransomware variant encrypts everything. You realize you have zero recovery plan. By then, it's too late—this is the most common self-hosted mistake because it feels like overhead until the day it isn't.
The typical homelab doesn't fail because the services break; it fails because there's no backup strategy. Most people tell themselves "I'll set it up later" and never do.
The 3-2-1 Rule Actually Works
Keep three copies of critical data: two on different media types, one offsite. On my T5810 workstation running Ubuntu 24.04.1 LTS with 24GB RAM, I use this layered approach:
- Copy 1: Live ZFS dataset (daily snapshots, 7-day retention)
- Copy 2: External NAS via rsync (weekly, kept for 1 month)
- Copy 3: B2 cloud storage via Duplicati (monthly encrypted backup, 6-month retention)
Here's the rsync job that handles Copy 2—run this from cron every Sunday at 2 AM:
#!/bin/bash
# Weekly NAS backup - /etc/cron.d/homelab-nas-backup
RSYNC_LOG="/var/log/homelab-rsync.log"
SOURCE="/mnt/tank/containers"
DEST="rsync://[email protected]/backups/$(hostname)"
/usr/bin/rsync -av \
--delete \
--log-file="$RSYNC_LOG" \
--bwlimit=50000 \
"$SOURCE/" "$DEST/" \
2>&1 | logger -t homelab-backup
# Alert if rsync fails
if [ $? -ne 0 ]; then
echo "Backup failed on $(hostname)" | mail -s "BACKUP ALERT" [email protected]
fi
Gotcha: Don't use the same backup drive you use for scratch space. I learned this when a power surge took out both my working pool and backup simultaneously. Physically separate media types—NAS, external USB, cloud—or you're just creating illusions of redundancy.
Test Your Restores Monthly
A backup you've never restored from is just expensive delusion. Every 30 days, pick a random file from your offsite copy and verify you can actually recover it. Document the restore time. If restore takes 8 hours for a 500GB dataset, you need to know that before you're in crisis mode.
Permission Hell: The Gradual Descent Into Root Everything
You start a Docker container, it can't write to a volume. Quick fix: `chmod 777` the directory. Later, your backup script needs access to that same mount. Another `chmod 777`. Six months in, your entire `/mnt` tree is world-writable, your containers run as root, and you've created surface area for any compromised container to wreck your entire system.
This is how self-hosted mistakes compound: small permission shortcuts become architectural debt.
Use Explicit UID/GID Mapping
Create a dedicated Docker user and use static UID/GID across all containers. Don't rely on the default `docker` group (GID 999 varies by installation):
# Create isolated user for all container data ownership
useradd -r -u 5000 -s /bin/false dockerdata
# Create directory structure with explicit permissions
mkdir -p /mnt/tank/{containers,backups,media}
chown -R 5000:5000 /mnt/tank/
chmod 750 /mnt/tank/*
# Verify ownership
ls -ln /mnt/tank/
# drwxr-x--- 5 5000 5000 4096 Jan 15 10:22 containers
Now in your docker-compose.yml, explicitly declare this user:
version: '3.8'
services:
jellyfin:
image: jellyfin/jellyfin:10.8.13
user: "5000:5000"
volumes:
- /mnt/tank/containers/jellyfin/config:/config
- /mnt/tank/media:/media:ro
environment:
- JELLYFIN_DATA_DIR=/config
Gotcha: Pre-existing volumes won't automatically change ownership. If you're migrating from `chmod 777`, you need `chown -R 5000:5000 /mnt/tank/containers/jellyfin` before starting the container, or the app won't read config files and you'll spend 2 hours debugging why it won't start. Speaking from experience.
One User Per Service Category
Don't give everything the same UID. Your database shouldn't have write access to your media library. Create service-specific users:
useradd -r -u 5001 -s /bin/false postgres_app
useradd -r -u 5002 -s /bin/false media_apps
useradd -r -u 5003 -s /bin/false backup_service
# Directory layout
/mnt/tank/
├── db/ (5001:5001, 700)
├── media/ (5002:5002, 750)
└── backups/ (5003:5003, 700)
This way, if your Jellyfin container is compromised, it can't delete your PostgreSQL files. It's defense in depth.
Over-Engineering: The Kubernetes You Don't Need
Kubernetes is production-grade orchestration for production-grade complexity. Your homelab running 8 containers doesn't need it. Yet I see endless posts from people building k3s clusters at home, adding Helm charts, operator frameworks, persistent volume claims—all to run Plex and a database.
This is where good intentions become expensive mistakes. You're not gaining redundancy; you're gaining operational overhead that outweighs your actual needs.
Docker Compose Is Sufficient Until It Isn't
Use Docker Compose (version 3.8+) for up to ~15 containers. It's declarative, version-controllable, and debuggable in minutes. Here's a realistic homelab stack:
version: '3.8'
services:
postgres:
image: postgres:16.1-alpine
restart: unless-stopped
volumes:
- /mnt/tank/db/postgres:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
timeout: 5s
retries: 5
nextcloud:
image: nextcloud:27.1-apache
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
user: "5000:5000"
volumes:
- /mnt/tank/containers/nextcloud:/var/www/html
- /mnt/tank/media/shared:/media
ports:
- "8080:80"
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: /etc/docker/secrets/db_password.txt
This stack is:
- Versionable (commit to Git)
- Reproducible (exact image tags)
- Observable (easy to `docker-compose logs`)
- Debuggable (restart a single service with `docker-compose restart nextcloud`)
Migrate to orchestration when you actually need rolling updates, blue-green deployments, or cross-host scheduling. That's probably never in a homelab.
Network Segmentation: The Breach You Didn't Plan For
Your compromised Jellyfin container can see your Nextcloud database at 192.168.1.5. Your backup server is accessible from your media container. Everything's on the same VLAN because "it's just my home network."
Network segmentation isn't just enterprise security theater—it's how you contain failure domains in a homelab.
Use Docker Networks for Isolation
Docker's internal networking is isolated by default. Containers on different networks can't reach each other unless explicitly exposed:
networks:
media:
driver: bridge
database:
driver: bridge
public:
driver: bridge
services:
jellyfin:
networks:
- media
- public
ports:
- "8096:8096"
nextcloud:
networks:
- public
- database
postgres:
networks:
- database
# No ports exposed—only accessible from containers on 'database' network
Now Jellyfin can't touch your database even if compromised. It only knows about the media network and the public network (where Nextcloud talks to it).
Common Issues: Debugging Self-Hosted Mistakes in Production
Disk Fills, Service Stops, No Alerts
You find out your database container stopped three days ago because the log volume filled up. Add monitoring:
#!/bin/bash
# /usr/local/bin/check-disk.sh
# Run every 5 minutes via cron
THRESHOLD=80
HOSTNAME=$(hostname)
/usr/bin/df -h | grep /mnt/tank | while read line; do
USAGE=$(echo "$line" | awk '{print $5}' | sed 's/%//')
MOUNT=$(echo "$line" | awk '{print $6}')
if [ "$USAGE" -gt "$THRESHOLD" ]; then
echo "CRITICAL: $MOUNT is ${USAGE}% full on $HOSTNAME" | \
curl -X POST -d @- http://192.168.1.100:9093/api/v1/alerts
fi
done
Send alerts to Prometheus Alertmanager or just email—something active, not something you check.
Container Crashes After Reboot
You reboot your host. Containers start in parallel. Your Nextcloud tries to connect to Postgres before Postgres is ready. Set `depends_on` with proper health checks (shown above in the Postgres example). Then use `condition: service_healthy` on dependent services.
Secrets Hardcoded in Compose Files
Never store database passwords in your docker-compose.yml. Use Docker secrets (shown above) or environment files in `.gitignore`. At minimum:
# Create secrets directory with restricted permissions
mkdir -p /etc/docker/secrets
chmod 700 /etc/docker/secrets
# Store actual secrets
echo "your-secure-password-here" > /etc/docker/secrets/db_password.txt
chmod 600 /etc/docker/secrets/db_password.txt
# Add to .gitignore
echo "/etc/docker/secrets/" >> .gitignore
Where to Go From Here
You now have the foundation: automated backups you test monthly, permission isolation that actually works, a compose stack that's maintainable, and network segmentation that contains failures. This prevents 90% of homelab disasters.
The common self-hosted mistakes that kill projects aren't architectural failures—they're forgotten backups, creeping permissions, and over-engineering for complexity you don't have yet. Fix those three things and you've got a stable home infrastructure that runs for years with minimal intervention.
Next: implement ZFS snapshots if you're running on Linux, set up Prometheus + Alertmanager for visibility, and document your restore procedures. Your future self will be grateful when the inevitable disk failure happens at 2 AM.