Retention Policies
Configure how long audit entries are kept, schedule automated purges, archive entries before deletion, and implement legal holds — all covered on this page.
Configuring retention windows
Section titled “Configuring retention windows”Set a retention window when creating your audit instance. Entries older than the configured number of days become eligible for purging:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders", "payments"], retention: { days: 365 },});| Option | Type | Default | Description |
|---|---|---|---|
days | number | — | Required. Purge entries older than this many days. Must be a positive integer. |
tables | string[] | all tables | When set, only purge entries for these specific tables. |
Table-scoped retention
Section titled “Table-scoped retention”Different tables may have different compliance requirements. Use tables to scope the retention policy to specific tables:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders", "sessions", "api_requests"], retention: { days: 90, tables: ["sessions", "api_requests"], },});Entries for tables not in the list are kept indefinitely. To purge all audited tables, omit tables.
Common retention windows
Section titled “Common retention windows”| Regulation | Typical requirement | Suggested days |
|---|---|---|
| SOC 2 | 1 year | 365 |
| GDPR | As short as possible (data minimization) | 90–365 |
| HIPAA | 6 years | 2190 |
| PCI DSS | 1 year (3 months immediately accessible) | 365 |
| Internal ops | Varies | 30–90 |
Automated purge scheduling
Section titled “Automated purge scheduling”The retention config declares the rules. To actually delete old entries, run the purge command via the CLI:
# Preview what would be deleted (safe — no changes made)npx @usebetterdev/audit-cli purge --dry-run
# Delete entries older than the configured retention periodnpx @usebetterdev/audit-cli purge --yesPurge CLI reference
Section titled “Purge CLI reference”| Flag | Default | Description |
|---|---|---|
--database-url <url> | $DATABASE_URL | Connection string to your database. |
--since <value> | from config | Override the cutoff for this run. Accepts ISO dates (2025-01-01) or duration shorthands (90d, 4w, 3m, 1y). |
--batch-size <n> | 1000 | Rows per DELETE batch. |
--dry-run | false | Print the number of eligible rows without deleting anything. |
--yes | false | Skip the confirmation prompt. Required for non-interactive use. |
Setting up a cron job
Section titled “Setting up a cron job”Schedule the purge command to run automatically. The --yes flag is required for non-interactive execution:
name: Audit log purgeon: schedule: - cron: "0 3 * * *" # Daily at 3 AM UTC
jobs: purge: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: npx @usebetterdev/audit-cli purge --yes env: DATABASE_URL: ${{ secrets.DATABASE_URL }}# Daily at 3 AM — purge audit entries older than the configured retention period0 3 * * * cd /path/to/project && DATABASE_URL="postgresql://..." npx @usebetterdev/audit-cli purge --yes >> /var/log/audit-purge.log 2>&1How purge works
Section titled “How purge works”The CLI uses batched deletes (1,000 rows per batch by default) to avoid holding long row-level locks on large tables. Each batch runs a DELETE … WHERE id IN (SELECT id … LIMIT n) query, which works across PostgreSQL, MySQL, and SQLite. Progress is reported to stderr every 10 batches.
Priority for resolving the cutoff date:
--sinceflag (if provided)retention.daysfrom yourbetter.configfile
If neither is set, the command exits with: No retention policy configured. Pass --since <date|duration> or set audit.retention.days in your better.config file.
Archiving strategies
Section titled “Archiving strategies”For regulations that require long-term access to historical entries, archive before purging.
Export before purge
Section titled “Export before purge”Use the CLI export command to save entries before they are deleted:
# Export entries older than 1 year to a JSON file, then purge themnpx @usebetterdev/audit-cli export \ --since 365d \ --format json \ -o archive-older-than-365d.json \ --database-url $DATABASE_URL
npx @usebetterdev/audit-cli purge --since 365d --yes --database-url $DATABASE_URLArchive-then-purge script
Section titled “Archive-then-purge script”Combine export and purge into a single script for your CI pipeline:
#!/usr/bin/env bashset -euo pipefail
ARCHIVE_DIR="./audit-archives"DATE=$(date -u +%Y-%m-%d)RETENTION_DAYS=365# GNU date || macOS dateCUTOFF=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%dT00:00:00Z 2>/dev/null \ || date -u -v-${RETENTION_DAYS}d +%Y-%m-%dT00:00:00Z)
mkdir -p "$ARCHIVE_DIR"
echo "Exporting entries older than ${RETENTION_DAYS} days..."npx @usebetterdev/audit-cli export \ --to "$CUTOFF" \ --format json \ -o "${ARCHIVE_DIR}/audit-archive-${DATE}.json" \ --database-url "$DATABASE_URL"
echo "Purging exported entries..."npx @usebetterdev/audit-cli purge \ --since "${RETENTION_DAYS}d" \ --yes \ --database-url "$DATABASE_URL"
echo "Done. Archive saved to ${ARCHIVE_DIR}/audit-archive-${DATE}.json"Cold storage recommendations
| Storage tier | Use case | Example |
|---|---|---|
| Hot (database) | Active queries, dashboards, real-time alerts | Your primary database |
| Warm (object storage) | Compliance audits, investigation lookbacks | S3 Standard, GCS Standard |
| Cold (glacier) | Long-term legal retention | S3 Glacier, GCS Archive |
Move archives to progressively cheaper storage tiers as they age. Most compliance audits only need warm-tier access.
Legal hold patterns
Section titled “Legal hold patterns”A legal hold suspends normal retention rules for entries relevant to an ongoing investigation or litigation. While Better Audit does not enforce legal holds at the database level, you can implement them using compliance tags and scoped retention.
Tag entries for legal hold
Section titled “Tag entries for legal hold”Use enrichment to tag entries that should be preserved:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders", "payments"], retention: { days: 90, tables: ["orders"], // Only auto-purge orders },});
// Tag sensitive operations for legal preservationaudit.enrich("users", "DELETE", { label: "User account deleted", severity: "critical", compliance: ["gdpr", "legal-hold"],});
audit.enrich("payments", "*", { severity: "high", compliance: ["pci", "legal-hold"],});Exclude held entries from purge
Section titled “Exclude held entries from purge”Scope your retention policy to only purge tables that are not under legal hold. Tables not listed in retention.tables are kept indefinitely:
import { betterAudit } from "@usebetterdev/audit";import { drizzleAuditAdapter } from "@usebetterdev/audit/drizzle";
const audit = betterAudit({ database: drizzleAuditAdapter(db), auditTables: ["users", "orders", "payments", "sessions", "api_requests"], retention: { days: 90, tables: ["sessions", "api_requests"], // Only purge these tables }, // users, orders, payments → kept indefinitely (legal hold)});Export held entries for legal review
Section titled “Export held entries for legal review”Use compliance tag filtering to extract entries relevant to a legal matter:
# Export all entries tagged with legal-holdnpx @usebetterdev/audit-cli export \ --compliance legal-hold \ --format json \ -o legal-hold-export.json \ --database-url $DATABASE_URL
# Export legal-hold entries for a specific actornpx @usebetterdev/audit-cli export \ --compliance legal-hold \ --actor user-42 \ --format json \ -o legal-hold-user-42.json \ --database-url $DATABASE_URLLifting a legal hold
Section titled “Lifting a legal hold”When the hold is lifted, remove the table exclusion from your retention config so those entries become eligible for normal purging again:
-
Confirm with your legal team that the hold can be released.
-
Update your retention config to include the previously held tables:
src/audit.ts const audit = betterAudit({database: drizzleAuditAdapter(db),auditTables: ["users", "orders", "payments", "sessions", "api_requests"],retention: {days: 90,// tables omitted → all audited tables are now eligible for purging},}); -
Run a dry-run to verify what will be purged:
Terminal window npx @usebetterdev/audit-cli purge --dry-run -
Export a final archive of the held entries before purging:
Terminal window npx @usebetterdev/audit-cli export \--compliance legal-hold \--format json \-o legal-hold-final-archive.json \--database-url $DATABASE_URL -
Run the purge:
Terminal window npx @usebetterdev/audit-cli purge --yes