Files
leocrm/docs/infrastructure.md
T

11 KiB

Infrastructure Guide

Version: 1.0
Date: 2026-07-29
Applies to: System administrators and DevOps engineers


1. PgBouncer Setup

PgBouncer is a lightweight connection pooler for PostgreSQL. It reduces the overhead of establishing new database connections by reusing existing ones.

Why PgBouncer?

  • Connection pooling — Reduces PostgreSQL connection overhead
  • Resource efficiency — Handles thousands of client connections with minimal resources
  • Transaction pooling — Best for stateless applications like FastAPI
  • Session pooling — For stateful connections
  • Statement pooling — For specific use cases

Installation

# Debian/Ubuntu
apt-get update && apt-get install -y pgbouncer

# Verify installation
pgbouncer --version

Configuration

Create /etc/pgbouncer/pgbouncer.ini:

[databases]
leocrm = host=localhost port=5432 dbname=leocrm
leocrm_test = host=localhost port=5432 dbname=leocrm_test

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
unix_socket_dir = /var/run/pgbouncer

# Authentication
# Use md5 for password-based auth
# Use trust for local development
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

# Pool settings
pool_mode = transaction
default_pool_size = 25
max_client_conn = 200
max_db_connections = 50

# Timeouts
server_idle_timeout = 600
server_lifetime = 3600
client_idle_timeout = 1800
query_timeout = 30

# Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60

# Security
# Only allow connections from localhost and Docker network
listen_backlog = 128

User List

Create /etc/pgbouncer/userlist.txt:

"leocrm" "md5<password_hash>"
"postgres" "md5<password_hash>"

Generate the md5 hash:

# Format: md5 + md5(password + username)
echo -n "md5" && echo -n "your_passwordleocrm" | md5sum | cut -d' ' -f1

Running PgBouncer

# Start PgBouncer
pgbouncer -d /etc/pgbouncer/pgbouncer.ini

# Check status
pgbouncer -d /etc/pgbouncer/pgbouncer.ini -R

# Reload configuration
kill -HUP $(cat /var/run/pgbouncer/pgbouncer.pid)

# Stop PgBouncer
kill -INT $(cat /var/run/pgbouncer/pgbouncer.pid)

Docker Compose Integration

Add to docker-compose.yml:

services:
  pgbouncer:
    image: bitnami/pgbouncer:latest
    container_name: leocrm-pgbouncer
    ports:
      - "6432:6432"
    environment:
      - POSTGRESQL_HOST=crm-postgres
      - POSTGRESQL_PORT=5432
      - POSTGRESQL_USERNAME=leocrm
      - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRESQL_DATABASE=crm_db
      - PGBOUNCER_POOL_MODE=transaction
      - PGBOUNCER_DEFAULT_POOL_SIZE=25
      - PGBOUNCER_MAX_CLIENT_CONN=200
    depends_on:
      - crm-postgres
    restart: unless-stopped

Application Configuration

Update the database URL to use PgBouncer:

# Before (direct connection)
DATABASE_URL = "postgresql+asyncpg://leocrm:password@crm-postgres:5432/crm_db"

# After (via PgBouncer)
DATABASE_URL = "postgresql+asyncpg://leocrm:password@leocrm-pgbouncer:6432/crm_db"

Monitoring

# Show pool statistics
echo "SHOW STATS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer

# Show active pools
echo "SHOW POOLS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer

# Show clients
echo "SHOW CLIENTS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer

# Show servers
echo "SHOW SERVERS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer

Troubleshooting

Issue Cause Solution
Connection refused PgBouncer not running Check pgbouncer -d status
Auth failed Wrong password in userlist Regenerate md5 hash
Pool exhausted Too many connections Increase default_pool_size
Slow queries Query timeout Check query_timeout setting
Connection timeout PostgreSQL overload Check PostgreSQL connections

2. Audit Log Partitioning

The audit_log table can grow very large over time. PostgreSQL table partitioning helps manage this by splitting the table into smaller, more manageable pieces.

Why Partition?

  • Faster queries — Queries only scan relevant partitions
  • Easier maintenance — Drop old partitions instead of DELETE
  • Better vacuum — Each partition is vacuumed independently
  • Improved performance — Smaller indexes per partition

Partitioning Strategy

We use monthly range partitioning on the created_at column:

-- Each partition covers one month
-- Partition name: audit_log_YYYY_MM
-- Example: audit_log_2026_01, audit_log_2026_02, ...

Creating the Partitioned Table

