name: Database Backup run-name: 💾 DB Backup triggered by @${{ gitea.actor }} # ─────────────────────────────────────────────────────── # Owner: DevOps (Architekt) # Trigger: Manual (workflow_dispatch) + optional schedule. # # Strategy: # 1. Connects to the live PostgreSQL container via docker exec. # 2. Runs pg_dumpall (full cluster dump, single file). # 3. Compresses with gzip. # 4. Uploads as a Gitea Action artifact (or writes to host path). # 5. Artifacts are retained per Gitea repo settings (default 90 days). # # Rotation: Gitea artifact expiration handles old backups automatically. # For longer retention, configure an external cron job or use the # host_path output to copy the backup elsewhere. # # Restoration: See phases/deployment.md for step-by-step instructions. # ─────────────────────────────────────────────────────── concurrency: group: db-backup cancel-in-progress: false on: workflow_dispatch: inputs: keep_on_host: description: 'Also copy backup to host path?' required: false default: false type: boolean host_backup_path: description: 'Host path for backup (only if keep_on_host is true)' required: false default: '/opt/openclaw/backups' type: string # Optional: uncomment to enable nightly automatic backups # schedule: # - cron: '0 3 * * *' # Every night at 03:00 UTC jobs: backup: name: Backup PostgreSQL runs-on: ubuntu-latest env: ENV_TMPFILE: /tmp/nexus-backup-env ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus BACKUP_CONTAINER_NAME: nexus-postgres-1 steps: # ═══════════════════════════════════════════════════ # Step 1: Generate backup filename # ═══════════════════════════════════════════════════ - name: Generate backup identifier id: meta run: | TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ') echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT" echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT" echo "📅 Backup ID: ${TIMESTAMP}" # ═══════════════════════════════════════════════════ # Step 2: Dump PostgreSQL via docker exec # ═══════════════════════════════════════════════════ - name: Dump database run: | set -euo pipefail echo "🗄️ Dumping PostgreSQL cluster..." docker exec "${BACKUP_CONTAINER_NAME}" \ sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \ | gzip > "${{ steps.meta.outputs.filename }}" SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1) echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})" # ═══════════════════════════════════════════════════ # Step 3: Upload backup as Gitea artifact # ═══════════════════════════════════════════════════ - name: Upload backup artifact uses: actions/upload-artifact@v4 with: name: nexus-backup-${{ steps.meta.outputs.timestamp }} path: ${{ steps.meta.outputs.filename }} retention-days: 90 compression-level: 0 # already gzipped # ═══════════════════════════════════════════════════ # Step 4: Optional — copy to host filesystem # ═══════════════════════════════════════════════════ - name: Copy backup to host (optional) if: inputs.keep_on_host == true run: | set -euo pipefail HOST_PATH="${{ inputs.host_backup_path }}" # Create host dir if it doesn't exist docker run --rm \ -v "${HOST_PATH}:/backup-target" \ -v "${{ gitea.workspace }}:/src:ro" \ alpine:latest \ sh -c " mkdir -p /backup-target && \ cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \ echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}' " # ═══════════════════════════════════════════════════ # Step 5: Verify backup integrity # ═══════════════════════════════════════════════════ - name: Verify backup integrity run: | echo "🔍 Verifying backup integrity..." if gzip -t "${{ steps.meta.outputs.filename }}"; then echo "✅ Backup gzip integrity check passed" else echo "❌ Backup file is corrupted!" exit 1 fi # Quick content check: should start with PostgreSQL dump header HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1) if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then echo "✅ Backup content header check passed" else echo "⚠️ Unexpected backup header (may still be valid): $HEADER" fi # ═══════════════════════════════════════════════════ # Step 6: Backup Summary # ═══════════════════════════════════════════════════ - name: Backup Summary if: always() run: | STATUS="${{ job.status }}" echo "" echo "═══════════════════════════════════════" echo " 💾 Database Backup Summary" echo "═══════════════════════════════════════" echo " File: ${{ steps.meta.outputs.filename }}" echo " Timestamp: ${{ steps.meta.outputs.timestamp }}" echo " Triggered: @${{ gitea.actor }}" echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}" echo " Status: ${STATUS}" echo "═══════════════════════════════════════" if [ "${STATUS}" = "success" ]; then echo "" echo "💡 Restore command (manual, on host):" echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres" fi