diff --git a/.env.sample b/.env.sample index aa38abd..df7c075 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,12 @@ -POSTGRES_DB=qlockify -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres +POSTGRES_DB=qlockify +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +S3_BACKUP_BUCKET= +S3_BACKUP_PREFIX=qlockify +S3_BACKUP_REGION=us-east-1 +S3_BACKUP_ENDPOINT_URL= +S3_BACKUP_ACCESS_KEY_ID= +S3_BACKUP_SECRET_ACCESS_KEY= +BACKUP_ENCRYPTION_PASSPHRASE= +BACKUP_LOCAL_KEEP_LATEST=1 diff --git a/README.md b/README.md index 44bf315..c0025bb 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,64 @@ RESTORE_SKIP_MEDIA=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHM RESTORE_SKIP_ENV=1 ./scripts/restore.sh ./backups/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz ``` +Install backup upload prerequisites: + +```bash +sudo apt update +sudo apt install -y awscli openssl +``` + +Configure encrypted S3-compatible backup uploads in `./.env`: + +```bash +S3_BACKUP_BUCKET=qlockify-backups +S3_BACKUP_PREFIX=qlockify +S3_BACKUP_REGION=us-east-1 +S3_BACKUP_ENDPOINT_URL=https://c284984.parspack.net +S3_BACKUP_ACCESS_KEY_ID= +S3_BACKUP_SECRET_ACCESS_KEY= +BACKUP_ENCRYPTION_PASSPHRASE= +BACKUP_LOCAL_KEEP_LATEST=1 +``` + +Upload an encrypted backup to S3: + +```bash +chmod +x ./scripts/backup-upload-s3.sh +./scripts/backup-upload-s3.sh +``` + +Restore the latest encrypted backup from S3: + +```bash +chmod +x ./scripts/restore-from-s3.sh +./scripts/restore-from-s3.sh latest +docker compose up -d --build +``` + +Restore a specific encrypted backup from S3: + +```bash +./scripts/restore-from-s3.sh qlockify/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz.enc +docker compose up -d --build +``` + +Schedule daily encrypted uploads with cron: + +```cron +0 2 * * * cd /home/ubuntu/qlockify-deployment && ./scripts/backup-upload-s3.sh >> ./backups/backup.log 2>&1 +``` + +The S3 restore script supports the same partial restore flags: + +```bash +RESTORE_SKIP_DB=1 ./scripts/restore-from-s3.sh latest +RESTORE_SKIP_MEDIA=1 ./scripts/restore-from-s3.sh latest +RESTORE_SKIP_ENV=1 ./scripts/restore-from-s3.sh latest +``` + +Keep `BACKUP_ENCRYPTION_PASSPHRASE` in a safe place outside the server. Encrypted backups cannot be restored without it. + ## CI/CD with Gitea Actions This repository now ships with a Gitea Actions deployment workflow in: diff --git a/scripts/backup-upload-s3.sh b/scripts/backup-upload-s3.sh new file mode 100644 index 0000000..74c4745 --- /dev/null +++ b/scripts/backup-upload-s3.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + cat <<'EOF' +Usage: + backup-upload-s3.sh + +Environment: + DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. + S3_BACKUP_BUCKET S3 bucket name. + S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify. + S3_BACKUP_REGION S3 region. Defaults to us-east-1. + S3_BACKUP_ENDPOINT_URL Optional S3-compatible endpoint URL. + S3_BACKUP_ACCESS_KEY_ID S3 access key. + S3_BACKUP_SECRET_ACCESS_KEY S3 secret key. + BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to encrypt backup archives. + BACKUP_LOCAL_KEEP_LATEST Set to 1 to keep latest encrypted archive locally. +EOF +} + +log() { + printf '[backup-s3] %s\n' "$*" +} + +fail() { + printf '[backup-s3] %s\n' "$*" >&2 + exit 1 +} + +require_var() { + local name="$1" + [[ -n "${!name:-}" ]] || fail "$name is required" +} + +load_env() { + local env_path="$1" + [[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path" + set -a + # shellcheck disable=SC1090 + . "$env_path" + set +a +} + +aws_s3() { + if [[ -n "${S3_BACKUP_ENDPOINT_URL:-}" ]]; then + aws --endpoint-url "$S3_BACKUP_ENDPOINT_URL" s3 "$@" + else + aws s3 "$@" + fi +} + +normalize_prefix() { + local value="${1:-qlockify}" + value="${value#/}" + value="${value%/}" + printf '%s' "$value" +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +command -v aws >/dev/null 2>&1 || fail "aws CLI is required" +command -v openssl >/dev/null 2>&1 || fail "openssl is required" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" +DEPLOY_ENV="$DEPLOY_ROOT/.env" +BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh" + +[[ -x "$BACKUP_SCRIPT" ]] || fail "Backup script is not executable: $BACKUP_SCRIPT" +load_env "$DEPLOY_ENV" + +S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")" +S3_BACKUP_REGION="${S3_BACKUP_REGION:-us-east-1}" + +require_var S3_BACKUP_BUCKET +require_var S3_BACKUP_ACCESS_KEY_ID +require_var S3_BACKUP_SECRET_ACCESS_KEY +require_var BACKUP_ENCRYPTION_PASSPHRASE + +export AWS_ACCESS_KEY_ID="$S3_BACKUP_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="$S3_BACKUP_SECRET_ACCESS_KEY" +export AWS_DEFAULT_REGION="$S3_BACKUP_REGION" +export AWS_EC2_METADATA_DISABLED=true + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +PLAIN_DIR="$WORK_DIR/plain" +mkdir -p "$PLAIN_DIR" + +log "Creating local backup archive" +DEPLOY_ROOT="$DEPLOY_ROOT" "$BACKUP_SCRIPT" "$PLAIN_DIR" + +shopt -s nullglob +archives=("$PLAIN_DIR"/*.tar.gz) +shopt -u nullglob +[[ "${#archives[@]}" -eq 1 ]] || fail "Expected exactly one backup archive, found ${#archives[@]}" + +ARCHIVE_PATH="${archives[0]}" +ARCHIVE_NAME="$(basename "$ARCHIVE_PATH")" +ENCRYPTED_NAME="$ARCHIVE_NAME.enc" +ENCRYPTED_PATH="$WORK_DIR/$ENCRYPTED_NAME" +S3_URI="s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/$ENCRYPTED_NAME" + +log "Encrypting backup archive" +openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ + -in "$ARCHIVE_PATH" \ + -out "$ENCRYPTED_PATH" \ + -pass env:BACKUP_ENCRYPTION_PASSPHRASE + +rm -f "$ARCHIVE_PATH" + +log "Uploading encrypted backup to $S3_URI" +aws_s3 cp "$ENCRYPTED_PATH" "$S3_URI" --only-show-errors + +if [[ "${BACKUP_LOCAL_KEEP_LATEST:-1}" == "1" ]]; then + LATEST_DIR="$DEPLOY_ROOT/backups/latest" + mkdir -p "$LATEST_DIR" + find "$LATEST_DIR" -type f -name '*.tar.gz.enc' -delete + cp "$ENCRYPTED_PATH" "$LATEST_DIR/$ENCRYPTED_NAME" + log "Kept latest encrypted backup locally: $LATEST_DIR/$ENCRYPTED_NAME" +fi + +log "S3 backup upload completed: $S3_URI" diff --git a/scripts/restore-from-s3.sh b/scripts/restore-from-s3.sh new file mode 100644 index 0000000..653f284 --- /dev/null +++ b/scripts/restore-from-s3.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + cat <<'EOF' +Usage: + restore-from-s3.sh latest + restore-from-s3.sh + +Examples: + restore-from-s3.sh latest + restore-from-s3.sh qlockify/qlockify-backup-YYYYMMDD-HHMMSS.tar.gz.enc + +Environment: + DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. + S3_BACKUP_BUCKET S3 bucket name. + S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify. + S3_BACKUP_REGION S3 region. Defaults to us-east-1. + S3_BACKUP_ENDPOINT_URL Optional S3-compatible endpoint URL. + S3_BACKUP_ACCESS_KEY_ID S3 access key. + S3_BACKUP_SECRET_ACCESS_KEY S3 secret key. + BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to decrypt backup archives. + +Existing RESTORE_SKIP_DB, RESTORE_SKIP_MEDIA, and RESTORE_SKIP_ENV flags are +passed through to restore.sh. +EOF +} + +log() { + printf '[restore-s3] %s\n' "$*" +} + +fail() { + printf '[restore-s3] %s\n' "$*" >&2 + exit 1 +} + +require_var() { + local name="$1" + [[ -n "${!name:-}" ]] || fail "$name is required" +} + +load_env() { + local env_path="$1" + [[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path" + set -a + # shellcheck disable=SC1090 + . "$env_path" + set +a +} + +aws_s3() { + if [[ -n "${S3_BACKUP_ENDPOINT_URL:-}" ]]; then + aws --endpoint-url "$S3_BACKUP_ENDPOINT_URL" s3 "$@" + else + aws s3 "$@" + fi +} + +normalize_prefix() { + local value="${1:-qlockify}" + value="${value#/}" + value="${value%/}" + printf '%s' "$value" +} + +latest_object_key() { + aws_s3 ls "s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/" --recursive \ + | awk '{print $4}' \ + | grep '\.tar\.gz\.enc$' \ + | sort \ + | tail -n 1 +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +REQUESTED_KEY="${1:-}" +[[ -n "$REQUESTED_KEY" ]] || { + usage + exit 1 +} + +command -v aws >/dev/null 2>&1 || fail "aws CLI is required" +command -v openssl >/dev/null 2>&1 || fail "openssl is required" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" +DEPLOY_ENV="$DEPLOY_ROOT/.env" +RESTORE_SCRIPT="$SCRIPT_DIR/restore.sh" + +[[ -x "$RESTORE_SCRIPT" ]] || fail "Restore script is not executable: $RESTORE_SCRIPT" +load_env "$DEPLOY_ENV" + +S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")" +S3_BACKUP_REGION="${S3_BACKUP_REGION:-us-east-1}" + +require_var S3_BACKUP_BUCKET +require_var S3_BACKUP_ACCESS_KEY_ID +require_var S3_BACKUP_SECRET_ACCESS_KEY +require_var BACKUP_ENCRYPTION_PASSPHRASE + +export AWS_ACCESS_KEY_ID="$S3_BACKUP_ACCESS_KEY_ID" +export AWS_SECRET_ACCESS_KEY="$S3_BACKUP_SECRET_ACCESS_KEY" +export AWS_DEFAULT_REGION="$S3_BACKUP_REGION" +export AWS_EC2_METADATA_DISABLED=true + +if [[ "$REQUESTED_KEY" == "latest" ]]; then + log "Resolving latest encrypted backup object" + OBJECT_KEY="$(latest_object_key)" + [[ -n "$OBJECT_KEY" ]] || fail "No encrypted backups found in s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/" +else + OBJECT_KEY="${REQUESTED_KEY#s3://$S3_BACKUP_BUCKET/}" +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +ENCRYPTED_PATH="$WORK_DIR/$(basename "$OBJECT_KEY")" +DECRYPTED_PATH="$WORK_DIR/${ENCRYPTED_PATH##*/}" +DECRYPTED_PATH="${DECRYPTED_PATH%.enc}" + +log "Downloading encrypted backup from s3://$S3_BACKUP_BUCKET/$OBJECT_KEY" +aws_s3 cp "s3://$S3_BACKUP_BUCKET/$OBJECT_KEY" "$ENCRYPTED_PATH" --only-show-errors + +log "Decrypting backup archive" +openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ + -in "$ENCRYPTED_PATH" \ + -out "$DECRYPTED_PATH" \ + -pass env:BACKUP_ENCRYPTION_PASSPHRASE + +log "Restoring decrypted backup archive" +DEPLOY_ROOT="$DEPLOY_ROOT" "$RESTORE_SCRIPT" "$DECRYPTED_PATH" + +log "S3 restore completed from: s3://$S3_BACKUP_BUCKET/$OBJECT_KEY"