Tune Postgres, Hasura, Redis, and Nginx for your workload. Profile before you optimize.
# Check current performance metrics
nself perf status
# Identify slow queries (top 10 by total time)
nself db slow-queries --top 10
# Run a quick load test
nself perf bench --requests 1000 --concurrency 10
# Open Grafana for real-time metrics
nself monitor open grafanaPostgres defaults are conservative. Adjust based on your server's RAM — the most impactful settings are shared_buffers, work_mem, and max_connections.
# In .environments/prod/.env:
# Rule of thumb: shared_buffers = 25% of total RAM
# 4 GB server:
PG_SHARED_BUFFERS=1GB
PG_EFFECTIVE_CACHE_SIZE=3GB
PG_WORK_MEM=16MB
PG_MAINTENANCE_WORK_MEM=256MB
# 8 GB server:
PG_SHARED_BUFFERS=2GB
PG_EFFECTIVE_CACHE_SIZE=6GB
PG_WORK_MEM=32MB
PG_MAINTENANCE_WORK_MEM=512MB
# 16 GB server:
PG_SHARED_BUFFERS=4GB
PG_EFFECTIVE_CACHE_SIZE=12GB
PG_WORK_MEM=64MB
PG_MAINTENANCE_WORK_MEM=1GBEnable connection pooling for high-concurrency workloads. PgBouncer is included with ɳSelf:
# In .environments/prod/.env:
PGBOUNCER_ENABLED=true
PGBOUNCER_MAX_CLIENT_CONN=200
PGBOUNCER_DEFAULT_POOL_SIZE=20
PGBOUNCER_POOL_MODE=transaction # transaction | session | statement
# Hasura uses PgBouncer automatically when enabled
# Check pool stats
nself db pool-stats# Enable pg_stat_statements (enabled by default in ɳSelf)
nself db slow-queries --top 10 --min-duration 100ms
# Output:
# Query Calls Total Time Avg Time
# SELECT * FROM np_items WHERE 1,203 45.2s 37.6ms ← needs index
# SELECT count(*) FROM np_files 892 12.1s 13.6ms ← needs index
# Explain a specific query
nself db explain "SELECT * FROM np_items WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20"
# Reset stats
nself db slow-queries --reset# Add index via migration (recommended — tracked in git)
nself db migration create add_items_user_id_index
# Edit the migration file:
CREATE INDEX CONCURRENTLY idx_np_items_user_id_created
ON np_items (user_id, created_at DESC);
# Apply
nself db migrate
# Check index usage
nself db index-stats# In .environments/prod/.env:
HASURA_GRAPHQL_MAX_CONNECTIONS=50 # Hasura → Postgres pool size
HASURA_GRAPHQL_CONN_LIFETIME=600 # Max connection lifetime (seconds)
HASURA_GRAPHQL_IDLE_TIMEOUT=180 # Idle connection timeout
# Subscription limits (WebSocket)
HASURA_GRAPHQL_MAX_SUBSCRIPTIONS=100
HASURA_GRAPHQL_LIVE_QUERIES_MULTIPLEXED_REFETCH_INTERVAL=1000# Enable query caching (requires Redis)
HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS=true
HASURA_GRAPHQL_CACHE_ENABLED=true
# In your GraphQL query (client-side):
query GetItems @cached(ttl: 60) {
np_items(limit: 20) {
id
title
created_at
}
}# Scale Hasura horizontally
HASURA_REPLICAS=3
# Nginx load-balances across replicas automatically
# Verify all replicas healthy
nself health hasura --verbose# In .environments/prod/.env:
REDIS_ENABLED=true
REDIS_MAX_MEMORY=512mb
REDIS_MAX_MEMORY_POLICY=allkeys-lru # evict least-recently-used when full
# Session caching (auth service uses Redis by default)
AUTH_SESSION_STORE=redis
AUTH_SESSION_TTL=86400 # 24 hours
# Check Redis memory usage
nself service exec redis redis-cli info memory | grep used_memory_human
# Check cache hit rate
nself service exec redis redis-cli info stats | grep keyspace# In nginx/conf.d/tuning.conf (hand-managed, safe to edit):
worker_processes auto;
worker_connections 1024;
# Gzip compression
gzip on;
gzip_types text/plain application/json application/javascript text/css;
gzip_min_length 1000;
# Static file caching (for frontend apps)
location ~* .(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Keepalive to upstream services
upstream hasura {
server hasura:8080;
keepalive 32;
}# Cloudflare (recommended)
# 1. Point your domain's nameservers to Cloudflare
# 2. Enable "Proxied" mode (orange cloud) for your A records
# 3. Set Cache Level to "Standard"
# 4. Enable "Auto Minify" for JS, CSS, HTML
# Cloudflare caches static assets. API calls (api.yourdomain.com)
# pass through without caching by default.
# Tell Cloudflare not to cache GraphQL (add Cache-Control header):
# In nginx/conf.d/hasura.conf:
add_header Cache-Control "no-store, no-cache" always;# Built-in benchmark tool
nself perf bench --requests 5000 --concurrency 50 --endpoint https://api.yourdomain.com/v1/graphql
# Output:
# Requests: 5,000
# Concurrency: 50
# Duration: 12.4s
# Throughput: 403 req/s
# P50 latency: 94ms
# P95 latency: 187ms
# P99 latency: 312ms
# Errors: 0
# Profile Postgres query plans during a benchmark
nself db profile --duration 60s
# Monitor in real time during benchmark
nself perf watch # refreshes every 2 seconds# Performance alert thresholds (in .env):
ALERT_PERF_P95_LATENCY_MS=500 # Alert if P95 latency > 500ms
ALERT_PERF_ERROR_RATE=0.01 # Alert if error rate > 1%
ALERT_PERF_CPU_PERCENT=80 # Alert if CPU > 80% sustained
ALERT_PERF_MEMORY_PERCENT=85 # Alert if memory > 85%
ALERT_PERF_DISK_PERCENT=80 # Alert if disk > 80%nself db slow-queries before tuning — guess wrong and you make things worse.CREATE INDEX CONCURRENTLY in production — regular index creation locks the table.REDIS_MAX_MEMORY_POLICY=allkeys-lru to prevent Redis from filling up and crashing.