# ADR-019: Data Backup and Recovery Strategy

**Date**: 2025-12-12

**Status**: Accepted

**Implemented**: 2026-01-09

## Context

The application's data, stored in PostgreSQL, is critical. Currently, there is no formalized or automated strategy for creating backups or for recovering data in the event of hardware failure, data corruption, or accidental deletion.

## Decision

We will implement a formal data backup and recovery strategy. This will involve using standard PostgreSQL tools (`pg_dump`) to perform **regular, automated backups** (e.g., daily). Backup files will be stored securely in a separate, off-site location (e.g., a cloud storage bucket). The ADR will also define the Recovery Time Objective (RTO) and Recovery Point Objective (RPO) and document the step-by-step procedure for restoring from a backup.

## Consequences

- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery.
- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files.

## Implementation Details

### Backup Workflow

Located in `.gitea/workflows/manual-db-backup.yml`:

```yaml
name: Manual - Backup Production Database

on:
  workflow_dispatch:
    inputs:
      confirmation:
        description: 'Type "backup-production-db" to confirm'
        required: true

jobs:
  backup-database:
    runs-on: projectium.com

    env:
      DB_HOST: ${{ secrets.DB_HOST }}
      DB_PORT: ${{ secrets.DB_PORT }}
      DB_USER: ${{ secrets.DB_USER }}
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      DB_NAME: ${{ secrets.DB_NAME_PROD }}

    steps:
      - name: Validate Secrets
        run: |
          if [ -z "$DB_HOST" ] || [ -z "$DB_USER" ]; then
            echo "ERROR: Database secrets not configured."
            exit 1
          fi

      - name: Create Database Backup
        run: |
          TIMESTAMP=$(date +'%Y%m%d-%H%M%S')
          BACKUP_FILENAME="flyer-crawler-prod-backup-${TIMESTAMP}.sql.gz"

          # Create compressed backup
          PGPASSWORD="$DB_PASSWORD" pg_dump \
            -h "$DB_HOST" -p "$DB_PORT" \
            -U "$DB_USER" -d "$DB_NAME" \
            --clean --if-exists | gzip > "$BACKUP_FILENAME"

          echo "backup_filename=$BACKUP_FILENAME" >> $GITEA_ENV

      - name: Upload Backup as Artifact
        uses: actions/upload-artifact@v3
        with:
          name: database-backup
          path: ${{ env.backup_filename }}
```

### Restore Workflow

Located in `.gitea/workflows/manual-db-restore.yml`:

```yaml
name: Manual - Restore Database from Backup

on:
  workflow_dispatch:
    inputs:
      confirmation:
        description: 'Type "restore-from-backup" to confirm'
        required: true
      backup_file:
        description: 'Path to backup file on server'
        required: true

jobs:
  restore-database:
    steps:
      - name: Verify Confirmation
        run: |
          if [ "${{ inputs.confirmation }}" != "restore-from-backup" ]; then
            exit 1
          fi

      - name: Restore Database
        run: |
          # Decompress and restore
          gunzip -c "${{ inputs.backup_file }}" | \
            PGPASSWORD="$DB_PASSWORD" psql \
              -h "$DB_HOST" -p "$DB_PORT" \
              -U "$DB_USER" -d "$DB_NAME"
```

### Backup Command Reference

**Manual Backup**:

```bash
# Create compressed backup
PGPASSWORD="password" pg_dump \
  -h localhost -p 5432 \
  -U dbuser -d flyer-crawler \
  --clean --if-exists | gzip > backup-$(date +%Y%m%d).sql.gz

# List backup contents (without restoring)
gunzip -c backup-20260109.sql.gz | head -100
```

**Manual Restore**:

```bash
# Restore from compressed backup
gunzip -c backup-20260109.sql.gz | \
  PGPASSWORD="password" psql \
    -h localhost -p 5432 \
    -U dbuser -d flyer-crawler
```

### pg_dump Options

| Option            | Purpose                        |
| ----------------- | ------------------------------ |
| `--clean`         | Drop objects before recreating |
| `--if-exists`     | Use IF EXISTS when dropping    |
| `--no-owner`      | Skip ownership commands        |
| `--no-privileges` | Skip access privilege commands |
| `-F c`            | Custom format (for pg_restore) |
| `-F p`            | Plain text SQL (default)       |

### Recovery Objectives

| Metric                             | Target   | Current        |
| ---------------------------------- | -------- | -------------- |
| **RPO** (Recovery Point Objective) | 24 hours | Manual trigger |
| **RTO** (Recovery Time Objective)  | 1 hour   | ~15 minutes    |

### Backup Retention Policy

| Type            | Retention | Storage          |
| --------------- | --------- | ---------------- |
| Daily backups   | 7 days    | Gitea artifacts  |
| Weekly backups  | 4 weeks   | Gitea artifacts  |
| Monthly backups | 12 months | Off-site storage |

### Backup Verification

Periodically test backup integrity:

```bash
# Verify backup can be read
gunzip -t backup-20260109.sql.gz

# Test restore to a temporary database
createdb flyer-crawler-restore-test
gunzip -c backup-20260109.sql.gz | psql -d flyer-crawler-restore-test
# Verify data integrity...
dropdb flyer-crawler-restore-test
```

### Disaster Recovery Checklist

1. **Identify the Issue**
   - Data corruption?
   - Accidental deletion?
   - Full database loss?

2. **Select Backup**
   - Find most recent valid backup
   - Download from Gitea artifacts or off-site storage

3. **Stop Application**

   ```bash
   pm2 stop all
   ```

4. **Restore Database**

   ```bash
   gunzip -c backup.sql.gz | psql -d flyer-crawler
   ```

5. **Verify Data**
   - Check table row counts
   - Verify recent data exists
   - Test critical queries

6. **Restart Application**

   ```bash
   pm2 start all
   ```

7. **Post-Mortem**
   - Document incident
   - Update procedures if needed

## Key Files

- `.gitea/workflows/manual-db-backup.yml` - Backup workflow
- `.gitea/workflows/manual-db-restore.yml` - Restore workflow
- `.gitea/workflows/manual-db-reset-test.yml` - Reset test database
- `.gitea/workflows/manual-db-reset-prod.yml` - Reset production database
- `sql/master_schema_rollup.sql` - Current schema definition

## Related ADRs

- [ADR-013](./0013-database-schema-migration-strategy.md) - Schema Migration Strategy
- [ADR-017](./0017-ci-cd-and-branching-strategy.md) - CI/CD Strategy
