feat: backup and restore

This commit is contained in:
2026-07-20 22:45:43 +01:00
parent c12a507fc1
commit 1aa340f7ce
37 changed files with 524 additions and 207 deletions
+48
View File
@@ -0,0 +1,48 @@
services:
# Nightly backup of every named volume on this host to S3. Containers
# labeled docker-volume-backup.stop-during-backup=true (see
# gitea-docker-compose.yml) are stopped for the duration of the backup so
# their data is captured in a consistent state, then restarted.
backup:
image: offen/docker-volume-backup:v2
container_name: backup
environment:
BACKUP_FILENAME: "dev-%Y-%m-%dT%H-%M-%S.{{ .Extension }}"
BACKUP_CRON_EXPRESSION: "0 3 * * *"
BACKUP_RETENTION_DAYS: "14"
BACKUP_PRUNING_LEEWAY: "5m"
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_PATH: ${AWS_S3_PATH}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_ENDPOINT: ${AWS_ENDPOINT}
volumes:
- gitea_data:/backup/gitea_data:ro
- letsencrypt_data:/backup/letsencrypt_data:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
# One-shot: restores empty volumes from the latest S3 backup, then exits.
# Run explicitly by spinup.sh before the services that own these volumes
# start - not started by `docker compose up`.
restore:
image: amazon/aws-cli:2
container_name: backup-restore
entrypoint: ["/bin/bash", "/restore.sh"]
environment:
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
AWS_S3_PATH: ${AWS_S3_PATH}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_ENDPOINT: ${AWS_ENDPOINT}
volumes:
- ./restore.sh:/restore.sh:ro
- gitea_data:/restore/gitea_data
- letsencrypt_data:/restore/letsencrypt_data
restart: "no"
volumes:
gitea_data:
name: gitea_data
letsencrypt_data:
name: letsencrypt_data
+11 -3
View File
@@ -4,7 +4,7 @@ services:
image: gitea/gitea:latest
container_name: gitea
volumes:
- ${DATA_DIR}/gitea:/data
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
@@ -29,7 +29,11 @@ services:
labels:
## Expose Gitea Through Trefik ##
- "traefik.enable=true" # <== Enable traefik to proxy this container
# Stopped for the duration of each backup run so the sqlite db/repos are
# captured in a consistent state - see backup-docker-compose.yml.
- "docker-volume-backup.stop-during-backup=true"
- "traefik.http.middlewares.cors-gitea.headers.accesscontrolallowmethods=*"
- "traefik.http.middlewares.cors-gitea.headers.accesscontrolalloworiginlist=*"
- "traefik.http.middlewares.cors-gitea.headers.addvaryheader=true"
@@ -51,4 +55,8 @@ services:
networks:
proxy:
external: true
external: true
volumes:
gitea_data:
name: gitea_data
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Restores this host's named volumes from the latest S3 backup, but only into
# volumes that are currently empty - never overwrites data that's already
# there. Run by spinup.sh via `docker compose -f backup-docker-compose.yml
# run --rm restore` before any service that owns one of the mounted volumes
# starts. Safe to run on a bucket/prefix that doesn't exist yet (fresh
# estate): it just leaves the empty volumes alone.
set -euo pipefail
S3_URI="s3://${AWS_S3_BUCKET_NAME}/${AWS_S3_PATH}"
ENDPOINT_ARGS=()
if [ -n "${AWS_ENDPOINT:-}" ]; then
ENDPOINT_ARGS=(--endpoint-url "https://${AWS_ENDPOINT}")
fi
empty_volumes=()
for dir in /restore/*/; do
name="$(basename "$dir")"
if [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then
empty_volumes+=("$name")
fi
done
if [ "${#empty_volumes[@]}" -eq 0 ]; then
echo "All volumes already contain data - skipping restore."
exit 0
fi
echo "Empty volumes: ${empty_volumes[*]}"
latest="$(aws "${ENDPOINT_ARGS[@]}" s3 ls "${S3_URI}/" 2>/dev/null | awk '{print $4}' | sort | tail -n1 || true)"
if [ -z "$latest" ]; then
echo "No existing backup found at ${S3_URI} - starting with empty volumes."
exit 0
fi
echo "Restoring ${S3_URI}/${latest} into empty volumes only..."
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
aws "${ENDPOINT_ARGS[@]}" s3 cp "${S3_URI}/${latest}" "$tmp/backup.tar.gz"
mkdir "$tmp/extracted"
tar -xzf "$tmp/backup.tar.gz" -C "$tmp/extracted"
for name in "${empty_volumes[@]}"; do
if [ -d "$tmp/extracted/$name" ]; then
echo "Restoring $name..."
cp -a "$tmp/extracted/$name/." "/restore/$name/"
else
echo "No $name/ in backup - leaving volume empty."
fi
done
echo "Restore complete."
+3 -1
View File
@@ -9,7 +9,9 @@ cd ..
docker compose -f gitea-docker-compose.yml down
docker compose -f traefik-docker-compose.yml down
docker compose -f watchtower-docker-compose.yml down
docker compose -f backup-docker-compose.yml down
docker rmi $(docker images -q)
docker system prune -f -a
docker volume prune -f -a
# Deliberately no `docker volume prune` - gitea_data/letsencrypt_data/etc. are
# named volumes holding real data, backed up to S3 but not disposable.
+8 -1
View File
@@ -4,10 +4,14 @@ set -e
cd "$(dirname "$0")"
if [ ! -f .env ]; then
echo "Missing .env (expected DATA_DIR=/mnt/HC_Volume_...) - run 'ansible-playbook playbooks/deploy.yml' in ansible/ first." >&2
echo "Missing .env (expected AWS_S3_BUCKET_NAME/AWS_ACCESS_KEY_ID/...) - run 'ansible-playbook playbooks/deploy.yml' in ansible/ first." >&2
exit 1
fi
echo "Restoring named volumes from S3 backup if any are empty..."
docker compose -f backup-docker-compose.yml pull restore
docker compose -f backup-docker-compose.yml run --rm restore
docker compose -f traefik-docker-compose.yml pull && docker compose -f traefik-docker-compose.yml up -d
docker compose -f gitea-docker-compose.yml pull && docker compose -f gitea-docker-compose.yml up -d
@@ -31,3 +35,6 @@ docker compose pull && docker compose up -d
cd ..
docker compose -f watchtower-docker-compose.yml pull && docker compose -f watchtower-docker-compose.yml up -d
docker compose -f backup-docker-compose.yml pull backup
docker compose -f backup-docker-compose.yml up -d backup
+5 -1
View File
@@ -15,7 +15,7 @@ services:
- "80:80"
- "443:443"
volumes:
- "${DATA_DIR}/letsencrypt:/letsencrypt"
- "letsencrypt_data:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
- proxy
@@ -36,3 +36,7 @@ services:
networks:
proxy:
name: proxy
volumes:
letsencrypt_data:
name: letsencrypt_data