Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added

- **Backups & disaster recovery** (#35) — `scripts/backup.sh` (timestamped,
compressed `pg_dump` with retention) and `scripts/restore.sh`, a `make backup`
target, and a DR section in the deployment guide (cron example, Docker variant,
restore drill). All state is in Postgres, so a DB dump is a full backup.

## [1.2.0] - 2026-07-11

### Added
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ help:
@echo " vet fmt go vet / gofmt"
@echo " dist Cross-compile bare release binaries into $(DIST_DIR)/"
@echo " release Cross-compile + tar.gz + SHA256SUMS (VERSION=vX.Y.Z)"
@echo " backup Dump the database via scripts/backup.sh (needs DATABASE_URL)"
@echo " docker-up Start the Postgres dependency via docker compose"
@echo " clean Remove build artifacts"

Expand Down Expand Up @@ -82,6 +83,9 @@ release: clean
@cd $(DIST_DIR) && shasum -a 256 *.tar.gz > $(BINARY)-$(VERSION)-SHA256SUMS.txt
@echo "==> Wrote $(DIST_DIR)/$(BINARY)-$(VERSION)-SHA256SUMS.txt"

backup:
./scripts/backup.sh

docker-up:
docker compose up -d postgres

Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ Things the application can't enforce on your behalf:
- [ ] Set `ADMIN_EMAIL` to the owner's address before first sign-in.
- [ ] The app rate-limits per IP already; for extra depth, also throttle at the
reverse proxy (`rate_limit` in Caddy, `limit_req` in nginx).
- [ ] Schedule an off-host `pg_dump` backup.
- [ ] Schedule an off-host backup with `scripts/backup.sh` (see the deployment
guide's "Backups & disaster recovery") and test a restore periodically.
- [ ] **Never** set `DEV_MODE=true` in production — it unlocks `dev-login` and
relaxes the Secure cookie flag.
38 changes: 38 additions & 0 deletions docs/deployment-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,44 @@ AI data flow, and the full hardening checklist. Not covered here:

---

## Backups & disaster recovery

All state lives in PostgreSQL — the app itself is stateless (templates and assets
are baked into the binary). So a database backup is a full backup.

The repo ships two helpers that wrap `pg_dump`/`pg_restore`:

```bash
# Back up to ./backups (compressed custom-format dump), pruning dumps > 14 days.
DATABASE_URL=postgres://smart360:<pass>@localhost:5432/smart360 ./scripts/backup.sh

# Restore a dump into a target database (drops & recreates objects).
DATABASE_URL=postgres://smart360:<pass>@localhost:5432/smart360 \
./scripts/restore.sh backups/smart360-YYYYMMDDTHHMMSSZ.dump
```

Schedule the backup nightly and **ship the dumps off-host** — they contain all
feedback data. A cron example:

```cron
# /etc/cron.d/smart360-backup (runs 02:30 daily as the smart360 user)
30 2 * * * smart360 DATABASE_URL='postgres://smart360:<pass>@localhost:5432/smart360' BACKUP_DIR=/var/backups/smart360 RETENTION_DAYS=14 /usr/local/lib/smart360/backup.sh >> /var/log/smart360-backup.log 2>&1
```

Docker Compose users can run the same dump against the `postgres` service:

```bash
docker compose -f docker-compose.prod.yml exec -T postgres \
pg_dump --format=custom --no-owner --no-privileges -U smart360 smart360 \
> "smart360-$(date -u +%Y%m%dT%H%M%SZ).dump"
```

Recovery drill: after restoring into a fresh database and pointing `DATABASE_URL`
at it, start the app — it re-applies any pending migrations on boot. **Test a
restore periodically**; an untested backup is a guess, not a recovery plan.

---

## Operational notes

### Updating to a new version
Expand Down
37 changes: 37 additions & 0 deletions scripts/backup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Back up the Smart 360 PostgreSQL database to a timestamped, compressed
# custom-format dump, then prune old dumps.
#
# Usage:
# DATABASE_URL=postgres://user:pass@host:5432/smart360 ./scripts/backup.sh
#
# Env:
# DATABASE_URL required — the Postgres connection string
# BACKUP_DIR output directory (default: ./backups)
# RETENTION_DAYS prune dumps older than this many days (default: 14)
#
# Restore a dump with scripts/restore.sh. Ship dumps off-host (they contain all
# feedback data) and test a restore periodically — an untested backup is a guess.
set -euo pipefail

: "${DATABASE_URL:?set DATABASE_URL to the Postgres connection string}"
BACKUP_DIR="${BACKUP_DIR:-./backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"

command -v pg_dump >/dev/null 2>&1 || {
echo "pg_dump not found — install the PostgreSQL client tools." >&2
exit 1
}

mkdir -p "$BACKUP_DIR"
ts="$(date -u +%Y%m%dT%H%M%SZ)"
out="$BACKUP_DIR/smart360-${ts}.dump"

echo "Backing up to ${out}"
pg_dump --format=custom --no-owner --no-privileges --file="$out" "$DATABASE_URL"
echo "Wrote ${out} ($(du -h "$out" | cut -f1))"

# Prune old dumps (best-effort).
find "$BACKUP_DIR" -maxdepth 1 -name 'smart360-*.dump' -type f -mtime "+${RETENTION_DAYS}" -print -delete \
| sed 's/^/Pruned: /' || true
28 changes: 28 additions & 0 deletions scripts/restore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
#
# Restore a Smart 360 PostgreSQL dump produced by scripts/backup.sh.
#
# Usage:
# DATABASE_URL=postgres://user:pass@host:5432/smart360 \
# ./scripts/restore.sh backups/smart360-YYYYMMDDTHHMMSSZ.dump
#
# WARNING: this drops and recreates the objects in the target database. Point it
# at a fresh/empty database for disaster recovery, or knowingly overwrite.
set -euo pipefail

: "${DATABASE_URL:?set DATABASE_URL to the target Postgres connection string}"
dump="${1:?usage: restore.sh <dump-file>}"

[ -f "$dump" ] || { echo "dump not found: $dump" >&2; exit 1; }
command -v pg_restore >/dev/null 2>&1 || {
echo "pg_restore not found — install the PostgreSQL client tools." >&2
exit 1
}

echo "About to restore ${dump}"
echo " into ${DATABASE_URL%%\?*}"
echo " (existing objects will be dropped and recreated). Ctrl-C within 5s to abort."
sleep 5

pg_restore --clean --if-exists --no-owner --no-privileges --dbname="$DATABASE_URL" "$dump"
echo "Restore complete. The app re-applies any pending migrations on next start."
Loading