How I Back Up My Homelab

January 15, 2026

After spending some time migrating my homelab to k3s, I realized I’d been neglecting something important: backups. My old Docker Compose setup never had backups (yes, I know), but now it felt like the right time to fix that.

The Problem

My data lived in three different places:

  1. Docker volumes on neuron-1: Home Assistant config, Zigbee device pairings, DNS settings, storage, etc.
  2. Kubernetes PVCs: Databases (PostgreSQL, MariaDB, ClickHouse) and application data
  3. Books: A growing eBook collection that I actually wanted to browse directly if needed

Each needed a different approach. Kubernetes PVCs can’t be backed up the same way as Docker bind mounts. And I wanted my books accessible as regular files, not buried in some encrypted blob.

The Strategy

I ended up with four complementary backup methods, all pushing to Backblaze B2:


homelab-backups/
├── docker/             # Restic repository (encrypted, deduplicated)
├── longhorn/           # Longhorn volume snapshots
├── postgresql/         # pg_dump SQL files (gzipped)
└── books/              # Plain files (rclone sync)


Why four different tools?

  • Restic for Docker: Encrypted, deduplicated, handles lots of small config files efficiently
  • Longhorn for k8s PVCs: Native integration, no need to mount volumes externally
  • pg_dump for PostgreSQL: Portable, inspectable SQL dumps without WAL overhead
  • rclone for books: Plain files I can browse directly in B2's web console

Docker Backups with Restic

Docker configs were always neglected.

I’d been running without backups, hoping nothing would break, so setting up a proper solution meant:

  • Restic for encrypted, deduplicated backups to B2
  • A Kubernetes CronJob (not a host cron) so it’s managed by Argo CD
  • Logs going to Loki (visible in Grafana)
  • Credentials stored in a SealedSecret (not a plaintext file)

The CronJob mounts neuron-1’s Docker directory via hostPath and runs Restic directly:

nodeSelector:
  kubernetes.io/hostname: neuron-1

volumes:
  - name: docker-data
    hostPath:
      path: /path/to/docker
      type: Directory

The backup command is inline, no wrapper script needed:

restic backup \
  /data/.env \
  /data/critical/adguard/conf \
  /data/home/home-assistant/production/config \
  /data/home/zigbee2mqtt/data \
  # ... more paths
  --exclude='*.log' \
  --exclude='__pycache__/' \
  --tag docker \
  --tag neuron-1

It runs daily at 3 AM and takes about 30 seconds. Thanks to deduplication, after the initial run it only uploads what actually changed.

Longhorn Backups for Kubernetes PVCs

Longhorn has built-in backup support. Point it at an S3-compatible backend, create a RecurringJob, label your PVCs, and you’re done.

The setup lives in Ansible (since Longhorn is deployed during cluster bootstrap):

apiVersion: longhorn.io/v1beta2
kind: RecurringJob
metadata:
  name: longhorn-backup
spec:
  task: backup
  cron: "0 4 * * *"
  retain: 7
  groups:
    - backup-daily

PVCs opt into backups via labels:

labels:
  recurring-job.longhorn.io/source: enabled
  recurring-job-group.longhorn.io/backup-daily: enabled

Every PVC with these labels gets snapshotted and pushed to B2 daily at 4 AM, with seven days retained. No scripts, no cron jobs to manage: Longhorn handles it.

I add these labels to my Helm charts for stateful apps (MariaDB, ClickHouse, Linkwarden, etc). PostgreSQL is the exception: CloudNativePG manages its own PVCs, which is why it uses a separate backup approach.

Restoring Is GUI-Based

Longhorn’s restore workflow isn’t very CLI-friendly. I have the Longhorn UI exposed via ingress, so restoring is just:

  1. Navigate to Backup
  2. Select volume → select snapshot → Restore

It creates a new PVC that you can swap into your deployment. Not ideal for automation, but restores are rare enough that clicking through a UI is acceptable.

PostgreSQL: From Barman to pg_dump

I initially set up PostgreSQL (via CloudNativePG) with barman backups to B2, the “proper” way, with continuous WAL archiving for point-in-time recovery.

Then I checked my B2 usage after 24 hours: 4.9 GB for a database with maybe 100 MB of actual data.

The WAL Problem

Barman does two things:

  1. Base backups: Full database snapshots
  2. WAL archiving: Continuous upload of write-ahead log segments

The base backups were fine. The WAL files were not. PostgreSQL generates a new 16 MB WAL segment every few minutes, even with minimal activity, and each one gets uploaded to B2.

I tried:

  • wal_compression: on
  • archive_timeout: 3600
  • Reducing retention to 7 days

Still too much. With barman, it’s all or nothing.

The pg_dump Solution

For a homelab, point-in-time recovery is overkill. I just need “restore to yesterday.”

So I disabled barman entirely and switched to pg_dump:

initContainers:
  - name: pgdump
    image: postgres:17-bookworm
    command:
      - /bin/bash
      - -c
      - |
        pg_dumpall | gzip > /backup/pgdump-$(date +%F).sql.gz

containers:
  - name: s3-upload
    image: amazon/aws-cli:latest
    command:
      - /bin/bash
      - -c
      - |
        aws s3 cp /backup/pgdump-*.sql.gz s3://bucket/postgresql/ \
          --endpoint-url $S3_ENDPOINT

The compressed dump is only a few megabytes. It runs daily at 2 AM, keeps seven days, and cleans itself up.

After switching, I deleted the old barman data and freed 4.9 GB instantly.

Why Not Longhorn?

  • Portable: Works with any PostgreSQL instance
  • Inspectable: Easy to grep or restore individual tables
  • Smaller: Much smaller than block-level snapshots

Books with rclone

My eBook collection needed different treatment. I wanted direct access to files.

rclone sync /books b2:homelab-backups/books/

A Kubernetes CronJob runs this daily at 5 AM. Because the PVC is ReadWriteOnce, the job uses pod affinity to run on the same node as the main app.

If I need to recover a single book, I can download it directly from B2, or sync everything back.

Monitoring

All jobs log to Loki and are visible in Grafana. I also have alerts for “backup hasn’t run in 48 hours.”

If something silently breaks, I want to know before I need to restore.

What I Learned

What I Learned

  • Backups should be visible. Running backups as Kubernetes CronJobs means logs in Grafana, job history via kubectl get jobs, and alerts when things fail. No more “I hope that script is still running somewhere.”

  • Different data needs different tools. Trying to force everything into one backup method would have been a mess. Config files, database volumes, and browsable media all have different recovery needs.

  • “Enterprise” solutions can be overkill. Barman with continuous WAL archiving is the right choice for production. For a homelab with minimal data, it was burning through gigabytes per day. A simple pg_dump works fine and costs almost nothing.

  • Test your restores. I did a full restore drill after setting this up, spun up a fresh namespace, restored from B2, and verified the data. I caught a few path issues that would have been painful to discover during an actual outage.

Current State

Four backup methods, one destination, zero manual intervention.

Total Backblaze B2 usage: ~50 GB Monthly cost: ~$0.25

That peace of mind is worth far more than the cost.

Important distinction: The books rclone sync exists for accessibility, not disaster recovery. The books PVC is also backed up via Longhorn, giving me seven days to recover accidental deletions before they disappear everywhere.