-- Create the partitioned table
CREATE TABLE audit_log_partitioned (
    id UUID NOT NULL DEFAULT gen_random_uuid(),
    tenant_id UUID,
    user_id UUID,
    action VARCHAR(100) NOT NULL,
    entity_type VARCHAR(50),
    entity_id UUID,
    changes JSONB,
    ip_address VARCHAR(45),
    user_agent TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE audit_log_2026_01 PARTITION OF audit_log_partitioned
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE audit_log_2026_02 PARTITION OF audit_log_partitioned
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE audit_log_2026_03 PARTITION OF audit_log_partitioned
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- Add indexes on each partition
CREATE INDEX idx_audit_log_2026_01_tenant ON audit_log_2026_01 (tenant_id);
CREATE INDEX idx_audit_log_2026_01_action ON audit_log_2026_01 (action);
CREATE INDEX idx_audit_log_2026_01_entity ON audit_log_2026_01 (entity_type, entity_id);
CREATE INDEX idx_audit_log_2026_01_created ON audit_log_2026_01 (created_at DESC);

CREATE INDEX idx_audit_log_2026_02_tenant ON audit_log_2026_02 (tenant_id);
CREATE INDEX idx_audit_log_2026_02_action ON audit_log_2026_02 (action);
CREATE INDEX idx_audit_log_2026_02_entity ON audit_log_2026_02 (entity_type, entity_id);
CREATE INDEX idx_audit_log_2026_02_created ON audit_log_2026_02 (created_at DESC);

CREATE INDEX idx_audit_log_2026_03_tenant ON audit_log_2026_03 (tenant_id);
CREATE INDEX idx_audit_log_2026_03_action ON audit_log_2026_03 (action);
CREATE INDEX idx_audit_log_2026_03_entity ON audit_log_2026_03 (entity_type, entity_id);
CREATE INDEX idx_audit_log_2026_03_created ON audit_log_2026_03 (created_at DESC);

Migrating Existing Data

-- Step 1: Create the partitioned table
-- (see script above)

-- Step 2: Insert existing data
INSERT INTO audit_log_partitioned (
    id, tenant_id, user_id, action, entity_type,
    entity_id, changes, ip_address, user_agent, created_at
)
SELECT id, tenant_id, user_id, action, entity_type,
       entity_id, changes, ip_address, user_agent, created_at
FROM audit_log;

-- Step 3: Verify data integrity
SELECT COUNT(*) FROM audit_log_partitioned;
SELECT COUNT(*) FROM audit_log;

-- Step 4: Rename tables
ALTER TABLE audit_log RENAME TO audit_log_old;
ALTER TABLE audit_log_partitioned RENAME TO audit_log;

-- Step 5: Update sequences and indexes
-- (handled by the partitioned table definition)

-- Step 6: Drop old table after verification
-- DROP TABLE audit_log_old;

Automating Partition Creation

Use the setup_audit_partitioning.sql script to automate partition management:

# Run the setup script
psql -h localhost -U leocrm -d crm_db -f scripts/setup_audit_partitioning.sql

Cron Job for Partition Maintenance

Add to crontab to create partitions automatically:

# Run on the 1st of each month at 2 AM
0 2 1 * * /usr/bin/psql -h localhost -U leocrm -d crm_db -c "SELECT create_monthly_audit_partition();"

Querying Partitioned Data

-- Query a specific month (fast, only scans one partition)
SELECT * FROM audit_log
WHERE created_at >= '2026-01-01'
  AND created_at < '2026-02-01'
  AND tenant_id = '...';

-- Query across months (scans multiple partitions)
SELECT * FROM audit_log
WHERE created_at >= '2026-01-01'
  AND created_at < '2026-03-01'
  AND action = 'permission_grant';

-- Check which partitions will be scanned
EXPLAIN SELECT * FROM audit_log
WHERE created_at >= '2026-01-01'
  AND created_at < '2026-02-01';

Dropping Old Partitions

-- Drop partitions older than retention period
DROP TABLE IF EXISTS audit_log_2025_01;
DROP TABLE IF EXISTS audit_log_2025_02;
-- ...

-- Or use a function
SELECT drop_old_audit_partitions(12);  -- Keep last 12 months

Performance Considerations

  • Index each partition — Don't rely on parent table indexes
  • Use created_at in WHERE — Always filter by date for partition pruning
  • Monitor partition count — Too many partitions can slow planning
  • Archive old partitions — Consider moving to cheaper storage
  • Vacuum partitions — Each partition needs independent vacuum

Monitoring Partition Health

-- Check partition sizes
SELECT
    relname AS partition_name,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname LIKE 'audit_log_%'
ORDER BY relname;

-- Check row counts per partition
SELECT
    relname AS partition_name,
    n_live_tup AS row_count
FROM pg_catalog.pg_stat_user_tables
WHERE relname LIKE 'audit_log_%'
ORDER BY relname;

-- List all partitions
SELECT
    inhrelid::regclass AS partition_name
FROM pg_catalog.pg_inherits
WHERE inhparent = 'audit_log'::regclass
ORDER BY partition_name;

3. Backup and Recovery

Database Backup

# Full backup
pg_dump -h localhost -U leocrm -d crm_db -F c -f /backups/crm_db_$(date +%Y%m%d).dump

# Backup with compression
pg_dump -h localhost -U leocrm -d crm_db -F c -Z 9 -f /backups/crm_db_$(date +%Y%m%d).dump.gz

# Backup specific schema only
pg_dump -h localhost -U leocrm -d crm_db -n public -F c -f /backups/crm_db_schema_$(date +%Y%m%d).dump

Database Restore

# Restore full backup
pg_restore -h localhost -U leocrm -d crm_db -c /backups/crm_db_20260701.dump

# Restore with parallel workers (faster)
pg_restore -h localhost -U leocrm -d crm_db -j 4 -c /backups/crm_db_20260701.dump

Automated Backup Script

See scripts/backup.py for the automated backup solution.


4. Monitoring and Alerts

Key Metrics

Metric Target Alert Threshold
Database connections < 50 > 80% of max
Query response time < 100ms > 500ms
Cache hit ratio > 95% < 90%
Partition size < 10GB > 50GB
PgBouncer pool usage < 80% > 90%

Health Checks

# Check PgBouncer status
echo "SHOW STATS;" | psql -h localhost -p 6432 -U leocrm -d pgbouncer | grep -E "total_|avg_"

# Check partition health
psql -h localhost -U leocrm -d crm_db -c "SELECT count(*) FROM audit_log WHERE created_at < NOW() - INTERVAL '3 months';"

# Check database size
psql -h localhost -U leocrm -d crm_db -c "SELECT pg_size_pretty(pg_database_size('crm_db'));"