Skip to content

feat: automated backups with a tested restore (#379) - #611

Merged
NotYuSheng merged 2 commits into
mainfrom
feature/automated-backups
Jul 28, 2026
Merged

feat: automated backups with a tested restore (#379)#611
NotYuSheng merged 2 commits into
mainfrom
feature/automated-backups

Conversation

@NotYuSheng

Copy link
Copy Markdown
Owner

Problem

Backup was documented but not implemented. backup-restore.rst listed pg_dump and mc mirror commands for a human to run by hand — no schedule, no verification, no rehearsed restore.

The leadership deck ranked this as the top risk ("No automatic backups — a real risk of losing imported data, which matters most here"), and it was the last Stage 1 item still behind that framing.

What's here

scripts/backup.sh — one timestamped archive holding all three things that cannot be regenerated: the PostgreSQL dump (custom format, so pg_restore --clean works), the MinIO objects, and signatures.yml from the config volume, plus a manifest of what was captured.

scripts/restore.sh — restores an archive and verifies rather than assuming: counts the tables actually present afterwards and fails if the database came back empty. Rejects corrupt archives and non-TracePcap tarballs, and warns when the manifest's database name doesn't match the target.

systemd service + timer — nightly at 02:30. Persistent=true so a powered-off night is caught up rather than skipped; RandomizedDelaySec so several hosts don't hit the same NAS simultaneously. Cron equivalent documented.

Two properties that matter more than the happy path

  • Pruning happens only after the new archive passes its size check and a tar -tzf read-back — so a failing run can never destroy your last good backup.
  • Both scripts exit non-zero on failure, so cron and systemd surface a broken backup instead of silently reporting success. Silent backup failure is the classic way this goes wrong.

Credentials come from .env and are passed to mc inside the container rather than interpolated into a shell command — following the fix from #604.

Validation: actually destroyed the data

Not just "the script ran". I uploaded and analysed a PCAP, took a backup, then dropped the public schema and wiped the bucket entirely:

Check Result
After wipe 0 tables, 0 objects
Tables restored 31
Row counts Exactfiles=1 packets=15 conversations=11
API after restart HTTP 200, file list renders
Recovered PCAP Byte-identical (MD5 match)
Corrupt / foreign / missing archive All rejected with clear errors
Retention pruning Dropped a 30-day-old archive, kept current ones

Scope decision: no PITR

The issue title says "with PITR". I've deliberately left WAL archiving out — it roughly doubles the effort and is a poor trade for a single-server deployment whose primary data is re-importable. Losing up to 24h of PCAP analysis is very different from losing a transactional ledger.

The real exposure is called out in the docs: human labels and overrides entered since the last backup cannot be recovered, unlike the PCAPs themselves.

Happy to add PITR as a follow-up if you'd rather have it — hence Refs rather than Closes.

Also documented

RPO (24h) and RTO, a rehearsal procedure operators can follow, and an explicit "What Is Not Covered" section: PITR, Keycloak users (the keycloak_data volume isn't captured), and redundancy — backups protect against deletion, not hardware failure.

The timing figures are labelled as method, not capacity: sub-second on a trivial dataset tells you there's no fixed overhead, not how long real data takes.

Refs #379

🤖 Generated with Claude Code

Backup was documented but not implemented: backup-restore.rst listed pg_dump and
mc mirror commands for a human to run by hand, with no schedule, no verification,
and no rehearsed restore. The leadership deck ranked losing imported data as the
top risk, and this was the last item behind that framing.

  scripts/backup.sh
    One timestamped archive holding all three things that cannot be regenerated:
    the PostgreSQL dump (custom format, so pg_restore --clean works), the MinIO
    objects, and signatures.yml from the config volume. Plus a manifest recording
    what was captured.

  scripts/restore.sh
    Restores an archive, and verifies rather than assuming: it counts the tables
    actually present afterwards and fails if the database came back empty. It
    rejects corrupt archives, tarballs that aren't TracePcap backups, and warns
    when the manifest's database name doesn't match the target.

  systemd service + timer
    Nightly at 02:30, Persistent=true so a powered-off night is caught up rather
    than skipped, RandomizedDelaySec so several hosts don't hit the same NAS at
    once. Cron equivalent documented.

Two properties that matter more than the happy path:

  - Old backups are pruned only AFTER the new archive passes its size check and
    a tar -tzf read-back, so a failing run can never destroy the last good one.
  - Both scripts exit non-zero on failure, so cron and systemd surface a broken
    backup instead of passing over it silently.

Credentials come from .env and are passed to `mc` inside the container rather
than interpolated into a shell command, following the fix from #604.

Validated end to end, not just executed: uploaded and analysed a PCAP, took a
backup, dropped the public schema and wiped the bucket entirely, then restored.
Row counts returned exactly (files=1 packets=15 conversations=11), 31 tables
came back, the API served the file list, and the recovered PCAP was
byte-identical to the original by MD5. Corrupt/foreign/missing archives were all
rejected, and retention pruning kept the current backups while dropping a
30-day-old one.

PITR is deliberately out of scope — WAL archiving is a poor trade for a
single-server deployment whose primary data is re-importable. RPO (24h) and RTO,
and what is NOT covered (PITR, Keycloak users, redundancy), are documented.

Refs #379

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@NotYuSheng, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 08e0280e-7890-41d9-840c-aead5eed07a7

📥 Commits

Reviewing files that changed from the base of the PR and between 5f1c22e and 92f560e.

📒 Files selected for processing (6)
  • .gitignore
  • docs/operations/backup-restore.rst
  • scripts/backup.sh
  • scripts/restore.sh
  • scripts/tracepcap-backup.service
  • scripts/tracepcap-backup.timer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Addresses code review on #611. Two findings were genuine blockers, and one of
them reintroduced on the host exactly the bug #604 fixed for mc.

  .env was SOURCED, not parsed (backup.sh, restore.sh)
    `set -a; source ./.env` executes the file as shell. Verified: a password
    containing $(...) RUNS as a command, and one containing a space aborts the
    script with `spaces: command not found` before any backup happens. .env is
    Compose's format, not bash. Now parsed line by line — no execution path.

    Sourcing also inverted precedence: .env clobbered the real environment, so
    the systemd unit's BACKUP_DIR was silently discarded and every nightly
    archive would land on local disk instead of the off-host storage the docs
    call the whole point. The caller's environment now wins.

  Failed config restore printed NOTHING and still reported success (restore.sh)
    `docker cp … && log "restored"` with no || branch: under set -e a failed
    command in that position neither prints nor exits, so signatures.yml could
    go missing while the operator was told "Restore complete."

Also fixed:
  - Restore now inspects pg_restore stderr, separating real errors from the
    "does not exist" notices --clean always emits. Previously the exit code was
    ignored entirely and a half-restored database still passed the table check.
  - Bucket restore uses --remove, making it the replacement the prompt and docs
    promise rather than a merge that orphans objects the database can't see.
  - Restore compares the bucket against the manifest's object count, so a
    partial transfer fails instead of silently restoring some PCAPs.
  - Backup cross-checks captured objects against the bucket, so a wrong bucket
    name can't produce a "successful" backup containing no PCAPs.
  - Staging moved from /tmp into BACKUP_DIR: it holds an uncompressed copy of
    the whole capture set, and /tmp is tmpfs on many server images.
  - Pruning reads the timestamp in the filename, not mtime, which resets when
    archives are copied to a NAS and would let BACKUP_DIR grow unbounded.
  - flock guards concurrent runs (timer + manual) from truncating one archive.
  - Service: Restart=on-failure for boot-time catch-up runs that fire before the
    containers are ready, and the [Install] section removed so enabling the
    service alongside the timer can't add a backup on every boot.
  - .gitignore: anchor /backups/ to the repo root.

Verified: hostile .env ($(...) and spaces) completes with no execution; env
overrides .env; full destroy/restore cycle still returns exact row counts and a
byte-identical PCAP; tampered manifest and wrong bucket both rejected; pruning
removes an old-named archive with a fresh mtime; both systemd units pass
systemd-analyze verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NotYuSheng
NotYuSheng merged commit 511aea1 into main Jul 28, 2026
3 checks passed
@NotYuSheng
NotYuSheng deleted the feature/automated-backups branch July 28, 2026 03:55
NotYuSheng added a commit that referenced this pull request Jul 28, 2026
production-readiness.rst and security-audit.rst were project tracking rendered
as documentation: a Stage 1 checklist with Open/Done/Partial columns, effort
estimates in weeks, and ~60 issue references between them. That belongs in the
issue tracker, not on a site whose other pages tell an operator how to run the
thing — and it goes stale on every merge. Both were already wrong, listing
backups and credential rotation as open after #611 and #595 shipped.

The analysis is not lost: it lives in #364, #365 and #366 where it originated.

Kept the operator-facing half of security-audit.rst — its "Deployment
Requirements" list is real guidance, not status, so it moves to
production-hardening.rst as an up-front checklist (issue references stripped,
since each item is already documented in detail on that page).

scalability.rst and storage-redundancy.rst stay. Those are reference material an
operator needs — rows per GB, indexing, partitioning, the MinIO SPOF and its
SNMD/MNMD upgrade paths — with almost no status language, and backup-restore.rst
links to both.

Verified: no dangling :doc: references remain and Sphinx builds with no new
warnings.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant