PostgreSQL Production Guide — Tuning, Pooling, Monitoring#
"PostgreSQL's default config looks like it was written for a Raspberry Pi. If it isn't tuned in the first week on a production server, 6 months later the 'why is this slow' argument starts."
As of 2026, this guide compiles the critical tuning, connection pooling, monitoring, and operational topics for a prod-grade PostgreSQL setup. It references Postgres 16/17.
🎯 First Decision: Containerized, Managed, or Bare-Metal?#
| Scenario | Preference |
|---|---|
| Dev / staging | Container (docker-compose / StatefulSet) |
| Prod < 100 GB | K8s Operator (CloudNativePG, Zalando) |
| Prod 100 GB – 1 TB | Managed (RDS / CloudSQL / Aurora) |
| Prod > 1 TB, IOPS-heavy | Bare metal / dedicated VM + managed backup |
| Multi-region active-active | CockroachDB / YugabyteDB (not Postgres!) |
🔑 Rule: "Is DB operations your core competency?" If the answer is "no", use a managed service. Vendor lock-in cost is a tenth of a postgres DBA's salary.
⚙️ postgresql.conf Tuning#
⚠️ Start with
pgtune.leopard.in.ua, then tune based on your workload. The values below reference 16 GB RAM, 4 vCPU.
Memory#
# Total RAM × 25% — query buffer cache
shared_buffers = 4GB
# Per-query work memory — sorting, hash join (PER operation, not per connection)
# Start conservative (16-64MB); if EXPLAIN ANALYZE shows disk-temp, increase gradually.
# WARNING: total consumption ≈ work_mem × concurrent sort/hash op count → an aggressive value risks OOM.
work_mem = 16MB
# Maintenance (VACUUM, CREATE INDEX) — Total RAM × 5%
maintenance_work_mem = 1GB
# OS file cache hint — Postgres's trust in the OS cache
effective_cache_size = 12GB
# WAL buffer
wal_buffers = 16MB
Connections#
max_connections = 100 # keep low behind PgBouncer
# Statement timeout — kill bad queries
statement_timeout = 60s
idle_in_transaction_session_timeout = 5min
lock_timeout = 30s
🔑 Rule:
max_connections×work_mem≤shared_buffers. Otherwise there's a memory exhaustion risk.
Checkpoint & WAL#
# Checkpoint less frequent + smoother
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB
# For WAL replication
wal_level = replica
max_wal_senders = 10
hot_standby = on
Query Planner#
random_page_cost = 1.1 # for SSD (4.0 on HDD)
effective_io_concurrency = 200 # SSD/NVMe
default_statistics_target = 100 # plan quality
Logging (critical for production observability)#
log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_rotation_age = 1d
log_rotation_size = 100MB
log_min_duration_statement = 500ms # log queries taking 500ms+
log_checkpoints = on
log_connections = on
log_disconnections = on
log_lock_waits = on
log_temp_files = 0 # log all temp files (indicator that work_mem is insufficient)
log_autovacuum_min_duration = 0
log_line_prefix = '%m [%p] %q%u@%d/%a '
Autovacuum (never disable)#
autovacuum = on # NEVER off
autovacuum_max_workers = 4
autovacuum_naptime = 30s
# Speed it up (defaults lead to bloat)
autovacuum_vacuum_scale_factor = 0.05 # vacuum once the table grows 5%
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 1000
🚨
autovacuum = off= a death sentence for the DB. Table bloat → queries slow down → cardinality gets worse → more bloat. A spiral.
🌊 Connection Pooling: PgBouncer#
A PostgreSQL connection = a process. Each connection is ~10 MB RAM. max_connections=500 = 5 GB just for idle connections.
PgBouncer: 1 worker multiplies thousands of client connections into a handful of DB connections.
Choosing a mode#
| Mode | Usage | Limits |
|---|---|---|
| Session | Default; client-conn = DB-conn lifetime | Pool runs dry, idle timeouts |
| Transaction | Connection freed at end of transaction | Best for most apps |
| Statement | Freed after every statement | Multi-statement TX forbidden |
🔑
pool_mode = transactionis the sweet spot for modern apps.
Config example#
# /etc/pgbouncer/pgbouncer.ini
[databases]
app = host=<DB_HOST> port=5432 dbname=app
[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
# Pool sizing
default_pool_size = 25 # DB connection per pool
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 5
max_client_conn = 1000 # total max from clients
max_db_connections = 50 # total max toward the DB
# Timeout
server_idle_timeout = 600
server_lifetime = 3600
query_wait_timeout = 120
# TLS
server_tls_sslmode = require
client_tls_sslmode = require
App side#
⚠️ In transaction mode, prepared statements cause trouble. With Postgres 14+,
protocol-levelprepared statements are supported in PgBouncer (max_prepared_statements).
🔒 Security Baseline#
pg_hba.conf#
# /etc/postgresql/<VER>/main/pg_hba.conf
# local connection (Unix socket)
local all all scram-sha-256
# local network — application subnet
host app app 10.0.0.0/8 scram-sha-256
# replica
host replication replicator 10.0.0.0/8 scram-sha-256
# DENY everything else
host all all 0.0.0.0/0 reject
🚨
host all all 0.0.0.0/0 trustormd5= leaving the door open. This line must not exist in production.
Role hygiene#
-- Superuser only for the DBA, not the app
CREATE ROLE dba_team SUPERUSER;
-- App user — only its own DB, only the required tables
CREATE ROLE app LOGIN PASSWORD '<PWD>';
GRANT CONNECT ON DATABASE app TO app;
GRANT USAGE ON SCHEMA public TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app;
-- Separate role for migrations
CREATE ROLE app_migrate LOGIN PASSWORD '<PWD>';
GRANT app TO app_migrate;
GRANT CREATE ON SCHEMA public TO app_migrate;
Encryption#
- In transit: TLS mandatory (
pg_hba.confhostssl) - At rest: Filesystem-level (LUKS) or cloud KMS-backed disk
- Sensitive columns: pgcrypto or app-side encryption
📊 Monitoring#
pg_stat_statements extension#
-- postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
-- In the DB
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- The slowest queries
SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
-- Where the total time goes
SELECT query, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Postgres Exporter (Prometheus)#
# helm install: prometheus-community/prometheus-postgres-exporter
postgresql-exporter:
config:
datasource:
uri: <PG_HOST>:5432
user: postgres_exporter
passwordSecret:
name: postgres-exporter-creds
key: password
database: postgres
sslmode: require
serviceMonitor:
enabled: true
Key metrics + alerts#
groups:
- name: postgres
rules:
- alert: PostgresDown
expr: pg_up == 0
for: 1m
- alert: PostgresReplicationLag
expr: pg_replication_lag_seconds > 60
for: 5m
- alert: PostgresConnectionsHigh
expr: pg_stat_activity_count / pg_settings_max_connections > 0.8
for: 5m
- alert: PostgresLongRunningTransaction
expr: pg_stat_activity_max_tx_duration > 600
for: 5m
- alert: PostgresDeadlocks
expr: rate(pg_stat_database_deadlocks[5m]) > 0
for: 1m
- alert: PostgresAutovacuumDisabled
expr: pg_settings_autovacuum != 1
- alert: PostgresTableBloat
expr: pg_bloat_ratio > 50
for: 30m
- alert: PostgresDiskFullSoon
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/var/lib/postgresql"}[6h], 24*3600) < 0
for: 30m
Slow query log#
-- Log any query that runs 1+ second
ALTER SYSTEM SET log_min_duration_statement = '1s';
SELECT pg_reload_conf();
→ Logs are shipped to Loki/Splunk, and flow into dashboards.
🚦 Index Hygiene#
Detecting missing indexes#
-- Tables doing sequential scans
SELECT relname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY seq_tup_read DESC;
Unused indexes#
-- Indexes never used (drop candidates)
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
Index bloat#
-- REINDEX CONCURRENTLY while pg_stat_progress_create_index tracks progress
REINDEX INDEX CONCURRENTLY <INDEX_NAME>;
🛡️ HA Topology#
Streaming Replication (simple)#
Auto-failover: Patroni#
- Patroni = Postgres + DCS (etcd/Consul) + watchdog
- Primary down → standby switches to promote
- Details:
HA-Patroni-Stolon.md
K8s Operator: CloudNativePG (recommended)#
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: app-postgres
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
storage:
size: 100Gi
storageClass: <FAST_SSD_CLASS>
bootstrap:
initdb:
database: app
owner: app
monitoring:
enablePodMonitor: true
backup:
barmanObjectStore:
destinationPath: s3://<BACKUP_BUCKET>/postgres
s3Credentials:
accessKeyId: {name: backup-creds, key: ACCESS_KEY}
secretAccessKey: {name: backup-creds, key: SECRET_KEY}
wal:
compression: gzip
retentionPolicy: "30d"
resources:
requests: {cpu: "2", memory: "8Gi"}
limits: {cpu: "4", memory: "16Gi"}
🔑 CloudNativePG is the most pragmatic K8s operator in 2026. HA + backup + monitoring + replication integrated.
📦 Backup Strategy#
| Method | RPO | Restore | Usage |
|---|---|---|---|
pg_dump once a day | 24h | Slow | Dev / small |
pg_basebackup | 1 day | Fast | Full DB image |
| WAL-G + S3 | < 5 min | PITR | Sweet spot for most prod |
| pgBackRest | < 5 min | Fast | Incremental, large prod |
| Streaming replica + snapshot | 0 | Instant | HA, top tier |
An untested backup is not a backup#
# Quarterly: backup restore drill
1. Spin up a new instance
2. Restore the latest backup
3. Smoke test: is the schema consistent, is row count reasonable, does the app connect?
4. Measure RTO
5. Postmortem: fix any gaps
⚠️ 3-2-1 rule: 3 copies, 2 different media, 1 off-site. Backup on S3 has versioned + MFA delete on.
🔄 Zero-Downtime Migration#
Details:
Zero-Downtime-Migrations.md.
Expand/Contract pattern#
1. EXPAND: add new column (default null)
2. App: dual write (old + new column)
3. Backfill: copy data from old → new
4. App: write/read only the new column
5. CONTRACT: drop the old column
Online schema change#
- gh-ost (GitHub) — designed for MySQL, the postgres alternative is
pg_repack pg_repack: lock-free table refactor
🚫 Anti-Pattern Table#
| Anti-pattern | Why it's bad | Correct |
|---|---|---|
Default postgresql.conf in prod | Optimized for a Raspberry Pi | pgtune + workload-tune |
max_connections=500 without PgBouncer | RAM exhaustion | PgBouncer + lower max_connections |
App connects with superuser | Privilege violation, audit trail lost | App-specific role, least privilege |
autovacuum = off | Bloat → DB dies | Never disable it |
| Backup never tested | Doesn't work during a crisis | Quarterly restore drill |
pg_dump on a 500 GB DB | Takes hours, transaction inconsistent | WAL-G or pgBackRest |
| No index strategy | Sequential scans, slow queries | pg_stat_user_indexes review |
| 50+ unused indexes | Write amplification | Quarterly cleanup |
| Replication lag not monitored | Read replica serves stale data | Alert + dashboard |
| Schema migration in unattended deploy | DROP COLUMN mid-prod | Expand/contract pattern |
| DB exposed to the internet | Brute force | private network only |
| No TLS | A sniffer reads the password | hostssl enforce |
| Same DB for all apps | Cross-tenant query, SLO violation | DB-per-service or schema-per-service |
| Infinite connection retry | DB down → app DDoSes the DB | Exponential backoff + jitter |
📋 Production Readiness Checklist#
[ ] postgresql.conf tuned to the workload
[ ] PgBouncer (transaction mode) installed
[ ] pg_hba.conf: no 0.0.0.0/0, app subnet only
[ ] TLS enforced (hostssl)
[ ] App user least privilege; superuser only for the DBA
[ ] autovacuum active, sped up
[ ] pg_stat_statements extension
[ ] postgres-exporter + Prometheus + alerts
[ ] Slow query log → SIEM/Loki
[ ] HA: streaming replica (sync + async)
[ ] Auto-failover via Patroni or CloudNativePG
[ ] Backup: WAL-G or pgBackRest, S3 versioned + MFA delete
[ ] Quarterly restore drill
[ ] Index review (missing + unused)
[ ] Migration: expand/contract pattern, manual review
[ ] Connection retry: exponential backoff + jitter (app-side)
[ ] DR plan: how long for primary down → standby promote?
[ ] Capacity planning: disk/conn/CPU trend
[ ] PII encryption: pgcrypto or app-side
[ ] Audit log: pgaudit (or shipped from pg_stat_activity)
[ ] Logical replication (read-replica → analytics)
📚 References#
- PostgreSQL Documentation — postgresql.org/docs
- CloudNativePG — cloudnative-pg.io
- Patroni — github.com/zalando/patroni
- PgBouncer — pgbouncer.org
- pgtune — pgtune.leopard.in.ua
- WAL-G — github.com/wal-g/wal-g
- pgBackRest — pgbackrest.org
Backup-Restore-Patterns.mdHA-Patroni-Stolon.mdZero-Downtime-Migrations.mdConnection-Pooling.mdMonitoring-Postgres.md
"PostgreSQL's best feature: 35 years of maturity. Its worst: its default config was written for hardware from 35 years ago. If you deploy to prod without tuning it, blame yourself, not Postgres."