PostgreSQL Point-in-Time Recovery (PITR) Guide¶
Last Updated: 2025-12-31
Table of Contents¶
- Overview
- Architecture
- Onboarding - Initial Setup
- Daily Operations & Maintenance
- Backup Management
- Disaster Recovery
- Monitoring & Troubleshooting
- Advanced Topics
Overview¶
This guide covers the PostgreSQL PITR (Point-in-Time Recovery) module, which provides enterprise-grade backup and disaster recovery capabilities for PostgreSQL databases in your NixOS homelab.
Key Features¶
- Continuous WAL Archiving: Automatic write-ahead log archiving for minimal data loss (5-minute RPO by default)
- Automated Base Backups: Daily full database backups via
pg_basebackup - Point-in-Time Recovery: Restore to any point in time, specific transaction, or named restore point
- Multi-Method Restore: Self-healing with syncoid → local snapshots → Restic fallback
- ZFS Integration: Optimal dataset configuration (8K recordsize for data, 128K for WAL)
- Health Monitoring: Prometheus metrics and automated health checks
- Notification Integration: Systemd OnFailure hooks with centralized alerting
Architecture Highlights¶
- Storage: ZFS datasets with optimal properties for PostgreSQL workloads
- Backup: Restic for encrypted, deduplicated offsite storage
- Replication: Sanoid/Syncoid for ZFS snapshot replication to nas-1
- Recovery: Three-tier restore strategy with configurable methods
Architecture¶
Component Overview¶
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL Instance │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Data Directory │ │ WAL Archive Dir │ │
│ │ tank/services/ │ │ tank/services/ │ │
│ │ postgresql/main │ │ postgresql/ │ │
│ │ │ │ main-wal │ │
│ │ (8K recordsize) │ │ (128K recordsize)│ │
│ └─────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │
│ │
├──────────────┬───────────────┤
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────────┐
│ Sanoid │ │ Restic │ │ Prometheus │
│ ZFS │ │ Backup │ │ Metrics │
│ Snapshots│ │ (NAS) │ │ │
└─────────┘ └──────────┘ └──────────────┘
│ │
▼ ▼
┌─────────────────────────────┐
│ nas-1.holthome.net │
│ ZFS Replication Target │
│ Restic Repository │
└─────────────────────────────┘
Storage Layout¶
tank/services/postgresql/main/ # Main data directory
├── base/ # Database files (8K blocks)
├── global/ # Cluster-wide tables
├── pg_wal/ # Write-ahead logs (symlink or local)
└── postgresql.conf
tank/services/postgresql/main-wal/ # WAL archive directory
├── incoming/ # Temporary WAL staging
│ └── 000000010000000000000001.tmp
└── 000000010000000000000001 # Archived WAL files
/var/backup/postgresql/main/ # Base backup output
└── 20251011-120000/ # Timestamped base backups
├── base.tar.gz
└── pg_wal.tar.gz
Onboarding - Initial Setup¶
Prerequisites¶
Before deploying PostgreSQL with PITR, ensure:
- ZFS Pools:
tankpool exists and is healthy - Backup Repository:
primaryRepoconfigured inhosts/forge/default.nix - Secrets: Restic password configured in SOPS (
restic/password) - Network: NFS mount for nas-1 configured (if using preseed)
- Monitoring: Node exporter enabled for metrics collection
Step 1: Basic PostgreSQL Instance¶
Add to your hosts/forge/default.nix:
modules.services.postgresql.instances.main = {
enable = true;
version = "16"; # PostgreSQL 16 (default)
port = 5432;
# Memory tuning (adjust based on available RAM)
sharedBuffers = "2GB"; # 25% of RAM
effectiveCacheSize = "6GB"; # 75% of RAM
workMem = "64MB"; # For complex queries
maintenanceWorkMem = "512MB"; # For VACUUM, CREATE INDEX
# Create initial databases
databases = [ "app1" "app2" ];
# Backup configuration
backup = {
walArchive.enable = true;
walArchive.syncInterval = "*/5"; # 5-minute RPO
walArchive.retentionDays = 30;
baseBackup.enable = true;
baseBackup.schedule = "daily"; # Daily full backups
restic = {
enable = true;
repositoryName = primaryRepo.name;
repositoryUrl = primaryRepo.url;
passwordFile = primaryRepo.passwordFile;
};
};
# Health checks
healthCheck.enable = true;
healthCheck.interval = "1min";
# Monitoring integration
monitoring.enable = true;
};
Step 2: Deploy and Verify¶
# Build and deploy
cd /Users/ryan/src/nix-config
nixos-rebuild switch --flake .#forge --target-host forge.holthome.net
# Verify PostgreSQL is running
ssh forge.holthome.net "sudo systemctl status postgresql"
# Check health
ssh forge.holthome.net "sudo systemctl status postgresql-healthcheck-main.timer"
ssh forge.holthome.net "sudo journalctl -u postgresql-healthcheck-main -n 20"
# Verify datasets were created
ssh forge.holthome.net "zfs list | grep postgresql"
# Expected output:
# tank/services/postgresql/main 15.2G 900G 15.2G /var/lib/postgresql/16/main
# tank/services/postgresql/main-wal 128K 900G 128K /var/lib/postgresql/16/main-wal-archive
Step 3: Verify Backup Jobs¶
# Check Restic backup jobs are registered
ssh forge.holthome.net "systemctl list-timers | grep postgresql"
# Expected:
# postgresql-basebackup-main.timer
# postgresql-healthcheck-main.timer
# postgresql-walprune-main.timer
# restic-backups-postgresql-main-wal.timer
# restic-backups-postgresql-main-base.timer
# Manually trigger first WAL backup
ssh forge.holthome.net "sudo systemctl start restic-backups-postgresql-main-wal"
# Check backup job status
ssh forge.holthome.net "sudo journalctl -u restic-backups-postgresql-main-wal -f"
Step 4: Create Test Database¶
# Connect to PostgreSQL
ssh forge.holthome.net "sudo -u postgres psql"
# Create test data
CREATE TABLE test_pitr (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP DEFAULT NOW(),
data TEXT
);
INSERT INTO test_pitr (data) VALUES ('Initial data - ' || NOW());
# Verify
SELECT * FROM test_pitr;
\q
Step 5: Verify WAL Archiving¶
# Check WAL archive directory
ssh forge.holthome.net "sudo ls -lh /var/lib/postgresql/16/main-wal-archive/"
# Check PostgreSQL is archiving
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT pg_switch_wal();'"
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT archived_count, failed_count FROM pg_stat_archiver;'"
Daily Operations & Maintenance¶
Monitoring Health¶
Check Service Status¶
# PostgreSQL main service
ssh forge.holthome.net "sudo systemctl status postgresql"
# Health check timer
ssh forge.holthome.net "sudo systemctl status postgresql-healthcheck-main.timer"
# Backup timers
ssh forge.holthome.net "systemctl list-timers | grep postgresql"
Prometheus Metrics¶
Metrics are exported to /var/lib/node_exporter/textfile_collector/:
# View current metrics
ssh forge.holthome.net "cat /var/lib/node_exporter/textfile_collector/postgresql_health.prom"
ssh forge.holthome.net "cat /var/lib/node_exporter/textfile_collector/postgresql_backup.prom"
ssh forge.holthome.net "cat /var/lib/node_exporter/textfile_collector/postgresql_wal_archive.prom"
Key Metrics:
- postgresql_health{instance="main"} - 1 = healthy, 0 = unhealthy
- postgresql_backup_last_success_timestamp - Unix timestamp of last successful backup
- postgresql_wal_archive_files_present - Number of WAL files in archive
- postgresql_wal_archive_last_success_timestamp - Last successful WAL archive
Routine Maintenance Tasks¶
Weekly: Check Backup Status¶
# Check last 7 days of base backups
ssh forge.holthome.net "ls -lh /var/backup/postgresql/main/ | tail -7"
# Verify Restic snapshots
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots --tag postgresql --tag main"
# Check for failed backups in last week
ssh forge.holthome.net "sudo journalctl --since '7 days ago' -u restic-backups-postgresql-main-base | grep -i error"
Monthly: Verify Recovery Capability¶
# Test WAL restore (non-destructive)
ssh forge.holthome.net "sudo systemctl start postgresql-healthcheck-main"
ssh forge.holthome.net "sudo journalctl -u postgresql-healthcheck-main -n 50"
# Verify replication to nas-1 (if configured)
ssh forge.holthome.net "zfs list -t snapshot tank/services/postgresql/main | tail -5"
ssh nas-1.holthome.net "zfs list -t snapshot backup/forge/services/postgresql/main | tail -5"
Quarterly: Full Recovery Test¶
Perform a full disaster recovery test on a separate test system (see Disaster Recovery section).
Database Maintenance¶
Vacuum and Analyze¶
# Manual VACUUM ANALYZE
ssh forge.holthome.net "sudo -u postgres vacuumdb --all --analyze --verbose"
# Check for bloat
ssh forge.holthome.net "sudo -u postgres psql" <<EOF
SELECT schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;
EOF
Connection Monitoring¶
# Check active connections
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT count(*) FROM pg_stat_activity;'"
# Check for long-running queries
ssh forge.holthome.net "sudo -u postgres psql" <<EOF
SELECT pid, age(clock_timestamp(), query_start), usename, query
FROM pg_stat_activity
WHERE query != '<IDLE>' AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY query_start DESC;
EOF
Backup Management¶
Understanding Backup Components¶
1. WAL Archives (Continuous)¶
- Location:
tank/services/postgresql/main-wal/ - Frequency: Every time a WAL segment fills (typically 1-5 minutes)
- Restic Job:
restic-backups-postgresql-main-wal(runs every 5 minutes) - Retention: 30 days in local archive, longer in Restic
Purpose: Enables point-in-time recovery between base backups.
2. Base Backups (Daily)¶
- Location:
/var/backup/postgresql/main/ - Frequency: Daily (configurable via
backup.baseBackup.schedule) - Restic Job:
restic-backups-postgresql-main-base(runs daily) - Method:
pg_basebackupwith compression
Purpose: Full database snapshot for fast recovery.
3. ZFS Snapshots (Hourly)¶
- Location:
tank/services/postgresql/main@sanoid_* - Frequency: Hourly (via Sanoid)
- Retention: 24h/7d/4w (configurable)
- Replication: Synced to nas-1 via Syncoid
Purpose: Fast local recovery, disaster recovery via replication.
Manual Backup Operations¶
Trigger Immediate Base Backup¶
# Start base backup manually
ssh forge.holthome.net "sudo systemctl start postgresql-basebackup-main"
# Watch progress
ssh forge.holthome.net "sudo journalctl -u postgresql-basebackup-main -f"
# Verify completion
ssh forge.holthome.net "ls -lh /var/backup/postgresql/main/ | tail -1"
Force WAL Switch and Archive¶
# Force PostgreSQL to switch to new WAL file
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT pg_switch_wal();'"
# Manually trigger WAL sync to Restic
ssh forge.holthome.net "sudo systemctl start restic-backups-postgresql-main-wal"
Create Named Restore Point¶
# Create a restore point (useful before major operations)
ssh forge.holthome.net "sudo -u postgres psql" <<EOF
SELECT pg_create_restore_point('before_major_upgrade');
EOF
# View restore points in logs
ssh forge.holthome.net "sudo grep 'restore point' /var/lib/postgresql/16/main/log/postgresql-*.log | tail -5"
Backup Verification¶
Verify Base Backup Integrity¶
# Check last base backup
BACKUP_PATH=$(ssh forge.holthome.net "ls -td /var/backup/postgresql/main/* | head -1")
ssh forge.holthome.net "sudo pg_verifybackup $BACKUP_PATH"
List Restic Snapshots¶
# All PostgreSQL snapshots
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots --tag postgresql --tag main"
# WAL archive snapshots (last 10)
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots --tag wal-archive --tag main --last 10"
# Base backup snapshots
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots --tag base-backup --tag main"
Test WAL Restore¶
# Find a specific WAL file in Restic
WAL_FILE="000000010000000000000042"
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup find $WAL_FILE --json"
# Verify it can be restored (dry run)
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup restore latest --target /tmp/test-restore --include '**/$WAL_FILE'"
ssh forge.holthome.net "ls -lh /tmp/test-restore"
ssh forge.holthome.net "sudo rm -rf /tmp/test-restore"
Disaster Recovery¶
Recovery Scenarios¶
- Minor Data Loss: Restore from recent ZFS snapshot (fastest, < 5 min)
- Database Corruption: Point-in-time recovery from base backup + WAL replay
- Complete Server Loss: Rebuild on new hardware with preseed automation
- Data Center Disaster: Restore from offsite Restic repository
Scenario 1: Quick Recovery from ZFS Snapshot¶
Use Case: Accidental DROP TABLE, unwanted data modification, application bug.
RPO: Up to 1 hour (last hourly snapshot) RTO: 5-10 minutes
# 1. Stop PostgreSQL
ssh forge.holthome.net "sudo systemctl stop postgresql"
# 2. List available snapshots
ssh forge.holthome.net "zfs list -t snapshot tank/services/postgresql/main"
# 3. Choose a snapshot (e.g., 1 hour ago)
SNAPSHOT="tank/services/postgresql/main@sanoid_2025-10-11_14:00:00_hourly"
# 4. Rollback to snapshot
ssh forge.holthome.net "sudo zfs rollback -r $SNAPSHOT"
# 5. Start PostgreSQL
ssh forge.holthome.net "sudo systemctl start postgresql"
# 6. Verify database state
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT NOW();'"
Scenario 2: Point-in-Time Recovery (PITR)¶
Use Case: Need to restore to exact moment before incident, or specific transaction.
RPO: 5 minutes (WAL archive interval) RTO: 30-60 minutes
Step 1: Stop PostgreSQL and Backup Current State¶
# Stop PostgreSQL
ssh forge.holthome.net "sudo systemctl stop postgresql"
# Backup current data directory (just in case)
ssh forge.holthome.net "sudo mv /var/lib/postgresql/16/main /var/lib/postgresql/16/main.backup.$(date +%Y%m%d-%H%M%S)"
Step 2: Restore Base Backup¶
# Find latest base backup before incident
ssh forge.holthome.net "ls -lt /var/backup/postgresql/main/"
# Restore from base backup
BASE_BACKUP="/var/backup/postgresql/main/20251011-020000"
ssh forge.holthome.net "sudo mkdir -p /var/lib/postgresql/16/main"
ssh forge.holthome.net "sudo tar -xzf $BASE_BACKUP/base.tar.gz -C /var/lib/postgresql/16/main"
ssh forge.holthome.net "sudo chown -R postgres:postgres /var/lib/postgresql/16/main"
Step 3: Configure Recovery¶
# Create recovery configuration
ssh forge.holthome.net "sudo tee /var/lib/postgresql/16/main/recovery.signal" <<EOF
# Recovery signal file created by disaster recovery process
EOF
# Option A: Recover to specific time
ssh forge.holthome.net "sudo -u postgres psql -d postgres -c \"
ALTER SYSTEM SET recovery_target_time = '2025-10-11 14:30:00';
ALTER SYSTEM SET recovery_target_action = 'promote';
\""
# Option B: Recover to transaction ID
# ssh forge.holthome.net "sudo -u postgres psql -d postgres -c \"
# ALTER SYSTEM SET recovery_target_xid = '12345678';
# ALTER SYSTEM SET recovery_target_action = 'promote';
# \""
# Option C: Recover to named restore point
# ssh forge.holthome.net "sudo -u postgres psql -d postgres -c \"
# ALTER SYSTEM SET recovery_target_name = 'before_major_upgrade';
# ALTER SYSTEM SET recovery_target_action = 'promote';
# \""
Step 4: Start Recovery¶
# Start PostgreSQL in recovery mode
ssh forge.holthome.net "sudo systemctl start postgresql"
# Monitor recovery progress
ssh forge.holthome.net "sudo tail -f /var/lib/postgresql/16/main/log/postgresql-*.log"
# Look for:
# - "starting point-in-time recovery"
# - "restored log file from archive"
# - "recovery stopping before commit"
# - "database system is ready to accept connections"
Step 5: Verify and Promote¶
# Check if in recovery mode
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT pg_is_in_recovery();'"
# Should show 'f' (false) after promotion
# Verify data
ssh forge.holthome.net "sudo -u postgres psql -d app1 -c 'SELECT NOW(), COUNT(*) FROM your_table;'"
# Clean up backup
# ssh forge.holthome.net "sudo rm -rf /var/lib/postgresql/16/main.backup.*"
Scenario 3: Complete Node Rebuild with Preseed¶
Use Case: Server hardware failure, complete OS reinstallation, migration to new hardware.
RPO: 5 minutes (last WAL archive) RTO: 2-4 hours (includes OS installation)
Prerequisites¶
Enable preseed in your configuration:
modules.services.postgresql.instances.main = {
# ... existing config ...
preseed = {
enable = true; # Enable self-healing restore
restoreMethods = [ "syncoid" "local" "restic" ];
# Restic config for disaster recovery
repositoryUrl = primaryRepo.url;
passwordFile = primaryRepo.passwordFile;
environmentFile = config.sops.secrets."restic/env".path;
};
};
Step 1: Rebuild Node¶
# 1. Reinstall NixOS on new hardware (or existing hardware)
# 2. Ensure network connectivity and NFS mounts are working
# 3. Verify nas-1 is reachable
ping nas-1.holthome.net
# 4. Deploy configuration
nixos-rebuild switch --flake .#forge --target-host forge.holthome.net
Step 2: Preseed Automatic Restore¶
The preseed service will automatically run before PostgreSQL starts and attempt restore:
# Monitor preseed service
ssh forge.holthome.net "sudo journalctl -u preseed-postgresql-main -f"
# Restore hierarchy:
# 1. Try syncoid from nas-1 (fastest, full dataset)
# 2. Try local ZFS snapshot rollback (if any exist)
# 3. Try Restic restore (slowest, but works from offsite)
# 4. Create empty dataset (allows service to start)
Step 3: Manual Intervention (if needed)¶
If preseed fails or you need specific restore point:
# Check preseed status
ssh forge.holthome.net "sudo systemctl status preseed-postgresql-main"
# View logs for errors
ssh forge.holthome.net "sudo journalctl -u preseed-postgresql-main | tail -100"
# Manual Restic restore
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup restore latest --target /var/lib/postgresql/16/main --tag base-backup --tag main"
# Fix ownership
ssh forge.holthome.net "sudo chown -R postgres:postgres /var/lib/postgresql/16/main"
Step 4: Verify and Resume Operations¶
# Check PostgreSQL started
ssh forge.holthome.net "sudo systemctl status postgresql"
# Verify database accessibility
ssh forge.holthome.net "sudo -u postgres psql -l"
# Check data integrity
ssh forge.holthome.net "sudo -u postgres psql -d app1 -c 'SELECT COUNT(*) FROM your_critical_table;'"
# Resume normal backups
ssh forge.holthome.net "sudo systemctl start postgresql-basebackup-main"
Scenario 4: Restore from Offsite Restic Only¶
Use Case: All local storage lost, replication unavailable, nas-1 down.
RPO: Up to 24 hours (last base backup) + 5 minutes (WAL) RTO: 2-6 hours
# 1. Deploy base PostgreSQL configuration
nixos-rebuild switch --flake .#forge --target-host forge.holthome.net
# 2. Stop PostgreSQL
ssh forge.holthome.net "sudo systemctl stop postgresql"
# 3. List available Restic snapshots
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots --tag base-backup --tag main"
# 4. Restore latest base backup
SNAPSHOT_ID="abc123def"
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup restore $SNAPSHOT_ID --target /var/lib/postgresql/16/main"
# 5. Restore WAL archives for PITR
ssh forge.holthome.net "sudo mkdir -p /var/lib/postgresql/16/main-wal-archive"
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup restore latest --target /var/lib/postgresql/16/main-wal-archive --tag wal-archive --tag main"
# 6. Configure recovery (see Scenario 2, Step 3)
# 7. Fix permissions
ssh forge.holthome.net "sudo chown -R postgres:postgres /var/lib/postgresql/16/main"
ssh forge.holthome.net "sudo chown -R postgres:postgres /var/lib/postgresql/16/main-wal-archive"
# 8. Start PostgreSQL
ssh forge.holthome.net "sudo systemctl start postgresql"
Monitoring & Troubleshooting¶
Common Issues and Solutions¶
Issue: WAL Archive Not Growing¶
Symptoms: WAL files stuck in pg_wal/, not being archived.
Diagnosis:
# Check archive status
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT * FROM pg_stat_archiver;'"
# Look at failed_count - should be 0
# Check recent logs
ssh forge.holthome.net "sudo journalctl -u postgresql | grep -i archive | tail -20"
# Check WAL archive directory
ssh forge.holthome.net "sudo ls -lh /var/lib/postgresql/16/main-wal-archive/"
Solution:
# Check disk space
ssh forge.holthome.net "df -h /var/lib/postgresql"
# Check permissions
ssh forge.holthome.net "sudo ls -ld /var/lib/postgresql/16/main-wal-archive"
# Should be postgres:postgres with 0700
# Manually test archive command
ssh forge.holthome.net "sudo -u postgres /nix/store/.../pg-archive-wal-main /var/lib/postgresql/16/main/pg_wal/000000010000000000000001 000000010000000000000001"
# Check metrics
ssh forge.holthome.net "cat /var/lib/node_exporter/textfile_collector/postgresql_wal_archive.prom"
Issue: Base Backup Fails¶
Symptoms: postgresql-basebackup-main service fails.
Diagnosis:
# Check service status
ssh forge.holthome.net "sudo systemctl status postgresql-basebackup-main"
# View full logs
ssh forge.holthome.net "sudo journalctl -u postgresql-basebackup-main -n 100"
# Check backup directory
ssh forge.holthome.net "sudo ls -lh /var/backup/postgresql/main/"
ssh forge.holthome.net "df -h /var/backup"
Common Causes & Solutions:
- Disk Space: Free up space on
/var/backup - PostgreSQL Busy: Retry during lower load period
- Permissions:
sudo chown -R postgres:postgres /var/backup/postgresql - Connection Issues: Check PostgreSQL is accepting local connections
Issue: Restic Backup Fails¶
Symptoms: restic-backups-postgresql-main-wal or -base fails.
Diagnosis:
# Check Restic job status
ssh forge.holthome.net "sudo systemctl status restic-backups-postgresql-main-wal"
ssh forge.holthome.net "sudo journalctl -u restic-backups-postgresql-main-wal -n 50"
# Test Restic repository access
ssh forge.holthome.net "sudo restic -r /mnt/nas-backup snapshots | head -20"
# Check NFS mount
ssh forge.holthome.net "mount | grep nas-backup"
ssh forge.holthome.net "df -h /mnt/nas-backup"
Common Solutions:
- NFS Mount:
sudo systemctl restart mnt-nas\x2dbackup.mount - Repository Locked:
sudo restic -r /mnt/nas-backup unlock - Password Issue: Verify SOPS secret is correct
- Network: Check nas-1 is reachable
Issue: Recovery Hangs or Fails¶
Symptoms: PostgreSQL stuck in recovery mode, won't promote.
Diagnosis:
# Check if still in recovery
ssh forge.holthome.net "sudo -u postgres psql -c 'SELECT pg_is_in_recovery();'"
# View recovery progress
ssh forge.holthome.net "sudo tail -f /var/lib/postgresql/16/main/log/postgresql-*.log"
# Check WAL restore
ssh forge.holthome.net "sudo ls -lh /var/lib/postgresql/16/main-wal-archive/ | wc -l"
Solutions:
- Missing WAL Files: Check Restic has required WAL segments
- Incorrect Target: Verify
recovery_target_*settings - Permissions: Ensure postgres can read WAL archive directory
- Force Promote:
SELECT pg_wal_replay_resume();thenSELECT pg_promote();
Performance Tuning¶
Slow Queries¶
# Enable slow query logging
ssh forge.holthome.net "sudo -u postgres psql" <<EOF
ALTER SYSTEM SET log_min_duration_statement = 1000; -- Log queries > 1s
SELECT pg_reload_conf();
EOF
# View slow queries
ssh forge.holthome.net "sudo grep 'duration:' /var/lib/postgresql/16/main/log/postgresql-*.log | tail -20"
Connection Pool Exhaustion¶
# Increase max_connections in configuration
modules.services.postgresql.instances.main = {
maxConnections = 200; # Increase from default 100
};
Checkpoint Tuning¶
If you see frequent checkpoint warnings:
extraSettings = {
checkpoint_timeout = "30min"; # Increase from 15min
max_wal_size = "4GB"; # Increase from 2GB
};
Advanced Topics¶
Multi-Instance Management¶
The module fully supports multiple PostgreSQL instances on a single host. Each instance runs independently with separate data directories, ZFS datasets, backup jobs, and systemd services.
Example: Multiple Instances on One Host¶
modules.services.postgresql.instances = {
# Production instance
main = {
enable = true;
version = "16";
port = 5432;
databases = [ "app1" "app2" ];
sharedBuffers = "4GB";
effectiveCacheSize = "12GB";
backup = {
enable = true;
restic.repositoryName = primaryRepo.name;
walArchive.enable = true;
baseBackup.enable = true;
};
preseed.enable = false; # Safety first
healthCheck.enable = true;
monitoring.enable = true;
};
# Development instance
dev = {
enable = true;
version = "15"; # Different version OK
port = 5433; # MUST be unique per instance
databases = [ "dev_db" "test_db" ];
sharedBuffers = "1GB"; # Lower resources
effectiveCacheSize = "3GB";
backup = {
enable = true;
restic.repositoryName = primaryRepo.name;
walArchive = {
enable = true;
syncInterval = "*/15"; # Less frequent OK for dev
retentionDays = 7; # Shorter retention
};
baseBackup = {
enable = true;
schedule = "daily";
};
};
preseed.enable = false;
healthCheck.enable = true;
};
# Analytics instance
analytics = {
enable = true;
version = "16";
port = 5434; # MUST be unique
databases = [ "warehouse" "metrics" ];
sharedBuffers = "8GB"; # Higher for analytics workload
effectiveCacheSize = "24GB";
workMem = "256MB"; # Larger for complex queries
backup = {
enable = true;
restic.repositoryName = secondaryRepo.name; # Different repo OK
walArchive.enable = true;
baseBackup.schedule = "daily";
};
preseed.enable = false;
healthCheck.enable = true;
};
};
What Gets Created Per Instance¶
For each instance (e.g., main, dev, analytics):
ZFS Datasets:
- tank/services/postgresql/main (8K recordsize, lz4 compression)
- tank/services/postgresql/main-wal (128K recordsize, logbias=throughput)
Directories:
- /var/lib/postgresql/16/main/ (data directory)
- /var/lib/postgresql/16/main-wal-archive/ (WAL archive)
- /var/backup/postgresql/main/ (base backup output)
Systemd Services:
- postgresql-main.service
- postgresql-basebackup-main.service
- postgresql-basebackup-main.timer
- postgresql-healthcheck-main.service
- postgresql-healthcheck-main.timer
- postgresql-walprune-main.service
- postgresql-walprune-main.timer
Restic Backup Jobs:
- restic-backups-postgresql-main-wal
- restic-backups-postgresql-main-base
Prometheus Metrics:
- postgresql_health{instance="main"}
- postgresql_backup_last_success_timestamp{instance="main"}
- postgresql_wal_archive_files_present{instance="main"}
Managing Multiple Instances¶
# List all PostgreSQL instances
ssh forge.holthome.net "systemctl list-units 'postgresql-*.service'"
# Check specific instance
ssh forge.holthome.net "sudo systemctl status postgresql-main"
ssh forge.holthome.net "sudo systemctl status postgresql-dev"
ssh forge.holthome.net "sudo systemctl status postgresql-analytics"
# Connect to specific instance
ssh forge.holthome.net "sudo -u postgres psql -p 5432 -d app1" # main
ssh forge.holthome.net "sudo -u postgres psql -p 5433 -d dev_db" # dev
ssh forge.holthome.net "sudo -u postgres psql -p 5434 -d warehouse" # analytics
# View instance-specific backups
ssh forge.holthome.net "ls -lh /var/backup/postgresql/main/"
ssh forge.holthome.net "ls -lh /var/backup/postgresql/dev/"
ssh forge.holthome.net "ls -lh /var/backup/postgresql/analytics/"
# Check instance-specific metrics
ssh forge.holthome.net "cat /var/lib/node_exporter/textfile_collector/postgresql_health.prom | grep instance"
Important Considerations¶
- Unique Ports: Each instance MUST have a unique port number
- Resource Allocation: Total memory across all instances should not exceed available RAM
- Disk I/O: Multiple instances share disk - consider performance impact
- Backup Windows: Stagger backup schedules to avoid overlapping I/O
- Independent Recovery: Each instance can be restored independently
Multiple Databases vs Multiple Instances¶
Use Multiple Databases in One Instance When: - Databases are related/from same application stack - Same PostgreSQL version required - Shared resource pool is acceptable - Simplified backup/recovery process desired
modules.services.postgresql.instances.main = {
databases = [ "app1" "app2" "app3" ]; # All in one instance
};
Use Multiple Instances When: - Different PostgreSQL versions needed - Strict resource isolation required - Different backup/recovery policies needed - Different security boundaries required - Development/staging/production on same host
Database Provisioning¶
IMPORTANT: The current implementation supports single-instance provisioning only. Multi-instance database provisioning is planned for Phase 2+.
The database provisioning system allows services to declaratively create their databases with proper schema, permissions, extensions, and settings. This follows the same pattern as the storage module - services declare what they need, and the PostgreSQL module provisions it automatically.
Design Pattern¶
Following the storage.datasets pattern, services don't modify the monolithic PostgreSQL configuration. Instead:
- Service declares what it needs in its own module
- PostgreSQL module aggregates all database requests
- Provisioning runs automatically when configuration changes
- Hash-based change detection ensures idempotency
Basic Usage¶
Each service can declare its database requirements:
# In your service module (e.g., modules/nixos/services/dispatcharr/default.nix)
modules.services.postgresql.databases = {
dispatcharr = {
owner = "dispatcharr";
ownerPasswordFile = config.sops.secrets."dispatcharr/db_password".path;
extensions = [ "uuid-ossp" "pg_trgm" ];
permissions = {
dispatcharr = [ "ALL" ];
readonly = [ "SELECT" ];
};
};
};
# Define the SOPS secret
sops.secrets."dispatcharr/db_password" = {
sopsFile = ./secrets.sops.yaml;
mode = "0400";
owner = "postgres";
};
Complete Example: Immich Service¶
# modules/nixos/services/immich/default.nix
{ config, lib, pkgs, ... }:
let
cfg = config.modules.services.immich;
in {
options.modules.services.immich = {
enable = lib.mkEnableOption "Immich photo management";
};
config = lib.mkIf cfg.enable {
# Declare database needs
modules.services.postgresql.databases = {
immich = {
owner = "immich";
ownerPasswordFile = config.sops.secrets."immich/db_password".path;
extensions = [
"vectors" # pgvector for AI embeddings
"earthdistance"
"pg_trgm"
];
permissions = {
immich = [ "ALL" ];
backup = [ "SELECT" ]; # Read-only backup user
};
};
};
# Define SOPS secret
sops.secrets."immich/db_password" = {
sopsFile = ./secrets.sops.yaml;
mode = "0400";
owner = "postgres";
};
# Service reads password from its own secret
sops.secrets."immich/app_db_password" = {
sopsFile = ./secrets.sops.yaml;
mode = "0400";
owner = "immich";
};
# Service container configuration
virtualisation.oci-containers.containers.immich = {
image = "ghcr.io/immich-app/immich-server:release";
environment = {
DB_HOSTNAME = "localhost";
DB_DATABASE_NAME = "immich";
DB_USERNAME = "immich";
DB_PASSWORD_FILE = config.sops.secrets."immich/app_db_password".path;
};
# ...
};
};
}
Available Options¶
modules.services.postgresql.databases.<name> = {
# Required: Database owner role
owner = "myapp";
# Required: Path to file containing owner password
# MUST be readable by postgres user, use SOPS with owner = "postgres"
ownerPasswordFile = "/run/secrets/myapp_db_password";
# Optional: PostgreSQL extensions to enable
extensions = [ "uuid-ossp" "pg_trgm" "hstore" ];
# Optional: Role → Privileges mapping
# Creates roles if they don't exist, grants specified privileges
permissions = {
myapp = [ "ALL" ]; # Full access for app
readonly = [ "SELECT" ]; # Read-only for monitoring
backup = [ "SELECT" ]; # Read-only for backups
};
};
How It Works¶
1. Configuration Aggregation¶
The PostgreSQL module collects database declarations from all enabled services:
# In postgresql/databases.nix
mergedDatabases =
# New declarative API
lib.mapAttrsToList (name: dbCfg: dbCfg // { inherit name; }) cfg.databases
# Plus legacy databases list for backward compatibility
++ (map (name: { inherit name; owner = null; }) cfg.databases);
2. Hash-Based Change Detection¶
Provisioning only runs when the configuration changes:
currentHash = builtins.hashString "sha256" (
builtins.toJSON (map (db: {
inherit (db) name owner extensions permissions;
}) mergedDatabases)
);
# Stored in /var/lib/postgresql/provisioning/provisioned.sha256
# Script compares hashes and exits early if unchanged
3. Idempotent Provisioning Script¶
The generated script is fully idempotent:
-- Create role if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'myapp') THEN
CREATE ROLE myapp LOGIN;
END IF;
END $$;
-- Set password (always run to ensure correctness)
ALTER ROLE myapp WITH PASSWORD :'owner_password';
-- Create database if it doesn't exist
SELECT 'CREATE DATABASE myapp OWNER myapp'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'myapp')
\gexec
-- Enable extensions (idempotent)
\c myapp
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant permissions (idempotent)
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO readonly;
4. Security Hardening¶
- Password Security: Passwords read from SOPS files inside script, never exposed in process list
- Systemd Restrictions:
ProtectSystem=strict,PrivateTmp,NoNewPrivileges - Limited Access: Only postgres user can read provisioning state
- Audit Trail: All provisioning logged via systemd journal
5. Monitoring Integration¶
Exports Prometheus metrics to node_exporter:
# /var/lib/node_exporter/textfile_collector/postgresql_databases.prom
postgresql_databases_total{instance="main"} 5
postgresql_databases_provisioned_timestamp{instance="main"} 1704067200
postgresql_databases_provisioned_count{instance="main"} 5
postgresql_databases_provisioned_hash{instance="main",hash="abc123..."} 1
Provisioning Lifecycle¶
graph TD
A[Service Declares Database] --> B[PostgreSQL Module Aggregates]
B --> C[Calculate Config Hash]
C --> D{Hash Changed?}
D -->|No| E[Skip Provisioning]
D -->|Yes| F[Generate SQL Script]
F --> G[Wait for PostgreSQL Ready]
G --> H[Run Provisioning Script]
H --> I[Update Hash Stamp]
I --> J[Export Metrics]
J --> K[Send Success Notification]
H -->|Error| L[Send Failure Notification]
Operational Commands¶
# View provisioned databases
sudo -u postgres psql -c '\l'
# Check provisioning status
sudo systemctl status postgresql-database-provisioning
# View provisioning logs
sudo journalctl -u postgresql-database-provisioning
# Force re-provisioning (useful for troubleshooting)
sudo rm /var/lib/postgresql/provisioning/provisioned.sha256
sudo systemctl start postgresql-database-provisioning
# View current hash
cat /var/lib/postgresql/provisioning/provisioned.sha256
# Check metrics
cat /var/lib/node_exporter/textfile_collector/postgresql_databases.prom
Backward Compatibility¶
The legacy databases = [] list still works:
# Old syntax (still supported)
modules.services.postgresql.instances.main = {
databases = [ "app1" "app2" "app3" ];
};
# These are merged with new declarative API
# Owner defaults to postgres, no password/extensions/permissions
Troubleshooting¶
Database Not Created¶
- Check if provisioning service succeeded:
sudo systemctl status postgresql-database-provisioning
sudo journalctl -u postgresql-database-provisioning
- Verify SOPS secret is readable:
- Check PostgreSQL is running:
Permission Denied¶
Ensure the ownerPasswordFile has correct ownership:
sops.secrets."myapp/db_password" = {
sopsFile = ./secrets.sops.yaml;
mode = "0400";
owner = "postgres"; # CRITICAL: postgres must read this
};
Extensions Not Available¶
Some extensions require installation:
modules.services.postgresql.instances.main = {
extraPackages = with pkgs.postgresql_15.pkgs; [
postgis
timescaledb
pg_vectors # For pgvector
];
};
Hash Mismatch After Reboot¶
This is normal - hash is recalculated on every activation. If the hash differs from stored value, provisioning runs. To prevent unnecessary runs, don't modify database declarations unless actually changing databases.
Phase 1 MVP Scope¶
Currently Implemented:
- ✅ Declarative database/role/extension/permission declarations
- ✅ Hash-based change detection (runs only on config changes)
- ✅ Secure password handling via SOPS
- ✅ Idempotent SQL generation
- ✅ Prometheus metrics export
- ✅ Notification integration
- ✅ Backward compatibility with legacy
databases = [] - ✅ Single-instance support
Phase 2+ Roadmap:
- 🔜 Multi-instance support (database declarations per instance)
- 🔜
initScriptPathfor custom schema initialization - 🔜 Schema migration tracking (Flyway/Liquibase-style)
- 🔜 Database templating support
- 🔜 Replication role setup automation
- 🔜 Per-database backup configuration
- 🔜 Connection pooling integration (PgBouncer)
Best Practices¶
- One Database Per Service: Each service should have its own database and owner role
- Least Privilege: Grant only necessary permissions - use readonly roles for monitoring
- Password Management: Always use SOPS for password files, never hardcode
- Extensions: Declare all needed extensions upfront to ensure consistent provisioning
- Testing: Test database provisioning in VM before deploying to production
- Monitoring: Check
postgresql_databases.prommetrics to verify provisioning
Example: Multi-Service Setup¶
# Configuration that provisions databases for multiple services
modules.services.postgresql = {
instances.main = {
enable = true;
version = "15";
maxConnections = 200;
backup.enable = true;
};
# Service databases declared here or in service modules
databases = {
immich = {
owner = "immich";
ownerPasswordFile = config.sops.secrets."immich/db_password".path;
extensions = [ "vectors" "earthdistance" ];
permissions.immich = [ "ALL" ];
};
grafana = {
owner = "grafana";
ownerPasswordFile = config.sops.secrets."grafana/db_password".path;
extensions = [ "uuid-ossp" ];
permissions.grafana = [ "ALL" ];
};
nextcloud = {
owner = "nextcloud";
ownerPasswordFile = config.sops.secrets."nextcloud/db_password".path;
permissions = {
nextcloud = [ "ALL" ];
readonly = [ "SELECT" ];
};
};
};
};
# All databases provisioned atomically on activation
# Each service gets proper isolation and permissions
Custom Backup Schedules¶
backup.baseBackup.schedule = "02:00"; # Daily at 2 AM
backup.walArchive.syncInterval = "*/15"; # Every 15 minutes for lower RPO
Replication Setup (Primary → Standby)¶
For high-availability with hot standby:
# On primary (forge)
modules.services.postgresql.instances.main = {
# ... standard config ...
extraSettings = {
wal_level = "replica";
max_wal_senders = 3;
wal_keep_size = "1GB";
};
};
# On standby (forge-standby)
# Use preseed with asStandby = true (future enhancement)
Backup Retention Policies¶
Adjust in your Restic repository configuration:
modules.backup.restic.jobs."postgresql-main-base" = {
keep = {
daily = 7;
weekly = 4;
monthly = 12;
yearly = 3;
};
};
Monitoring Integration¶
Add to Prometheus:
# prometheus.yml
scrape_configs:
- job_name: 'node-exporter'
static_configs:
- targets: ['forge.holthome.net:9100']
metric_relabel_configs:
- source_labels: [__name__]
regex: 'postgresql_.*'
action: keep
Grafana dashboard queries:
# Database health
postgresql_health{instance="main"}
# Backup age (hours since last backup)
(time() - postgresql_backup_last_success_timestamp{instance="main"}) / 3600
# WAL archive lag
postgresql_wal_archive_files_present{instance="main"}
Appendix¶
Useful Commands Reference¶
# === PostgreSQL Management ===
# Connect to database
sudo -u postgres psql
sudo -u postgres psql -d app1
# List databases
sudo -u postgres psql -l
# Database size
sudo -u postgres psql -c "SELECT pg_size_pretty(pg_database_size('app1'));"
# Active connections
sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity;"
# === Backup Management ===
# Force base backup
sudo systemctl start postgresql-basebackup-main
# Force WAL sync
sudo systemctl start restic-backups-postgresql-main-wal
# List Restic snapshots
sudo restic -r /mnt/nas-backup snapshots --tag main
# === ZFS Management ===
# List PostgreSQL datasets
zfs list | grep postgresql
# List snapshots
zfs list -t snapshot tank/services/postgresql/main
# Create manual snapshot
sudo zfs snapshot tank/services/postgresql/main@manual-$(date +%Y%m%d-%H%M%S)
# === Monitoring ===
# View metrics
cat /var/lib/node_exporter/textfile_collector/postgresql_*.prom
# Watch logs live
sudo journalctl -u postgresql -f
sudo journalctl -u postgresql-basebackup-main -f
# === Recovery ===
# Stop PostgreSQL
sudo systemctl stop postgresql
# Start PostgreSQL
sudo systemctl start postgresql
# Check recovery status
sudo -u postgres psql -c 'SELECT pg_is_in_recovery();'
Configuration Reference¶
See modules/nixos/services/postgresql/default.nix for all available options.
Key option groups:
modules.services.postgresql.instances.<name>enable,version,port,listenAddressesmaxConnections, memory settingsbackup.*- WAL archiving, base backups, Restic integrationpreseed.*- Self-healing restore configurationrecovery.*- PITR target configurationhealthCheck.*- Monitoring configurationextraSettings- Any PostgreSQL settings
Support and References¶
Internal Documentation¶
- Storage Module:
/docs/persistence-quick-reference.md - Backup System:
/docs/backup-metrics-reference.md
External Resources¶
Document Version: 1.0 Last Updated: 2025-10-11 Module Version: modules/nixos/services/postgresql v1.0