Skip to content

Instantly share code, notes, and snippets.

@Alistair1231
Last active July 17, 2026 11:14
Show Gist options
  • Select an option

  • Save Alistair1231/b95f7c4a0b2b0e8474297b43675088db to your computer and use it in GitHub Desktop.

Select an option

Save Alistair1231/b95f7c4a0b2b0e8474297b43675088db to your computer and use it in GitHub Desktop.
restic backup script for docker bind mounts
#!/usr/bin/env bash
# Installs or updates the restic backup setup into /opt/restic and wires up
# the systemd service + timer. Run as root from a checkout of this repo:
# sudo ./install.sh
#
# Safe to re-run to pick up updates: restic.sh and the systemd units are
# always refreshed, but restic.env and restic.exclude (the per-machine
# config) are only ever created if missing, never overwritten.
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "install.sh must be run as root (sudo ./install.sh)" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR=/opt/restic
UNIT_DIR=/etc/systemd/system
mkdir -p "$INSTALL_DIR"
install -m 0700 "$SCRIPT_DIR/restic.sh" "$INSTALL_DIR/restic.sh"
new_config=0
if [ ! -f "$INSTALL_DIR/restic.env" ]; then
install -m 0600 "$SCRIPT_DIR/restic.env.example" "$INSTALL_DIR/restic.env"
new_config=1
fi
if [ ! -f "$INSTALL_DIR/restic.exclude" ]; then
install -m 0644 "$SCRIPT_DIR/restic.exclude.example" "$INSTALL_DIR/restic.exclude"
fi
install -m 0644 "$SCRIPT_DIR/restic-backup.service" "$UNIT_DIR/restic-backup.service"
install -m 0644 "$SCRIPT_DIR/restic-backup.timer" "$UNIT_DIR/restic-backup.timer"
systemctl daemon-reload
systemctl enable --now restic-backup.timer
echo "Installed restic.sh, restic-backup.service, restic-backup.timer."
if [ "$new_config" -eq 1 ]; then
echo
echo "New machine: $INSTALL_DIR/restic.env was created from the example template."
echo "Edit it (repository, backend credentials, BACKUP_TARGETS), create the"
echo "password file it points at, and review $INSTALL_DIR/restic.exclude,"
echo "before the next scheduled run."
fi
# /etc/systemd/system/restic-backup.service
[Unit]
Description=restic-backup
[Service]
Type=oneshot
ExecStart=/opt/restic/restic.sh
WorkingDirectory=/opt/restic
# /etc/systemd/system/restic-backup.timer
[Unit]
Description=restic-backup
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
# /opt/restic/restic.env
#
# Copy to /opt/restic/restic.env and edit for this machine. install.sh does
# this automatically on first install, and never touches the file again on
# later updates.
export RESTIC_PASSWORD_FILE="/opt/restic/.restic-password"
# Create the password file once per machine:
# printf '%s' 'your-password' > /opt/restic/.restic-password && chmod 600 /opt/restic/.restic-password
# --- Backend: pick ONE block below and fill it in --------------------------
# -- local --
# export RESTIC_REPOSITORY="/path/to/repo"
# -- sftp --
export RESTIC_REPOSITORY="sftp:user@host:/path/to/repo"
# Only needed if not relying on ~/.ssh/config or an agent:
export SSH_KEY="/root/.ssh/id_backup"
# -- rest-server --
# export RESTIC_REPOSITORY="rest:http://user:pass@host:8000/repo"
# -- S3 / S3-compatible (MinIO, etc.) --
# export RESTIC_REPOSITORY="s3:host/bucket"
# export AWS_ACCESS_KEY_ID="..."
# export AWS_SECRET_ACCESS_KEY="..."
# -- Backblaze B2 --
# export RESTIC_REPOSITORY="b2:bucket:path"
# export B2_ACCOUNT_ID="..."
# export B2_ACCOUNT_KEY="..."
# -- rclone (any rclone remote: Google Drive, Dropbox, OneDrive, etc.) --
# export RESTIC_REPOSITORY="rclone:remote:path"
# export RCLONE_CONFIG="/etc/rclone/rclone.conf"
# --- Paths to back up, space-separated --------------------------------------
export BACKUP_TARGETS="$HOME /etc"
# --- Retention policy: each is independent, comment out any you don't ------
# --- want enforced. If all four are commented out, forget/prune is --------
# --- skipped entirely (check still runs). ----------------------------------
export KEEP_LAST=24
export KEEP_DAILY=7
export KEEP_WEEKLY=4
export KEEP_MONTHLY=2
# --- Optional overrides (defaults shown) -------------------------------------
# export RESTIC_EXCLUDE_FILE=/opt/restic/restic.exclude
# export RESTIC_BIN=/usr/bin/restic
# export MAINTENANCE_HOUR=03
# export MAINTENANCE_STATE_FILE=/opt/restic/.last-maintenance
# /opt/restic/restic.exclude
#
# Copy to /opt/restic/restic.exclude and edit for this machine. install.sh
# does this automatically on first install, and never touches the file
# again on later updates. These are this machine's actual excludes as a
# starting point — adjust paths for whichever user(s)/PC you're deploying to.
# various big stuff
/home/al/.local/share/Steam
/home/al/.local/share/flatpak
/home/al/.local/share/Trash
/home/al/.local/share/bottles
/home/al/.local/share/containers
/home/al/winboat
/home/al/Mods
/home/al/Videos
/home/al/Music/Libation
/home/al/Downloads
/home/al/.ftba
/home/al/.vscode
# programming stuff
/home/al/.cargo/registry
/home/al/.cargo/git
/home/al/.gradle/caches
/home/al/.m2/repository
/home/al/.npm/_cacache
/home/al/.ccache
# Cache and log files
/home/al/**/*Cache*
/home/al/**/*cache*
/home/al/.cache
/home/al/.cache/**
/home/al/.local/state
/home/al/.local/state/**
/home/al/.log
/home/al/.log/**
# Flatpak
/home/al/.var/app/moe.launcher.sleepy-launcher/data/sleepy-launcher/
/home/al/.var/app/*/cache
/home/al/.var/app/*/.cache
/home/al/.var/app/*/tmp
/home/al/.var/app/*/.tmp
/home/al/.var/app/*/log
/home/al/.var/app/*/.local/state
#!/usr/bin/env bash
# /opt/restic/restic.sh
set -euo pipefail
# systemd runs this without a login shell (and always as root, see below),
# so $HOME is not guaranteed to be set. Default it before restic.env is
# sourced, since restic.env itself may reference $HOME (e.g. BACKUP_TARGETS).
: "${HOME:=/root}"
source "${RESTIC_ENV_FILE:-/opt/restic/restic.env}"
RESTIC_BIN="${RESTIC_BIN:-/usr/bin/restic}"
RESTIC_EXCLUDE_FILE="${RESTIC_EXCLUDE_FILE:-/opt/restic/restic.exclude}"
BACKUP_TARGETS="${BACKUP_TARGETS:-$HOME /etc}"
MAINTENANCE_HOUR="${MAINTENANCE_HOUR:-03}"
MAINTENANCE_STATE_FILE="${MAINTENANCE_STATE_FILE:-/opt/restic/.last-maintenance}"
log() { echo "========= $* $(date) ========="; }
trap 'echo "!!!!!!!! BACKUP FAILED (line $LINENO, exit $?) $(date) !!!!!!!!"' ERR
# Backend-specific extra options. Only sftp needs a flag here (an explicit
# private key); every other backend's credentials are read straight from
# the environment by restic itself, so this stays empty for them.
RESTIC_OPTS=()
if [ -n "${SSH_KEY:-}" ]; then
RESTIC_OPTS+=(-o "sftp.args=-i $SSH_KEY")
fi
# Each retention flag is independently optional: only pass the ones that are
# set, rather than requiring all four. No default here (unlike RESTIC_BIN
# etc.) — an unset KEEP_* means "don't enforce that cutoff", and if none are
# set at all, forget/prune is skipped below rather than calling restic with
# no retention policy.
KEEP_OPTS=()
[ -n "${KEEP_LAST:-}" ] && KEEP_OPTS+=(--keep-last "$KEEP_LAST")
[ -n "${KEEP_DAILY:-}" ] && KEEP_OPTS+=(--keep-daily "$KEEP_DAILY")
[ -n "${KEEP_WEEKLY:-}" ] && KEEP_OPTS+=(--keep-weekly "$KEEP_WEEKLY")
[ -n "${KEEP_MONTHLY:-}" ] && KEEP_OPTS+=(--keep-monthly "$KEEP_MONTHLY")
log START
# Clear stale locks left by a crashed/killed run. Safe: without --remove-all
# this only removes locks whose owning process is dead, never a live lock.
# Non-fatal so a transient unlock error doesn't abort the run under `set -e`.
"$RESTIC_BIN" -vv unlock "${RESTIC_OPTS[@]}" || true
# Hourly: backup. --retry-lock waits out brief overlap with a live run.
# BACKUP_TARGETS is intentionally unquoted so each path becomes a separate arg.
"$RESTIC_BIN" -vv backup $BACKUP_TARGETS \
"${RESTIC_OPTS[@]}" \
--retry-lock "5m" \
--exclude-file="$RESTIC_EXCLUDE_FILE"
# Daily (at or after MAINTENANCE_HOUR): forget old snapshots, prune, and
# check the repository. These are I/O-heavy, so gate them to once a day
# instead of every hour. Gated by >= and a last-run date file rather than an
# exact hour match, so a machine that's off/asleep exactly at
# MAINTENANCE_HOUR still catches up later the same day instead of skipping
# maintenance entirely until tomorrow. The 10# prefixes force base-10
# parsing so hours like 08/09 aren't misread as invalid octal.
today="$(date +%F)"
last_maintenance="$(cat "$MAINTENANCE_STATE_FILE" 2>/dev/null || true)"
if [ "$((10#$(date +%H)))" -ge "$((10#$MAINTENANCE_HOUR))" ] && [ "$last_maintenance" != "$today" ]; then
if [ "${#KEEP_OPTS[@]}" -gt 0 ]; then
"$RESTIC_BIN" -vv forget \
"${RESTIC_OPTS[@]}" \
--retry-lock "5m" \
"${KEEP_OPTS[@]}" \
--prune
else
log "no KEEP_* retention policy configured, skipping forget/prune"
fi
"$RESTIC_BIN" -vv check \
"${RESTIC_OPTS[@]}" \
--retry-lock "5m"
# Only recorded after forget/check succeed: under set -e a failure exits
# before this line, so the next run retries maintenance instead of
# wrongly considering today done.
echo "$today" > "$MAINTENANCE_STATE_FILE"
fi
log END
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment