Skip to content

Building a Fast, Encrypted & Private Backup Pipeline with restic, rest‑server, Hetzner Storage Box & Tailscale VPN

A step-by-step guide to building an encrypted, off-site backup pipeline with restic, rest-server, Hetzner Storage Box, and Tailscale VPN. Updated for restic 0.18 with append-only mode and Prometheus monitoring.

Cloud storage and backup infrastructure with encryption
Photo by Siyuan Hu / Unsplash
Updated April 2026 — Refreshed for restic 0.18, improved Docker Compose (no deprecated version key), added append-only mode and Prometheus monitoring, and tightened security recommendations. Looking for a UI-based approach instead? Check out Zerobyte: A Web UI for restic That Replaces Your Backup Scripts.
A step‑by‑step guide to reproducing (and understanding) the exact setup I use to keep my Proxmox homelab and personal data safe—updated for 2026.

1. Why this stack?

Layer What it does Why I chose it
restic CLI backup tool with client‑side encryption, deduplication & fast incremental snapshots Small binary, no root needed, battle‑tested, easy restores
rest‑server Lightweight HTTP backend that speaks restic’s REST protocol Lower latency than SFTP/SSH when the repository lives on a remote CIFS/NFS mount (no double encryption, stream‑oriented)
Hetzner Storage Box Cheap off‑site storage available over SMB / Samba, SFTP, WebDAV & more Flat pricing, runs in the same DC as my (tiny) VPS, unlimited traffic
Tailscale WireGuard‑based overlay network End‑to‑end encryption, avoids public exposure & lets every client “see” the repo via an internal IP
Docker Compose Deploy rest‑server & its CIFS mount reproducibly One‑file infrastructure, easy upgrades & rollbacks

The result is a pull‑based model: every device (Proxmox node, NAS, laptop, …) runs restic and pushes its encrypted chunks to a central rest‑server that lives on my Hetzner VPS. The VPS itself merely forwards bytes to a CIFS share on the Storage Box; it never sees unencrypted data.


2. Provision your Storage Box & sub‑account

  1. Order a Storage Box (BX line is enough for backups).
  2. Inside the Hetzner Cloud console create a sub‑account with:
    • SMB/CIFS enabled
    • Strong, unique password

Note the UNC path; it looks like

//<username>.your-storagebox.de/<username>

Hetzner’s docs confirm the same syntax for CIFS mounting


3. Spin up a tiny VPS & join it to Tailscale

# 1 vCPU, 2 GB RAM is plenty
hcloud server create --type cpx11 --image debian-13 --datacenter fsn1-dc14 \
                     --name backup-vps

curl -fsSL https://tailscale.com/install.sh | sh

sudo tailscale up --accept-routes --hostname restic-hub

Record the Tailscale IPv4 that the node receives (e.g. 100.x.y.z). Only this IP will be exposed from Docker.


4. Deploy rest‑server with Docker Compose

volumes:
  data:
    driver: local
    driver_opts:
      type: cifs
      device: "//<user>.your-storagebox.de/<user>"
      o: "username=<user>,password=<pass>,vers=3.0,uid=1000,gid=1000"

services:
  restic-server:
    image: restic/rest-server:latest
    restart: always
    user: "1000:1000"
    environment:
      - OPTIONS=--append-only --prometheus --log -
    ports:
      - "<tailscale_ip>:8000:8000"
    volumes:
      - data:/data
      - ./htpasswd:/htpasswd:ro

What’s changed and why:

  • No more version: "3.9" — Docker Compose V2 no longer requires or recommends the version key. Drop it.
  • --append-only — Clients can create new backups but cannot delete or modify existing ones. This protects against ransomware or a compromised client wiping your snapshots. Run forget and prune from the server side only.
  • --prometheus — Exposes metrics at /metrics for scraping with Prometheus + Grafana. Free observability into repo size, request counts, and error rates.
  • HTTP basic auth via htpasswd — Instead of DISABLE_AUTHENTICATION=1, we now mount a .htpasswd file. Each client gets its own credentials. Generate with: htpasswd -B -c ./htpasswd/credentials <username>
  • Tailscale IP binding — Still binding to the Tailscale IP instead of 0.0.0.0 to prevent accidental public exposure. This is your primary network-level control.

