Real-world configurations for common deployment scenarios — from simple pooling to sharding and Kubernetes.
Replace PgBouncer with Keel. Transaction pooling with prepared-statement virtualization for ORM-heavy applications.
Automatic read/write routing with Patroni cluster discovery. Replicas get reads; primary gets writes. Sticky-primary after writes.
Keel as a frontend for MySQL Group Replication or Galera Cluster. Automatic role detection and failover.
Connect to AWS RDS or Aurora using IAM token auth. No password rotation scripts needed — Keel handles token generation and caching.
End-to-end TLS with mutual client certificate verification. Includes kTLS kernel offload and TLS 1.3 enforcement.
Full Kubernetes deployment with Helm, ConfigMap, Secret, HPA based on pool wait queue, and PodMonitor for Prometheus.
Complete Docker Compose stack: Keel + PostgreSQL + Prometheus + Grafana. Ready for local development and staging.
4-shard setup with hash-based routing on user_id. Includes scatter-merge aggregation and admin plan inspection.
Route queries based on username and time-of-day using Lua. Block heavy reports during business hours. Demonstrates all 4 hook points.
Block dangerous operations, route analytics to replicas, rewrite legacy table names, and rate-limit heavy queries — all in INI config, no code.
The simplest production setup: replace PgBouncer with Keel's transaction pooling. The virtualize prepared-statement strategy transparently handles ORMs like Hibernate, GORM, and SQLAlchemy without any application changes.
[keel]
experimental_features = false
log_level = info
[worker_group.main]
protocol = postgresql
bind_addr = 0.0.0.0
bind_port = 6432
num_workers = 0 # auto: one per CPU core
# Production defaults
mode = pool
prepared_statement = virtualize
# Pool sizing
min_pool_size = 10
max_pool_size = 50
idle_timeout = 5m
pool_wait_timeout_ms = 5000
# Timeouts
client_connect_timeout = 10s
client_idle_timeout = 10m
backend_connect_timeout = 5s
# Health probes
probe = postgres
probe_interval = 5s
probe_timeout = 3s
probe_retries = 3
# Admin / metrics
admin_port = 9187
metrics = on
[worker_group.main.servers]
primary = host=db.local port=5432 dbname=mydb user=app password=secret role=RW weight=100
# Start
sudo systemctl start keel
# Connect through Keel
psql "host=localhost port=6432 dbname=mydb user=app"
# Check pool status
psql -h localhost -p 9187 -c "SHOW POOL"
psql -h localhost -p 9187 -c "SHOW WORKERS"
# Prometheus metrics
curl -s http://localhost:9187/metrics | grep keel_pool
num_workers = 0 auto-selects one worker per CPU core via sysconf(_SC_NPROCESSORS_ONLN)max_pool_size = 50 and 8 workers, max backend connections = 400. Adjust PostgreSQL's max_connections accordinglyprepared_statement = virtualize works with any ORM. Keel stores the parse text and re-issues it on each borrowed backendkill -HUP $(pgrep keel) applies pool size changes without restartAutomatic read/write routing with Patroni cluster discovery. Patroni's REST API provides automatic role detection — no manual primary/replica assignment needed.
[keel]
experimental_features = false
[worker_group.prod]
protocol = postgresql
bind_addr = 0.0.0.0
bind_port = 6432
num_workers = 0
# Hardening tier: enables R/W routing
mode = smart
prepared_statement = virtualize
transaction_tracking = off # promote to 'on' only after validating failover
min_pool_size = 10
max_pool_size = 100
# Patroni probe — auto-detects primary and replicas
probe = patroni
probe_host = patroni-api.local
probe_port = 8008
probe_interval = 3s
probe_timeout = 2s
probe_retries = 3
failover_delay = 10s
# Observability
admin_port = 9187
metrics = on
tracing = off
[worker_group.prod.servers]
# role=auto: Keel detects role via pg_is_in_recovery() / Patroni
node1 = host=10.0.0.1 port=5432 dbname=prod user=app password=s role=auto weight=100
node2 = host=10.0.0.2 port=5432 dbname=prod user=app password=s role=auto weight=100
node3 = host=10.0.0.3 port=5432 dbname=prod user=app password=s role=auto weight=100
weight=50 for replicas in geographically distant regionsGET /cluster; falls back to GET /patroni. No external library dependencies — pure C HTTP/1.0 clientKeel handles MySQL 9, Percona XtraDB Cluster, MariaDB Galera, and Group Replication topologies with the same pooling and routing features as PostgreSQL.
[worker_group.mysql]
protocol = mysql
bind_addr = 0.0.0.0
bind_port = 7306
num_workers = 0
mode = smart
min_pool_size = 5
max_pool_size = 100
# MySQL auth
server_auth = caching_sha2_password
# Health probe
probe = mysql
probe_interval = 3s
probe_timeout = 2s
probe_retries = 3
admin_port = 9188
metrics = on
[worker_group.mysql.servers]
primary = host=10.0.1.1 port=3306 dbname=app user=app password=s role=RW weight=100
replica1 = host=10.0.1.2 port=3306 dbname=app user=app password=s role=RO weight=100
replica2 = host=10.0.1.3 port=3306 dbname=app user=app password=s role=RO weight=100
Keel generates SigV4 tokens for RDS IAM authentication automatically and caches them for 14 minutes. Your application connects to Keel with a regular password and Keel handles IAM internally.
[worker_group.rds]
protocol = postgresql
bind_addr = 0.0.0.0
bind_port = 6432
num_workers = 0
mode = pool
prepared_statement = virtualize
min_pool_size = 5
max_pool_size = 50
[worker_group.rds.servers]
# auth=aws_iam: Keel generates SigV4 tokens (cached 14 min, auto-refreshed)
primary = host=mydb.cluster-abc.us-east-1.rds.amazonaws.com \
port=5432 dbname=mydb user=myapp \
role=RW auth=aws_iam aws_region=us-east-1
# For Aurora with reader endpoint
reader = host=mydb.cluster-ro-abc.us-east-1.rds.amazonaws.com \
port=5432 dbname=mydb user=myapp \
role=RO auth=aws_iam aws_region=us-east-1
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["rds-db:connect"],
"Resource": [
"arn:aws:rds-db:us-east-1:123456789012:dbuser:cluster-ABC/myapp"
]
}
]
}
Complete TLS configuration with mutual client authentication, kernel TLS offload, and TLS 1.3 enforcement.
[worker_group.secure]
protocol = postgresql
bind_port = 6432
mode = pool
# Frontend TLS (clients → Keel)
tls_mode = require
tls_cert_file = /etc/keel/certs/server.crt
tls_key_file = /etc/keel/certs/server.key
tls_ca_file = /etc/keel/certs/ca.crt # enables mTLS: verify client certs
tls_min_version = 1.3
tls_ciphers = TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
# Kernel TLS offload (kTLS) — reduces CPU usage for TLS encryption/decryption
ktls = on
# Backend TLS (Keel → PostgreSQL)
backend_tls = require
backend_tls_ca = /etc/keel/certs/backend-ca.crt
# Auto-renew warning threshold
tls_auto_renew_days = 30
[worker_group.secure.servers]
primary = host=db.local port=5432 dbname=mydb user=app password=s role=RW
mkdir -p /etc/keel/certs && cd /etc/keel/certs
# CA
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=keel-ca" -out ca.crt
# Server cert
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=keel-server" -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 365 -sha256
# Client cert (for mTLS)
openssl genrsa -out client.key 2048
openssl req -new -key client.key -subj "/CN=myapp" -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 365 -sha256
# Verify
psql "host=localhost port=6432 dbname=mydb user=myapp \
sslcert=client.crt sslkey=client.key sslrootcert=ca.crt sslmode=verify-full"
Full Kubernetes deployment with HPA scaling and Prometheus monitoring.
image:
repository: ghcr.io/virtlabs-io/keel
tag: latest
pullPolicy: IfNotPresent
replicaCount: 2
service:
type: ClusterIP
port: 6432
adminPort: 9187
config:
bindPort: 6432
numWorkers: 4
mode: pool
preparedStatement: virtualize
minPoolSize: 10
maxPoolSize: 50
adminPort: 9187
metrics: true
# Backend credentials from Secret
envFrom:
- secretRef:
name: keel-db-credentials
resources:
requests:
cpu: 200m
memory: 128Mi
limits:
cpu: 2
memory: 512Mi
# Liveness / readiness
livenessProbe:
httpGet:
path: /healthz
port: 9187
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /ready
port: 9187
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: keel-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: keel
minReplicas: 2
maxReplicas: 10
metrics:
# Scale up when clients are waiting for pool connections
- type: Pods
pods:
metric:
name: keel_pool_wait_queue_enqueued
target:
type: AverageValue
averageValue: "5"
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: keel
spec:
selector:
matchLabels:
app: keel
podMetricsEndpoints:
- port: admin
path: /metrics
interval: 15s
Full local development stack: Keel + PostgreSQL + Prometheus + Grafana.
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
keel:
image: ghcr.io/virtlabs-io/keel:latest
depends_on:
postgres:
condition: service_healthy
ports:
- "6432:6432"
- "9187:9187"
volumes:
- ./keel.ini:/etc/keel/keel.ini:ro
environment:
KEEL_LOG_LEVEL: info
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
volumes:
- grafdata:/var/lib/grafana
volumes:
pgdata:
grafdata:
[worker_group.main]
protocol = postgresql
bind_addr = 0.0.0.0
bind_port = 6432
num_workers = 2
mode = pool
prepared_statement = virtualize
min_pool_size = 5
max_pool_size = 20
admin_port = 9187
metrics = on
[worker_group.main.servers]
primary = host=postgres port=5432 dbname=mydb user=app password=secret role=RW
4-shard setup with hash-based routing on user_id. Scatter-merge queries like COUNT, SUM, GROUP BY work transparently.
[keel]
experimental_features = true
[worker_group.main]
protocol = postgresql
bind_port = 6432
mode = smart
prepared_statement = virtualize
[shard_rule.orders]
table = orders
column = user_id
shard_count = 4
strategy = hash
[shard_backend.0]
host = shard0.local
port = 5432
database = mydb
user = app
password = s
[shard_backend.1]
host = shard1.local
port = 5432
database = mydb
user = app
password = s
[shard_backend.2]
host = shard2.local
port = 5432
database = mydb
user = app
password = s
[shard_backend.3]
host = shard3.local
port = 5432
database = mydb
user = app
password = s
-- Single-shard lookup (hash(user_id=42) → shard 2)
SELECT * FROM orders WHERE user_id = 42;
-- Scatter-merge aggregation (fans out to all 4 shards)
SELECT status, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY status
ORDER BY total DESC;
-- Inspect routing plan
EXPLAIN SHARD PLAN FOR 'SELECT * FROM orders WHERE user_id = $1';
-- query_type | shard_count | target_shard | strategy | key_column | estimated_rows
-- ─────────────────────────────────────────────────────────────────────────────────
-- SINGLE | 4 | 2 | hash | user_id | 1042
-- List shard rules
SHOW SHARD RULES;
A Lua hook that routes analytics users to replicas, blocks heavy reports during business hours, and logs all DDL queries.
-- Hook: before_route
-- Demonstrates: custom routing, blocking, and logging
local function is_business_hours()
local hour = tonumber(os.date("%H"))
return hour >= 9 and hour < 18
end
function before_route(ctx)
-- Route analytics user always to replica
if ctx.user == "analytics" or ctx.user == "reporting" then
ctx.route = "replica"
return true
end
-- Block heavy report queries during business hours
if is_business_hours() and ctx.sql:find("GENERATE_SERIES") then
ctx.abort_message = "Heavy reports blocked during business hours (09:00-18:00)"
return false
end
return true -- continue with default routing
end
-- Hook: after_query_parse (mode=full required)
function after_query_parse(ctx)
-- Log all DDL queries
if ctx.query_type == "DDL" then
io.write(string.format("[DDL] user=%s sql=%s\n", ctx.user, ctx.sql:sub(1, 200)))
io.flush()
end
return true
end
[worker_group.main]
protocol = postgresql
bind_port = 6432
mode = full # hooks require mode=full
hook.before_route = /etc/keel/hooks/route.lua
hook.after_query_parse = /etc/keel/hooks/route.lua
[worker_group.main.servers]
primary = host=db1.local port=5432 dbname=prod user=app password=s role=RW
replica1 = host=db2.local port=5432 dbname=prod user=app password=s role=RO
replica2 = host=db3.local port=5432 dbname=prod user=app password=s role=RO
Powerful routing, blocking, and rewriting rules using only INI config — no Lua or Python needed.
[worker_group.main]
protocol = postgresql
bind_port = 6432
mode = smart
# ─── Routing Rules ───────────────────────────────────────────────────────
# Route analytics queries to replicas
[query_rule.analytics]
match = ^\s*(SELECT|EXPLAIN).*(FROM\s+(events|metrics|audit_log))
route_to = replica
# Force admin user queries to primary
[query_rule.admin_primary]
user = admin
route_to = primary
# ─── Blocking Rules ──────────────────────────────────────────────────────
# Block TRUNCATE in production
[query_rule.no_truncate]
match = ^\s*TRUNCATE
block = true
block_msg = "TRUNCATE is not allowed through this proxy"
# Block DROP TABLE for non-admin users
[query_rule.no_drop]
match = ^\s*DROP\s+TABLE
block = true
block_msg = "DDL not allowed — use the migration endpoint"
# ─── Rewrite Rules ───────────────────────────────────────────────────────
# Rewrite legacy table name transparently
[query_rule.rewrite_legacy_users]
match = \blegacy_users\b
rewrite_to = users
# ─── Throttle Rules ──────────────────────────────────────────────────────
# Rate-limit expensive report queries
[throttle.heavy_reports]
match = SELECT.*GENERATE_SERIES|SELECT.*pg_stat
rate_per_second = 2
burst = 5
# Per-user rate limit
[throttle.api_user]
user = api_readonly
rate_per_second = 100
burst = 200