From bf2d9e40fdd4f4cc7ddac7231adf2d3bb5d4f14e Mon Sep 17 00:00:00 2001 From: padriano Date: Sat, 9 May 2026 22:28:49 +0100 Subject: [PATCH] add SMB backup plan Co-authored-by: Cursor --- plans/Plan-smb-backup.md | 168 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 plans/Plan-smb-backup.md diff --git a/plans/Plan-smb-backup.md b/plans/Plan-smb-backup.md new file mode 100644 index 0000000..a02dee8 --- /dev/null +++ b/plans/Plan-smb-backup.md @@ -0,0 +1,168 @@ +# SMB Share Backup via docker-compose + +## Overview + +Add a dedicated `samba-backup` container based on Alpine that: +1. Mounts the local `/home/padriano/share` directory directly (same bind-mount as the `samba` container, read-only) +2. Mounts the remote SMB share via `cifs-utils` inside the container at sync time +3. Runs `rsync` on a cron schedule to sync from local to remote + +This is self-contained in docker-compose, requires no new tools on the host, and survives restarts. + +## Container choice + +A minimal `alpine` image with `rsync` + `cifs-utils` + `crond`. Alpine is preferred for simplicity and control. + +## Architecture + +```mermaid +flowchart LR + subgraph host [Host filesystem] + share["/home/padriano/share"] + end + subgraph compose [docker-compose] + samba["samba\n192.168.1.247\n[Share]"] + backup["samba-backup\n(alpine + rsync + cifs-utils)"] + end + subgraph remote [Remote SMB server] + remoteShare["\\\\remote-server\\ShareName"] + end + + share -->|"bind mount (ro)"| samba + share -->|"bind mount (ro)"| backup + backup -->|"cifs mount + rsync (scheduled)"| remoteShare +``` + +## Files to change / add + +- [`docker-compose.yml`](../docker-compose.yml) — add `samba-backup` service +- [`samba-backup/entrypoint.sh`](../samba-backup/entrypoint.sh) — script: mounts remote share on each cron run, runs rsync +- [`samba-backup/.credentials`](../samba-backup/.credentials) — remote SMB credentials (added to `.gitignore`) +- [`.env`](../.env) / [`.env.example`](../.env.example) — add `BACKUP_REMOTE_HOST`, `BACKUP_REMOTE_SHARE`, `BACKUP_CRON_SCHEDULE` + +## New service in docker-compose.yml + +```yaml +samba-backup: + image: alpine:latest + container_name: samba-backup + restart: unless-stopped + cap_add: + - SYS_ADMIN # required for mount inside container + security_opt: + - apparmor:unconfined # needed for CIFS mount + devices: + - /dev/fuse + environment: + - TZ=${TZ} + - BACKUP_REMOTE_HOST=${BACKUP_REMOTE_HOST} + - BACKUP_REMOTE_SHARE=${BACKUP_REMOTE_SHARE} + - BACKUP_CRON_SCHEDULE=${BACKUP_CRON_SCHEDULE:-0 3 * * *} + volumes: + - /home/padriano/share:/source:ro + - ./samba-backup/entrypoint.sh:/entrypoint.sh:ro + - ./samba-backup/.credentials:/etc/samba-backup/credentials:ro + entrypoint: ["/bin/sh", "/entrypoint.sh"] +``` + +> `SYS_ADMIN` + `apparmor:unconfined` are required so the Alpine container can run `mount.cifs` internally. + +## Handling remote unavailability + +The remote SMB share can be unreachable in two situations: + +**1. At container startup** — the entrypoint does NOT mount at startup. `crond` is started unconditionally, so the container never crashes due to a missing remote. + +**2. At sync time (cron fires, remote is down)** — the `do_backup` function tries to mount, skips rsync gracefully if mount fails, then unmounts. The next cron run will try again. No crash, no data loss. + +```mermaid +flowchart TD + start([Container starts]) --> install[apk install deps] + install --> cron[Register cron job] + cron --> crond[crond -f loop] + crond -->|"schedule fires"| tryMount{"mount remote\nSMB share"} + tryMount -->|"success"| rsync[rsync /source/ → /mnt/remote-smb/] + tryMount -->|"failure\n(log + skip)"| skip[Skip — log warning] + rsync --> umount[umount remote share] + umount --> crond + skip --> crond +``` + +## entrypoint.sh + +```sh +#!/bin/sh +# No set -e: we handle errors explicitly so the container stays up + +apk add --no-cache rsync cifs-utils tzdata > /dev/null 2>&1 + +MOUNT_TARGET=/mnt/remote-smb +LOG=/var/log/samba-backup.log +mkdir -p "$MOUNT_TARGET" + +do_backup() { + TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$TIMESTAMP] Starting backup run" >> "$LOG" + + # Attempt mount; skip sync if unavailable + if ! mount -t cifs "//${BACKUP_REMOTE_HOST}/${BACKUP_REMOTE_SHARE}" "$MOUNT_TARGET" \ + -o credentials=/etc/samba-backup/credentials,vers=3.0,uid=0,gid=0,file_mode=0660,dir_mode=0770 \ + > /dev/null 2>&1; then + echo "[$TIMESTAMP] WARNING: Remote share unavailable, skipping sync" >> "$LOG" + return 0 + fi + + rsync -avz --delete /source/ "${MOUNT_TARGET}/" >> "$LOG" 2>&1 + RSYNC_EXIT=$? + + umount "$MOUNT_TARGET" 2>/dev/null || true + + if [ "$RSYNC_EXIT" -ne 0 ]; then + echo "[$TIMESTAMP] ERROR: rsync exited with code ${RSYNC_EXIT}" >> "$LOG" + else + echo "[$TIMESTAMP] Backup completed successfully" >> "$LOG" + fi +} + +# Write cron job that calls this script in sync-only mode +cat > /etc/crontabs/root << EOF +${BACKUP_CRON_SCHEDULE} /bin/sh /entrypoint.sh --sync-only +EOF + +# If called with --sync-only, just run the backup and exit (used by crond) +if [ "$1" = "--sync-only" ]; then + do_backup + exit 0 +fi + +# Normal startup: run an immediate first sync in background, then hand off to crond +do_backup & + +crond -f -l 8 +``` + +## samba-backup/.credentials + +```ini +username=REMOTE_USER +password=REMOTE_PASSWORD +domain=WORKGROUP +``` + +## .env additions + +``` +BACKUP_REMOTE_HOST=192.168.x.x # remote SMB server IP or hostname +BACKUP_REMOTE_SHARE=ShareName # share name on remote server +BACKUP_CRON_SCHEDULE=0 3 * * * # daily at 03:00; adjust as needed +``` + +## Notes + +- `--delete` on rsync makes the remote a true mirror; remove it if you want additive-only backups. +- If you prefer not to give `SYS_ADMIN` to a container, mount the remote CIFS share on the host and pass it as a second bind-mount volume — removes the privileged requirement but requires host-side setup (`/etc/fstab` or `systemd.mount`). +- Consider a custom image (`FROM alpine` + `RUN apk add rsync cifs-utils tzdata`) to avoid `apk` running on every container restart. +- Backup logs are written to `/var/log/samba-backup.log` inside the container. Tail them with: + ``` + docker exec samba-backup tail -f /var/log/samba-backup.log + ```