Bring it up:

docker compose up -d

Verify the server is running:

curl -u <username>:<password> http://<tailscale_ip>:8000/

A 404 page not found response means the server is up but the repository directory is empty—time to initialize it from a client.


5. Install restic on your Proxmox host

apt update && apt install -y restic

# Verify you have 0.18+
restic version

# Store the repo password securely
pwgen -s 32 1 > /etc/restic-key
chmod 600 /etc/restic-key

Restic 0.18 (March 2025) added cold storage support for S3 backends, improved prune performance for repacking small files, and compression for dump output. If your distro ships an older version, grab the latest binary from the GitHub releases.


6. The backup script

Below is the full Bash helper I use. It does three jobs:

  1. Initializes the repo if it’s the first run.
  2. Runs restic backup with a chosen tag.
  3. Enforces a retention policy (7 daily, 4 weekly, 12 monthly, 2 yearly) via restic forget --prune.

It also ships Telegram alerts on success/failure so I don’t have to tail logs at 3 am.

#!/usr/bin/env bash

# backup.sh - Backup script using restic

set -euo pipefail

# Repository (adjust to your rest-server)
export RESTIC_REPOSITORY=rest:http://<username>:<password>@<tailscale_ip>:8000
export RESTIC_PASSWORD_FILE=/etc/restic-key

# Retention policy
KEEP_DAILY=7
KEEP_WEEKLY=4
KEEP_MONTHLY=12
KEEP_YEARLY=2

# Telegram notifications
TELEGRAM_CHAT_ID=<telegram_chat_id>
TELEGRAM_BOT_TOKEN=<telegram_bot_token>

telegram_notify() {
  local message="$1"
  curl -sf -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
       -d chat_id="${TELEGRAM_CHAT_ID}" \
       -d parse_mode="Markdown" \
       --data-urlencode text="${message}" >/dev/null 2>&1 || true
}

initialize_repo() {
  if restic snapshots >/dev/null 2>&1; then
    echo "Repository already initialized."
  else
    echo "Initializing repository..."
    restic init
    telegram_notify "\xE2\x84\xB9\xEF\xB8\x8F Initialized restic repo on host: \`$(hostname)\`"
  fi
}

backup() {
  local source_dir="$1"
  local tag="$2"

  echo "Backing up ${source_dir} (tag: ${tag})..."
  restic backup "${source_dir}" \
    --tag "${tag}" \
    --exclude-caches \
    --verbose

  telegram_notify "\xE2\x9C\x85 Backup of \`${source_dir}\` (${tag}) *completed* on \`$(hostname)\`"
}

cleanup() {
  echo "Applying retention policy..."
  restic forget \
    --keep-daily "${KEEP_DAILY}" \
    --keep-weekly "${KEEP_WEEKLY}" \
    --keep-monthly "${KEEP_MONTHLY}" \
    --keep-yearly "${KEEP_YEARLY}" \
    --prune

  telegram_notify "\xF0\x9F\xA7\xB9 Cleanup completed on \`$(hostname)\`"
}

error_handler() {
  telegram_notify "\xE2\x9D\x8C Backup *FAILED* on \`$(hostname)\`: \`${1:-unknown error}\`"
  exit 1
}

trap 'error_handler "$BASH_COMMAND"' ERR

# --- Main ---
case "${1:-}" in
  backup)
    [[ $# -ne 3 ]] && { echo "Usage: $0 backup <dir> <tag>"; exit 1; }
    initialize_repo
    backup "$2" "$3"
    ;;
  cleanup)
    cleanup
    ;;
  *)
    echo "Usage: $0 {backup <dir> <tag>|cleanup}"
    exit 1
    ;;
esac

