commit 7c1a67be4e03242888ac65cd1f14e49f7312e7f6 Author: Moritz Martinius Date: Sun Feb 15 15:16:36 2026 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b646e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +secrets/ +ssh/id_* +ssh/known_hosts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3e0191d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM alpine:3.21 + +RUN apk add --no-cache \ + restic \ + mariadb-client \ + sqlite \ + openssh-client \ + bash \ + && mkdir -p /mnt/source /mnt/staging /hooks + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY backup.sh /usr/local/bin/backup.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/backup.sh + +ENV BACKUP_CRON="20 7 * * *" \ + BACKUP_SOURCE="/mnt/source /mnt/staging" \ + KEEP_DAILY=1 \ + KEEP_WEEKLY=3 \ + KEEP_MONTHLY=4 \ + KEEP_YEARLY=1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..89e889b --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# restic-backup-sidecar + +Docker sidecar container for restic backups via SFTP. Runs on Alpine with restic, mariadb-client, and sqlite. + +## Structure + +``` +Dockerfile, entrypoint.sh, backup.sh # Shared image (copy to each service's restic/ dir) +mailcow/hooks/ # MariaDB dump via socket +vaultwarden/hooks/ # SQLite .backup for vaultwarden +gitea/hooks/ # SQLite .backup for gitea +``` + +## Setup per service + +1. Copy `Dockerfile`, `entrypoint.sh`, `backup.sh` into `/restic/` +2. Copy the matching `hooks/` directory +3. Create `secrets/restic_password` with a strong passphrase +4. Create `ssh/config` (see `ssh/config.sample`), add your private key as `ssh/id_ed25519`, populate `known_hosts` via `ssh-keyscan` +5. Create the target directory on the NAS: `mkdir -p /mnt/data/backup/admiralackbar.de/` +6. Add the sidecar service to the compose file (see compose snippets in each service dir) + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `RESTIC_REPOSITORY` | — | SFTP target (`sftp:user@host:/path`) | +| `RESTIC_PASSWORD_FILE` | — | Path to password file (use Docker secrets) | +| `BACKUP_CRON` | `20 7 * * *` | Cron schedule | +| `BACKUP_SOURCE` | `/mnt/source /mnt/staging` | Paths to back up | +| `KEEP_DAILY` | `1` | Daily snapshots to keep | +| `KEEP_WEEKLY` | `3` | Weekly snapshots to keep | +| `KEEP_MONTHLY` | `4` | Monthly snapshots to keep | +| `KEEP_YEARLY` | `1` | Yearly snapshots to keep | +| `MYSQL_*` | — | MariaDB credentials (mailcow only) | + +## Hooks + +Mount scripts to `/hooks/` in the container: +- `pre-backup.sh` — runs before `restic backup` (e.g. database dump) +- `post-backup.sh` — runs after `restic forget --prune` (e.g. cleanup) + +## Manual backup / check + +```bash +docker exec /usr/local/bin/backup.sh +docker exec restic snapshots +``` diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..696a18d --- /dev/null +++ b/backup.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# Source environment vars (crond runs in a minimal env) +if [ -f /etc/restic-backup.env ]; then + set -a + . /etc/restic-backup.env + set +a +fi + +echo "==> Backup started at $(date -Iseconds)" + +# Run pre-backup hook +if [ -x /hooks/pre-backup.sh ]; then + echo "==> Running pre-backup hook" + /hooks/pre-backup.sh +fi + +# Run restic backup +echo "==> Running restic backup: $BACKUP_SOURCE" +restic backup $BACKUP_SOURCE + +# Apply retention policy +echo "==> Applying retention policy: daily=$KEEP_DAILY weekly=$KEEP_WEEKLY monthly=$KEEP_MONTHLY yearly=$KEEP_YEARLY" +restic forget --prune \ + --keep-daily="$KEEP_DAILY" \ + --keep-weekly="$KEEP_WEEKLY" \ + --keep-monthly="$KEEP_MONTHLY" \ + --keep-yearly="$KEEP_YEARLY" + +# Run post-backup hook +if [ -x /hooks/post-backup.sh ]; then + echo "==> Running post-backup hook" + /hooks/post-backup.sh +fi + +echo "==> Backup finished at $(date -Iseconds)" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..12d38a5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +echo "==> Restic backup sidecar starting" + +# Validate required env vars +for var in RESTIC_REPOSITORY RESTIC_PASSWORD_FILE BACKUP_CRON; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set" >&2 + exit 1 + fi +done + +if [ ! -f "$RESTIC_PASSWORD_FILE" ]; then + echo "ERROR: Password file $RESTIC_PASSWORD_FILE does not exist" >&2 + exit 1 +fi + +export RESTIC_PASSWORD_FILE + +# Initialize restic repo if it doesn't exist yet +if ! restic snapshots --no-lock >/dev/null 2>&1; then + echo "==> Initializing restic repository at $RESTIC_REPOSITORY" + restic init +fi + +echo "==> Repository ready: $RESTIC_REPOSITORY" +restic snapshots --no-lock --latest 1 || true + +# Export all relevant env vars to a file so crond jobs can source them +# (crond runs jobs in a minimal environment without Docker env vars) +ENV_FILE=/etc/restic-backup.env +: > "$ENV_FILE" +for var in RESTIC_REPOSITORY RESTIC_PASSWORD_FILE BACKUP_SOURCE \ + KEEP_DAILY KEEP_WEEKLY KEEP_MONTHLY KEEP_YEARLY \ + MYSQL_HOST MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE PATH; do + if [ -n "${!var+x}" ]; then + printf '%s=%q\n' "$var" "${!var}" >> "$ENV_FILE" + fi +done + +# Write crontab for root user +cat > /etc/crontabs/root <> /proc/1/fd/1 2>> /proc/1/fd/2 +CRON + +echo "==> Cron schedule: $BACKUP_CRON" + +# Run crond in foreground +echo "==> Starting crond" +exec crond -f -l 6 diff --git a/gitea/hooks/post-backup.sh b/gitea/hooks/post-backup.sh new file mode 100755 index 0000000..062b810 --- /dev/null +++ b/gitea/hooks/post-backup.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [gitea] Cleaning up SQLite backup" +rm -f /mnt/staging/gitea.db diff --git a/gitea/hooks/pre-backup.sh b/gitea/hooks/pre-backup.sh new file mode 100755 index 0000000..5404805 --- /dev/null +++ b/gitea/hooks/pre-backup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [gitea] Creating SQLite backup" +mkdir -p /mnt/staging +sqlite3 /mnt/source/data/gitea/gitea.db ".backup /mnt/staging/gitea.db" +echo "==> [gitea] SQLite backup complete ($(du -h /mnt/staging/gitea.db | cut -f1))" diff --git a/mailcow/hooks/post-backup.sh b/mailcow/hooks/post-backup.sh new file mode 100644 index 0000000..7659aea --- /dev/null +++ b/mailcow/hooks/post-backup.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [mailcow] Cleaning up database dump" +rm -f /mnt/staging/mailcow.sql diff --git a/mailcow/hooks/pre-backup.sh b/mailcow/hooks/pre-backup.sh new file mode 100644 index 0000000..1c070c0 --- /dev/null +++ b/mailcow/hooks/pre-backup.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [mailcow] Dumping MariaDB database" +mkdir -p /mnt/staging +mariadb-dump \ + --socket=/var/run/mysqld/mysqld.sock \ + --skip-ssl \ + -u "$MYSQL_USER" \ + -p"$MYSQL_PASSWORD" \ + --single-transaction \ + "$MYSQL_DATABASE" > /mnt/staging/mailcow.sql +echo "==> [mailcow] Database dump complete ($(du -h /mnt/staging/mailcow.sql | cut -f1))" \ No newline at end of file diff --git a/ssh/config.sample b/ssh/config.sample new file mode 100644 index 0000000..20457af --- /dev/null +++ b/ssh/config.sample @@ -0,0 +1,4 @@ +Host 192.168.0.10 + User backup-admiralackbar + IdentityFile /root/.ssh/id_ed25519 + StrictHostKeyChecking yes diff --git a/vaultwarden/hooks/post-backup.sh b/vaultwarden/hooks/post-backup.sh new file mode 100644 index 0000000..7f22bcd --- /dev/null +++ b/vaultwarden/hooks/post-backup.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [vaultwarden] Cleaning up SQLite backup" +rm -f /mnt/staging/db.sqlite3 diff --git a/vaultwarden/hooks/pre-backup.sh b/vaultwarden/hooks/pre-backup.sh new file mode 100644 index 0000000..228a968 --- /dev/null +++ b/vaultwarden/hooks/pre-backup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail + +echo "==> [vaultwarden] Creating SQLite backup" +mkdir -p /mnt/staging +sqlite3 /mnt/source/bw-data/db.sqlite3 ".backup /mnt/staging/db.sqlite3" +echo "==> [vaultwarden] SQLite backup complete ($(du -h /mnt/staging/db.sqlite3 | cut -f1))"