From f300fde26bdec9f98fd30574257385d44cead137 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Mon, 11 May 2026 16:17:33 +0000 Subject: [PATCH] Add PostgreSQL backup and restore helpers --- .gitignore | 2 + docs/VPS_DEPLOYMENT.md | 45 ++++++++++++---- scripts/backup-postgres.sh | 67 +++++++++++++++++++++++ scripts/restore-postgres.sh | 105 ++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 11 deletions(-) create mode 100755 scripts/backup-postgres.sh create mode 100755 scripts/restore-postgres.sh diff --git a/.gitignore b/.gitignore index 8e9f4c0..f902815 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* + +backups/ diff --git a/docs/VPS_DEPLOYMENT.md b/docs/VPS_DEPLOYMENT.md index 204d7f6..9c6828e 100644 --- a/docs/VPS_DEPLOYMENT.md +++ b/docs/VPS_DEPLOYMENT.md @@ -260,7 +260,21 @@ Only expose `3000` temporarily when debugging, and close it again immediately af ## 12. Update deployment -From the project directory, pull the latest branch changes: +From the project directory, create a database backup before changing running containers or applying migrations: + +```bash +./scripts/backup-postgres.sh +``` + +The script stores timestamped SQL backups in `backups/`, for example `backups/hidden_cost_game_20260511_153000.sql`. It reads `POSTGRES_USER` and `POSTGRES_DB` from `.env` when present and uses the safe defaults `hcg` and `hidden_cost_game` otherwise. PostgreSQL stays private because the script runs `pg_dump` inside the Docker Compose `postgres` service; it does not expose port `5432`. + +Copy important backups off the VPS before updates or migrations. From your local computer, use `scp` with your real SSH user, server, and deployment path: + +```bash +scp your-user@your-server:/path/to/hidden-cost-game/backups/hidden_cost_game_YYYYMMDD_HHMMSS.sql ./ +``` + +Then pull the latest branch changes: ```bash git pull @@ -284,37 +298,46 @@ Check logs after every update: docker compose logs -f app ``` -## 13. Backup notes +## 13. Backup and restore notes -The bundled PostgreSQL database stores its files in the Docker volume named `postgres_data`. Docker volumes persist when containers are recreated, but they are still on the VPS disk. Back up the database before risky changes, major updates, or server migrations. +The bundled PostgreSQL database stores its files in the Docker volume named `postgres_data`. Docker volumes persist when containers are recreated, but they are still on the VPS disk. Back up the database before risky changes, major updates, migrations, or server moves. -Create a backup directory: +Create a timestamped backup: ```bash -mkdir -p backups +./scripts/backup-postgres.sh ``` -Basic PostgreSQL backup using `pg_dump` from the `postgres` container: +Backup files are written to the repository-local `backups/` directory, which is ignored by Git. Keep an off-server copy as well; a VPS disk failure, accidental deletion, or bad migration can affect both the Docker volume and local backup files. + +Copy a backup from the VPS to your local machine: ```bash -docker compose exec -T postgres pg_dump -U hcg -d hidden_cost_game > backups/hidden_cost_game_$(date +%F_%H%M%S).sql +scp your-user@your-server:/path/to/hidden-cost-game/backups/hidden_cost_game_YYYYMMDD_HHMMSS.sql ./ ``` -If you changed `POSTGRES_USER` or `POSTGRES_DB` in `.env`, replace `hcg` and `hidden_cost_game` in the command. +Copy a local backup back to the VPS when you intentionally need to restore it: + +```bash +scp ./hidden_cost_game_YYYYMMDD_HHMMSS.sql your-user@your-server:/path/to/hidden-cost-game/backups/ +``` Important restore warning: -- Restoring a database can overwrite or conflict with existing production data. +- Restoring a database can overwrite, modify, duplicate, or conflict with existing production data. - Practice restore steps on a test server first. - Stop the app or put the site into maintenance mode before restoring production data. +- Verify the backup file name, server, branch, and `.env` database settings before confirming a restore. - Keep an off-server copy of backup files, not only a copy inside the VPS. -A restore command usually looks like this, but do not run it on production unless you are certain it targets the correct database: +Restore only when you are certain the target database is correct: ```bash -cat backups/hidden_cost_game_BACKUP_FILE.sql | docker compose exec -T postgres psql -U hcg -d hidden_cost_game +./scripts/restore-postgres.sh backups/hidden_cost_game_YYYYMMDD_HHMMSS.sql ``` +The restore helper prints a large warning and requires you to type `RESTORE` before it streams the SQL file into the Docker Compose `postgres` service with `psql`. PostgreSQL remains private; no database port is exposed. + ## 14. Final smoke test After deployment and HTTPS setup, test the full production path: diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..3a61c1b --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_POSTGRES_USER="hcg" +DEFAULT_POSTGRES_DB="hidden_cost_game" +BACKUP_DIR="backups" +ENV_FILE=".env" + +read_env_value() { + local key="$1" + + if [[ ! -f "$ENV_FILE" ]]; then + return 1 + fi + + awk -v key="$key" ' + BEGIN { FS = "=" } + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { + line = $0 + sub(/^[[:space:]]*export[[:space:]]+/, "", line) + split(line, parts, "=") + name = parts[1] + gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) + if (name != key) { + next + } + value = substr(line, index(line, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { + value = substr(value, 2, length(value) - 2) + } + found = 1 + print value + exit + } + END { if (!found) exit 1 } + ' "$ENV_FILE" +} + +POSTGRES_USER="${POSTGRES_USER:-}" +POSTGRES_DB="${POSTGRES_DB:-}" + +if [[ -z "$POSTGRES_USER" ]]; then + POSTGRES_USER="$(read_env_value POSTGRES_USER || true)" +fi + +if [[ -z "$POSTGRES_DB" ]]; then + POSTGRES_DB="$(read_env_value POSTGRES_DB || true)" +fi + +POSTGRES_USER="${POSTGRES_USER:-$DEFAULT_POSTGRES_USER}" +POSTGRES_DB="${POSTGRES_DB:-$DEFAULT_POSTGRES_DB}" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" +OUTPUT_PATH="${BACKUP_DIR}/${POSTGRES_DB}_${TIMESTAMP}.sql" + +mkdir -p "$BACKUP_DIR" + +docker compose exec -T postgres pg_dump \ + --username="$POSTGRES_USER" \ + --dbname="$POSTGRES_DB" \ + --no-owner \ + --no-privileges \ + > "$OUTPUT_PATH" + +printf 'PostgreSQL backup written to: %s\n' "$OUTPUT_PATH" diff --git a/scripts/restore-postgres.sh b/scripts/restore-postgres.sh new file mode 100755 index 0000000..4f79757 --- /dev/null +++ b/scripts/restore-postgres.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEFAULT_POSTGRES_USER="hcg" +DEFAULT_POSTGRES_DB="hidden_cost_game" +ENV_FILE=".env" + +usage() { + printf 'Usage: %s \n' "$0" >&2 +} + +read_env_value() { + local key="$1" + + if [[ ! -f "$ENV_FILE" ]]; then + return 1 + fi + + awk -v key="$key" ' + BEGIN { FS = "=" } + /^[[:space:]]*#/ { next } + /^[[:space:]]*$/ { next } + { + line = $0 + sub(/^[[:space:]]*export[[:space:]]+/, "", line) + split(line, parts, "=") + name = parts[1] + gsub(/^[[:space:]]+|[[:space:]]+$/, "", name) + if (name != key) { + next + } + value = substr(line, index(line, "=") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { + value = substr(value, 2, length(value) - 2) + } + found = 1 + print value + exit + } + END { if (!found) exit 1 } + ' "$ENV_FILE" +} + +if [[ $# -ne 1 ]]; then + usage + exit 1 +fi + +BACKUP_FILE="$1" + +if [[ ! -f "$BACKUP_FILE" ]]; then + printf 'Backup file not found: %s\n' "$BACKUP_FILE" >&2 + exit 1 +fi + +if [[ ! -r "$BACKUP_FILE" ]]; then + printf 'Backup file is not readable: %s\n' "$BACKUP_FILE" >&2 + exit 1 +fi + +POSTGRES_USER="${POSTGRES_USER:-}" +POSTGRES_DB="${POSTGRES_DB:-}" + +if [[ -z "$POSTGRES_USER" ]]; then + POSTGRES_USER="$(read_env_value POSTGRES_USER || true)" +fi + +if [[ -z "$POSTGRES_DB" ]]; then + POSTGRES_DB="$(read_env_value POSTGRES_DB || true)" +fi + +POSTGRES_USER="${POSTGRES_USER:-$DEFAULT_POSTGRES_USER}" +POSTGRES_DB="${POSTGRES_DB:-$DEFAULT_POSTGRES_DB}" + +cat <<'WARNING' + +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +!! WARNING: DATABASE RESTORE MAY OVERWRITE OR MODIFY DATA !! +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +This command will stream the backup into the Docker Compose postgres +service without exposing the PostgreSQL port. Run it only when you are +certain the backup file and target database are correct. + +WARNING + +printf 'Target database: %s\n' "$POSTGRES_DB" +printf 'Target user: %s\n' "$POSTGRES_USER" +printf 'Backup file: %s\n\n' "$BACKUP_FILE" +printf 'Type RESTORE to continue: ' +read -r CONFIRMATION + +if [[ "$CONFIRMATION" != "RESTORE" ]]; then + printf 'Restore cancelled.\n' + exit 1 +fi + +docker compose exec -T postgres psql \ + --username="$POSTGRES_USER" \ + --dbname="$POSTGRES_DB" \ + --set=ON_ERROR_STOP=1 \ + < "$BACKUP_FILE" + +printf 'PostgreSQL restore completed from: %s\n' "$BACKUP_FILE"