From 9f7accba04869e015923f709c31b0a0743730c18 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 5 Jun 2026 13:29:30 +0330 Subject: [PATCH] feat(scripts): add backup/restore scripts --- .gitattributes | 1 + .gitignore | 1 + README.md | 29 ++++++++++ scripts/backup.sh | 126 ++++++++++++++++++++++++++++++++++++++++++ scripts/restore.sh | 134 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 .gitattributes create mode 100755 scripts/backup.sh create mode 100755 scripts/restore.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index e661c59..ca04b03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +backups/ backend/logs/ backend/.pytest_cache/ diff --git a/README.md b/README.md index d9cd56c..44bf315 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,35 @@ Stop everything: docker compose down ``` +Backup the deployed data: + +```bash +chmod +x ./scripts/backup.sh +./scripts/backup.sh +``` + +By default, backup archives are written to `./backups/`. Each archive contains: + +- PostgreSQL data from the `db` service +- media files from the Docker media volume +- `.env` files from the deployment, backend, and frontend projects + +Restore a backup archive on another machine: + +```bash +chmod +x ./scripts/restore.sh +./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz +docker compose up -d --build +``` + +Restore is destructive for the database by default. To restore only part of an archive, use: + +```bash +RESTORE_SKIP_DB=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz +RESTORE_SKIP_MEDIA=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz +RESTORE_SKIP_ENV=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz +``` + ## CI/CD with Gitea Actions This repository now ships with a Gitea Actions deployment workflow in: diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..2a5373a --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + cat <<'EOF' +Usage: + backup.sh [output-directory] + +Environment: + DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. + +Creates a timestamped .tar.gz archive containing: + - PostgreSQL dump from the db service + - media files from the backend media volume + - deployment, backend, and frontend .env files +EOF +} + +log() { + printf '[backup] %s\n' "$*" +} + +fail() { + printf '[backup] %s\n' "$*" >&2 + exit 1 +} + +require_file() { + local path="$1" + [[ -f "$path" ]] || fail "Required file not found: $path" +} + +compose() { + docker compose -f "$DEPLOY_ROOT/docker-compose.yml" "$@" +} + +wait_for_db() { + compose exec -T db sh -c ' + set -eu + : "${POSTGRES_USER:?POSTGRES_USER is missing}" + : "${POSTGRES_DB:?POSTGRES_DB is missing}" + until pg_isready --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"; do + sleep 1 + done + ' >/dev/null +} + +copy_env_if_exists() { + local source_path="$1" + local target_path="$2" + + if [[ -f "$source_path" ]]; then + cp "$source_path" "$target_path" + fi +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/qlockify-deployment}" +OUTPUT_DIR="${1:-$DEPLOY_ROOT/backups}" +BACKEND_ENV="$DEPLOY_ROOT/backend/qlockify-backend-deployment/.env" +FRONTEND_ENV="$DEPLOY_ROOT/frontend/qlockify-frontend-deployment/.env" +DEPLOY_ENV="$DEPLOY_ROOT/.env" + +require_file "$DEPLOY_ROOT/docker-compose.yml" +require_file "$BACKEND_ENV" + +mkdir -p "$OUTPUT_DIR" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +TIMESTAMP="$(date -u +'%Y%m%d-%H%M%S')" +ARCHIVE_NAME="qlockify-backup-$TIMESTAMP.tar.gz" +ARCHIVE_PATH="$OUTPUT_DIR/$ARCHIVE_NAME" + +mkdir -p "$WORK_DIR/env" + +log "Checking Docker Compose configuration" +compose config -q + +log "Starting database service if needed" +compose up -d db +wait_for_db + +log "Dumping database from db service" +compose exec -T db sh -c ' + set -eu + : "${POSTGRES_USER:?POSTGRES_USER is missing}" + : "${POSTGRES_DB:?POSTGRES_DB is missing}" + pg_dump \ + --username="$POSTGRES_USER" \ + --dbname="$POSTGRES_DB" \ + --format=plain \ + --clean \ + --if-exists \ + --no-owner \ + --no-privileges +' > "$WORK_DIR/database.sql" + +log "Archiving media files from backend media volume" +if compose ps --status running --services | grep -qx 'backend'; then + compose exec -T backend sh -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz" +else + log "Backend service is not running; using a temporary container for media volume" + compose run --rm --no-deps --entrypoint sh backend -c 'mkdir -p /app/media && tar -C /app/media -czf - .' > "$WORK_DIR/media.tar.gz" +fi + +log "Copying environment files" +copy_env_if_exists "$DEPLOY_ENV" "$WORK_DIR/env/deployment.env" +copy_env_if_exists "$BACKEND_ENV" "$WORK_DIR/env/backend.env" +copy_env_if_exists "$FRONTEND_ENV" "$WORK_DIR/env/frontend.env" + +cat > "$WORK_DIR/manifest.txt" < + +Environment: + DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. + RESTORE_SKIP_ENV Set to 1 to keep current .env files. + RESTORE_SKIP_MEDIA Set to 1 to keep current media files. + RESTORE_SKIP_DB Set to 1 to keep current database. + +This script restores: + - deployment, backend, and frontend .env files + - media files into the backend media volume + - PostgreSQL database from database.sql + +Database restore is destructive unless RESTORE_SKIP_DB=1 is set. +EOF +} + +log() { + printf '[restore] %s\n' "$*" +} + +fail() { + printf '[restore] %s\n' "$*" >&2 + exit 1 +} + +require_file() { + local path="$1" + [[ -f "$path" ]] || fail "Required file not found: $path" +} + +compose() { + docker compose -f "$DEPLOY_ROOT/docker-compose.yml" "$@" +} + +wait_for_db() { + compose exec -T db sh -c ' + set -eu + : "${POSTGRES_USER:?POSTGRES_USER is missing}" + : "${POSTGRES_DB:?POSTGRES_DB is missing}" + until pg_isready --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"; do + sleep 1 + done + ' >/dev/null +} + +restore_env_if_present() { + local source_path="$1" + local target_path="$2" + + if [[ -f "$source_path" ]]; then + mkdir -p "$(dirname "$target_path")" + cp "$source_path" "$target_path" + chmod 600 "$target_path" || true + log "Restored $target_path" + fi +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +ARCHIVE_PATH="${1:-}" +[[ -n "$ARCHIVE_PATH" ]] || { + usage + exit 1 +} + +DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/qlockify-deployment}" +BACKEND_ENV="$DEPLOY_ROOT/backend/qlockify-backend-deployment/.env" +FRONTEND_ENV="$DEPLOY_ROOT/frontend/qlockify-frontend-deployment/.env" +DEPLOY_ENV="$DEPLOY_ROOT/.env" + +require_file "$ARCHIVE_PATH" +require_file "$DEPLOY_ROOT/docker-compose.yml" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +log "Extracting backup archive" +tar -C "$WORK_DIR" -xzf "$ARCHIVE_PATH" + +if [[ "${RESTORE_SKIP_ENV:-0}" != "1" ]]; then + log "Restoring environment files" + restore_env_if_present "$WORK_DIR/env/deployment.env" "$DEPLOY_ENV" + restore_env_if_present "$WORK_DIR/env/backend.env" "$BACKEND_ENV" + restore_env_if_present "$WORK_DIR/env/frontend.env" "$FRONTEND_ENV" +else + log "Skipping environment restore" +fi + +require_file "$BACKEND_ENV" + +log "Checking Docker Compose configuration" +compose config -q + +if [[ "${RESTORE_SKIP_MEDIA:-0}" != "1" ]]; then + require_file "$WORK_DIR/media.tar.gz" + log "Restoring media files into backend media volume" + compose run --rm --no-deps --entrypoint sh backend -c 'rm -rf /app/media/* /app/media/.[!.]* /app/media/..?* 2>/dev/null || true' + compose run --rm --no-deps --entrypoint sh -T backend -c 'mkdir -p /app/media && tar -C /app/media -xzf -' < "$WORK_DIR/media.tar.gz" +else + log "Skipping media restore" +fi + +if [[ "${RESTORE_SKIP_DB:-0}" != "1" ]]; then + require_file "$WORK_DIR/database.sql" + log "Starting database service" + compose up -d db + wait_for_db + + log "Recreating and restoring database" + compose exec -T db sh -c ' + set -eu + : "${POSTGRES_USER:?POSTGRES_USER is missing}" + : "${POSTGRES_DB:?POSTGRES_DB is missing}" + psql --username="$POSTGRES_USER" --dbname=postgres --command="SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '\''$POSTGRES_DB'\'' AND pid <> pg_backend_pid();" >/dev/null + dropdb --username="$POSTGRES_USER" --if-exists "$POSTGRES_DB" + createdb --username="$POSTGRES_USER" "$POSTGRES_DB" + psql --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" + ' < "$WORK_DIR/database.sql" +else + log "Skipping database restore" +fi + +log "Restore completed" +log "Run: docker compose up -d --build"