Changes from the original script:

  • HTTP basic auth credentials are now embedded in the repository URL (rest:http://user:pass@host:port).
  • --exclude-caches skips directories containing a CACHEDIR.TAG file.
  • The Telegram function has || true to prevent a notification failure from aborting the entire backup.
  • Replaced if/elif chain with a case statement for cleaner argument handling.
  • The error trap now captures the failing command in the alert message.

7. Scheduling with crontab

Because the script bundles both the backup and retention logic, the crontab ends up extremely simple.
Here’s a practical example (sudo crontab -e on the Proxmox host):

# ┌─ minute (0‑59)
# │ ┌─ hour   (0‑23)
# │ │ ┌─ day‑of‑month (1‑31)
# │ │ │ ┌─ month      (1‑12)
# │ │ │ │ ┌─ day‑of‑week (0‑7)  (0|7 = Sunday)
# │ │ │ │ │
# │ │ │ │ │   command
# │ │ │ │ │
  15  3  *  *  *   /usr/local/bin/backup.sh backup /tank/cloud           cloud      >>/var/log/restic-cloud.log 2>&1
  45  3  *  *  *   /usr/local/bin/backup.sh backup /tank/photos          photos     >>/var/log/restic-photos.log 2>&1
  00  4  *  *  *   /usr/local/bin/backup.sh backup /tank/proxmox-backups proxmox    >>/var/log/restic-pbs.log    2>&1

# Weekly prune every Sunday at 05:00
  00  5  *  *  0   /usr/local/bin/backup.sh cleanup                                  >>/var/log/restic-cleanup.log 2>&1

Why cron, not Systemd?

  • ✅ Less moving parts — everyone understands crontab.
  • ✅ No unit files to manage; one‑liner edits are enough.
  • ✅ Logs are redirected to plain files and Telegram, so I still get alerts if something fails.

(If you prefer Systemd, just swap the cron entries for timers — the script itself doesn’t care.)


8. Restoration test (don’t skip this!)

# Verify repository integrity
restic -r rest:http://<user>:<pass>@<tailscale_ip>:8000 \
       -p /etc/restic-key \
       check

# Restore the latest snapshot to a test directory
restic -r rest:http://<user>:<pass>@<tailscale_ip>:8000 \
       -p /etc/restic-key \
       restore latest --target /tmp/restore-test

Run restic check regularly—it verifies that all data blobs are present and intact. Then do a real restore and verify the output. Boot a VM from the restored vzdump, diff a directory, spot-check a few files. Backups you never test are merely archives.


9. Hardening & extras

  • Append-only mode — Already enabled in our Compose file. This is your strongest defense against a compromised client deleting backups. Run forget and prune only from the server.
  • HTTP basic auth — One user per client (htpasswd -B ./htpasswd/credentials <client-name>). Easier to audit and revoke.
  • Read-only sub-account on the Hetzner Storage Box to prevent deletion at the storage layer too.
  • Prometheus + Grafana — Scrape /metrics from rest-server. The official repo includes a ready-made Grafana dashboard.
  • Rotate the restic password periodically with restic key passwd. Keep the old key in a secure location until you’ve verified the rotation.
  • Automatic updates — Use Watchtower or a scheduled apt upgrade to keep restic and rest-server current.
  • Firewall — Even with Tailscale, configure ufw or nftables on the VPS to only allow traffic from the Tailscale interface.

10. TL;DR checklist

  1. 🔐 Generate a strong repository password (pwgen -s 32).
  2. 🗄️ Order Storage Box → enable SMB → create sub‑account.
  3. ☁️ Boot a 1 vCPU VPS in the same DC → join Tailscale.
  4. 🐳 Deploy the docker‑compose.yml above.
  5. 💻 Install restic on every client and drop the Bash script.
  6. 📅 Schedule with cron (hourly, nightly, weekly).
  7. 🧪 Restore a random file every month.
  8. 📈 Add Telegram to get eyes on the process.

Congratulations—you now have an encrypted, versioned, off‑site backup system that costs a few euros a month and scales from single‑board computers to entire datastores.

If you’d prefer a web UI over shell scripts and cron jobs, check out my follow-up post: Zerobyte: A Web UI for restic That Replaces Your Backup Scripts. It uses the same restic engine and can even connect to repositories you’ve already created with this guide.

Happy backing up!

This website respects your privacy and does not use cookies for tracking purposes. Learn more