feat(scripts): add backup/restore scripts
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
backups/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
|
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -234,6 +234,35 @@ Stop everything:
|
|||||||
docker compose down
|
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
|
## CI/CD with Gitea Actions
|
||||||
|
|
||||||
This repository now ships with a Gitea Actions deployment workflow in:
|
This repository now ships with a Gitea Actions deployment workflow in:
|
||||||
|
|||||||
126
scripts/backup.sh
Executable file
126
scripts/backup.sh
Executable file
@@ -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" <<EOF
|
||||||
|
name=$ARCHIVE_NAME
|
||||||
|
created_at_utc=$TIMESTAMP
|
||||||
|
deploy_root=$DEPLOY_ROOT
|
||||||
|
includes=database.sql,media.tar.gz,env/deployment.env,env/backend.env,env/frontend.env
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "Creating archive $ARCHIVE_PATH"
|
||||||
|
tar -C "$WORK_DIR" -czf "$ARCHIVE_PATH" .
|
||||||
|
|
||||||
|
log "Backup completed: $ARCHIVE_PATH"
|
||||||
134
scripts/restore.sh
Executable file
134
scripts/restore.sh
Executable file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
restore.sh <backup-archive.tar.gz>
|
||||||
|
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user