Database — RDS PostgreSQL Setup
This page covers the SPWHI Platform RDS PostgreSQL 15 shared instance on the spwhi-platform account —
instance configuration, backups, PgBouncer connection pooling, read replica, and secrets management.
WardMitra and other applications connect to dedicated schemas within this shared instance.
Architecture Overview
spwhi-platform VPC — DB Subnet (private, no internet access)
│
└── spwhi-platform-db RDS PostgreSQL 15 · Multi-AZ · db.t3.large
├── wardmitra schema WardMitra application tables
├── fieldassist schema FieldAssist application tables
├── PgBouncer Connection pooler (sidecar pod in EKS)
└── Read Replica For analytics / reporting queries
| Concern | RDS gives you |
|---|---|
| High availability | Multi-AZ automatic failover — standby in separate AZ, ~60s RTO |
| Backups | Automated daily snapshots + PITR to any second within retention window |
| Patching | Minor version auto-patching during maintenance window |
| Monitoring | Enhanced Monitoring, Performance Insights out of the box |
| Encryption | Encryption at rest (KMS) and in transit (SSL enforced) |
| Ops burden | No managing pg_hba.conf, WAL archiving, or base backups manually |
RDS Instance Setup
Terraform — Shared SPWHI Platform RDS
# modules/rds/main.tf
resource "aws_db_subnet_group" "spwhi" {
name = "spwhi-db-subnet-group"
subnet_ids = var.db_subnet_ids # Private DB subnets from VPC module
tags = {
Name = "spwhi-db-subnet-group"
Project = "spwhi-platform"
}
}
# Create schemas for each application
resource "postgresql_schema" "wardmitra" {
name = "wardmitra"
owner = "wardmitra_admin"
}
resource "postgresql_schema" "fieldassist" {
name = "fieldassist"
owner = "fieldassist_admin"
}
resource "aws_db_parameter_group" "postgres15" {
name = "spwhi-postgres15"
family = "postgres15"
# Force SSL connections — no unencrypted traffic
parameter {
name = "rds.force_ssl"
value = "1"
}
# Log slow queries > 1 second
parameter {
name = "log_min_duration_statement"
value = "1000"
}
# Log connections (useful for PgBouncer pool audit)
parameter {
name = "log_connections"
value = "1"
}
tags = { Project = "spwhi-platform", ManagedBy = "terraform" }
}
resource "aws_db_instance" "spwhi_platform" {
identifier = "spwhi-platform-db"
# Engine
engine = "postgres"
engine_version = "15.5"
parameter_group_name = aws_db_parameter_group.postgres15.name
# Instance sizing — start here, scale up as DAU grows
# t3.large: 2 vCPU, 8GB RAM — handles up to ~30k DAU comfortably
# Upgrade path: t3.large → m5.large → m5.xlarge at 50k+ DAU
instance_class = var.ward_mitra_instance_class # "db.t3.large"
# Storage
allocated_storage = 100 # GB — start conservative
max_allocated_storage = 500 # GB — autoscaling ceiling
storage_type = "gp3"
storage_encrypted = true
kms_key_id = var.kms_key_arn
# Credentials — managed by Secrets Manager (auto-rotation)
# Do NOT set username/password here — use manage_master_user_password
manage_master_user_password = true # AWS Secrets Manager native rotation
username = "wardmitra_admin"
# High Availability
multi_az = true # Standby in separate AZ — automatic failover ~60s
# Network
db_subnet_group_name = aws_db_subnet_group.spwhi.name
vpc_security_group_ids = [aws_security_group.rds_spwhi_platform.id]
publicly_accessible = false # Never — DB subnet has no internet route
# Backups
backup_retention_period = 7 # days — PITR window
backup_window = "18:30-19:30" # UTC = 00:00-01:00 IST (low traffic)
maintenance_window = "sun:19:30-sun-20:30" # UTC Sunday night
# PITR — point-in-time recovery (enabled by default when backup_retention > 0)
# Allows restore to any second within the retention window
# Protection
deletion_protection = true # Cannot delete via console or Terraform without unsetting
skip_final_snapshot = false
final_snapshot_identifier = "spwhi-platform-db-final-snapshot"
# Monitoring
monitoring_interval = 60 # Enhanced Monitoring — granular OS metrics
monitoring_role_arn = var.rds_monitoring_role_arn
performance_insights_enabled = true
performance_insights_retention_period = 7 # days (free tier)
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
tags = {
Name = "spwhi-platform-db"
App = "spwhi-platform"
Shared = "true"
Project = "spwhi-platform"
ManagedBy = "terraform"
Environment = var.environment
}
}
Security Group for RDS
resource "aws_security_group" "rds_spwhi_platform" {
name = "spwhi-platform-rds-sg"
description = "Allow PostgreSQL access from EKS nodes — shared SPWHI instance"
vpc_id = var.vpc_id
ingress {
description = "PostgreSQL from EKS node security group"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.eks_node_sg_id] # EKS nodes SG — not open to internet
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "spwhi-platform-rds-sg", Project = "spwhi-platform" }
}
Instance Sizing Reference
| Scale | DAU | Instance | vCPU | RAM | Storage |
|---|---|---|---|---|---|
| Current (POC) | < 5k | db.t3.medium | 2 | 4 GB | 50 GB gp3 |
| Phase 1 target | 5k–30k | db.t3.large | 2 | 8 GB | 100 GB gp3 |
| Medium scale | 30k–100k | db.m5.large | 2 | 8 GB | 200 GB gp3 |
| Full scale (5L users) | 100k+ | db.m5.xlarge | 4 | 16 GB | 500 GB gp3 |
Instance class changes require a reboot (Multi-AZ: ~60s failover, minimal downtime). Always apply via Terraform during the maintenance window. Never resize via console.
Backups & Point-in-Time Recovery (PITR)
What's automatically backed up
| Backup type | Frequency | Retention | What it covers |
|---|---|---|---|
| Automated snapshot | Daily | 7 days | Full DB snapshot at backup window |
| PITR transaction logs | Continuous | 7 days | Restore to any second within window |
| Manual snapshot | On-demand | Until deleted | Pre-migration, pre-major-update checkpoints |
How to restore (PITR)
# Restore to a specific point in time — creates a NEW RDS instance
# Use this for: accidental data deletion, corruption, testing
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier spwhi-platform-db \
--target-db-instance-identifier spwhi-platform-db-restored \
--restore-time 2026-04-01T10:30:00Z \
--db-instance-class db.t3.large \
--db-subnet-group-name spwhi-db-subnet-group \
--no-publicly-accessible \
--region ap-south-1
# The restored instance will be in a stopped state — start it and verify data
# Then update app connection string to point to restored instance if needed
PITR does not restore in-place. It creates a new RDS instance. You must update the application connection string (via Secrets Manager rotation) to point to the new instance after verifying the restore is correct.
Pre-migration manual snapshot
# Always take a manual snapshot before any major operation
# (migration, schema change, version upgrade)
aws rds create-db-snapshot \
--db-instance-identifier spwhi-platform-db \
--db-snapshot-identifier spwhi-platform-pre-migration-$(date +%Y%m%d) \
--region ap-south-1
Migration — Create SPWHI Instance from POC RDS Data
Use this runbook when you need to seed the SPWHI environment from the POC account's RDS.
Choose migration path
| Path | Use when | Downtime | Notes |
|---|---|---|---|
| Cross-account snapshot restore | You want to clone the full POC instance into SPWHI | Low | Fastest way to copy full DB state |
Logical export/import (pg_dump/pg_restore) | You want only WardMitra schema/data into shared instance | Medium | Best fit for shared-instance, multi-schema model |
Option A: Cross-account snapshot restore (full instance copy)
- In POC account, create a manual snapshot:
aws rds create-db-snapshot \
--db-instance-identifier councillor-db \
--db-snapshot-identifier councillor-pre-migration-$(date +%Y%m%d) \
--region ap-south-1
-
Share snapshot with SPWHI account (and KMS key if encrypted).
-
In SPWHI account, copy shared snapshot into local account:
aws rds copy-db-snapshot \
--source-db-snapshot-identifier arn:aws:rds:ap-south-1:<POC_ACCOUNT_ID>:snapshot:poc-wardmitra-pre-migration-YYYYMMDD \
--target-db-snapshot-identifier spwhi-platform-seed-YYYYMMDD \
--kms-key-id <SPWHI_KMS_KEY_ARN> \
--source-region ap-south-1 \
--region ap-south-1
- Restore the copied snapshot as SPWHI instance:
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier spwhi-platform-db \
--db-snapshot-identifier spwhi-platform-seed-YYYYMMDD \
--db-instance-class db.t3.large \
--db-subnet-group-name spwhi-db-subnet-group \
--vpc-security-group-ids <spwhi-platform-rds-sg-id> \
--no-publicly-accessible \
--multi-az \
--region ap-south-1
- Post-restore hardening:
- Apply parameter group (
rds.force_ssl=1, slow query logs) - Enable Performance Insights and CloudWatch logs
- Validate application users and rotate secrets in Secrets Manager
Option B: Logical migration into shared instance (schema-level)
Use this when SPWHI will host multiple apps in one instance and WardMitra should live in wardmitra schema.
-
Freeze writes on POC app (maintenance mode) and take final snapshot.
-
Export WardMitra schema + data from POC:
pg_dump \
--host <POC_RDS_ENDPOINT> \
--port 5432 \
--username <POC_DB_USER> \
--dbname wardmitra \
--format custom \
--no-owner \
--schema wardmitra \
--file wardmitra-poc.dump
- Prepare target schema in SPWHI instance:
CREATE SCHEMA IF NOT EXISTS wardmitra AUTHORIZATION wardmitra_admin;
GRANT USAGE, CREATE ON SCHEMA wardmitra TO wardmitra_admin;
ALTER ROLE wardmitra_admin IN DATABASE wardmitra SET search_path TO wardmitra,public;
- Restore into SPWHI shared instance:
pg_restore \
--host <SPWHI_PLATFORM_DB_ENDPOINT> \
--port 5432 \
--username wardmitra_admin \
--dbname wardmitra \
--schema wardmitra \
--no-owner \
--clean --if-exists \
wardmitra-poc.dump
- Validate before cutover:
- Row counts for critical tables (POC vs SPWHI)
- Spot check recent tickets/workflows
- Run app smoke tests against SPWHI secrets
- Cutover:
- Update
ward-mitra/prod/rdssecret to SPWHI endpoint - Force ExternalSecret sync and restart PgBouncer
- Remove maintenance mode and monitor errors/latency
Because SPWHI is a shared PostgreSQL instance with per-app schemas, Option B (logical migration) is usually safer for long-term operations than lifting the entire POC instance as-is.
PgBouncer — Connection Pooling
Why PgBouncer
PostgreSQL has a hard connection limit. At 500–800 concurrent users, the Node.js API will open many short-lived connections — each one costs memory on the RDS instance. PgBouncer sits between the app pods and RDS, maintaining a small pool of long-lived connections and multiplexing hundreds of app requests through them.
EKS — ward-mitra-prod namespace
│
├── ward-mitra-api pods (many) ←── each opens connection to PgBouncer
│ PgBouncer: 5432
│ ↓ (small pool)
└── pgbouncer pod (sidecar) ←── maintains 20–50 long-lived RDS connections
↓
RDS spwhi-platform-db: 5432
(wardmitra schema)
PgBouncer Kubernetes Deployment
# gitops/helm-charts/ward-mitra-api/templates/pgbouncer.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
namespace: ward-mitra-prod
spec:
replicas: 2 # Two instances for HA — ALB or DNS round-robin
selector:
matchLabels:
app: pgbouncer
template:
metadata:
labels:
app: pgbouncer
spec:
containers:
- name: pgbouncer
image: pgbouncer/pgbouncer:1.22.0
ports:
- containerPort: 5432
env:
- name: POSTGRESQL_HOST
valueFrom:
secretKeyRef:
name: ward-mitra-rds-creds # Synced by ESO from Secrets Manager
key: host
- name: POSTGRESQL_PORT
value: "5432"
- name: POSTGRESQL_USERNAME
valueFrom:
secretKeyRef:
name: ward-mitra-rds-creds
key: username
- name: POSTGRESQL_PASSWORD
valueFrom:
secretKeyRef:
name: ward-mitra-rds-creds
key: password
- name: PGBOUNCER_DATABASE
value: wardmitra # Shared instance; all apps connect to same instance, different schemas
- name: PGBOUNCER_SCHEMA
value: wardmitra # Schema isolation within shared instance
- name: PGBOUNCER_POOL_MODE
value: transaction # Transaction pooling — most efficient for APIs
- name: PGBOUNCER_MAX_CLIENT_CONN
value: "500" # Max connections from app pods
- name: PGBOUNCER_DEFAULT_POOL_SIZE
value: "25" # Connections to RDS per database
- name: PGBOUNCER_MIN_POOL_SIZE
value: "5"
- name: PGBOUNCER_RESERVE_POOL_SIZE
value: "5"
- name: PGBOUNCER_RESERVE_POOL_TIMEOUT
value: "3"
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
livenessProbe:
tcpSocket:
port: 5432
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: pgbouncer
namespace: ward-mitra-prod
spec:
selector:
app: pgbouncer
ports:
- port: 5432
targetPort: 5432
type: ClusterIP
Pool mode explanation
| Mode | How it works | Best for |
|---|---|---|
session | One RDS connection per client session | Long-running sessions (not APIs) |
transaction | RDS connection held only during a transaction | ✅ REST APIs — short transactions |
statement | RDS connection held only for one statement | Rarely used — breaks multi-statement txns |
Ward Mitra uses transaction mode — Node.js API requests are short, stateless, and use transactions sparingly.
App connection string
# Node.js API connects to PgBouncer — NOT directly to RDS
# PgBouncer service DNS: pgbouncer.ward-mitra-prod.svc.cluster.local
# All apps connect to the same shared spwhi-platform-db instance, but use different schemas
DATABASE_URL=postgresql://wardmitra_admin:PASSWORD@pgbouncer.ward-mitra-prod.svc.cluster.local:5432/wardmitra?search_path=wardmitra
PgBouncer → RDS connection uses SSL (enforced by rds.force_ssl=1 parameter).
App → PgBouncer connection is within the cluster (no SSL required, traffic stays in VPC).
Read Replica — Analytics & Reporting
Why a read replica. Create only when needed
If Ward Mitra's AI features include predictive analytics, sentiment heatmaps, and ward-level reporting these queries are expensive — full table scans, aggregations, GROUP BYs — and should never run on the primary RDS instance where citizen grievance submissions are landing.
The read replica takes a copy of all writes from the primary (all schemas) and makes them available for read-only queries with ~1–5 second replication lag.
Primary RDS (write + read) Read Replica (read-only)
All apps ──write──► spwhi-platform-db
│ async replication (all schemas)
◄──read── spwhi-platform-db-replica
▲
Analytics service
Admin dashboard
Grafana SQL queries
Terraform — Read Replica
resource "aws_db_instance" "spwhi_platform_replica" {
identifier = "spwhi-platform-db-replica"
replicate_source_db = aws_db_instance.spwhi_platform.identifier
instance_class = "db.t3.micro" # Smaller — read-only, lighter load; replicates all schemas
storage_encrypted = true
kms_key_id = var.kms_key_arn
# Replica does not need Multi-AZ — it IS the redundancy
multi_az = false
# No automated backups on replica — primary handles this
backup_retention_period = 0
skip_final_snapshot = true
# Performance Insights for analytics query tuning
# performance_insights_enabled = true
# Monitoring
monitoring_interval = 60
monitoring_role_arn = var.rds_monitoring_role_arn
# Allow separate security group — analytics service gets access, app pods don't need it
vpc_security_group_ids = [aws_security_group.rds_replica.id]
publicly_accessible = false
tags = {
Name = "spwhi-platform-db-replica"
App = "spwhi-platform"
Role = "read-replica"
Shared = "true"
Project = "spwhi-platform"
ManagedBy = "terraform"
}
}
What connects to the replica vs primary
| Service | Connects to | Why |
|---|---|---|
ward-mitra-api (Node.js) | PgBouncer → Primary (wardmitra schema) | Write operations, citizen grievance submissions |
fieldassist-api (Node.js) | PgBouncer → Primary (fieldassist schema) | Write operations to separate schema |
| Analytics service | Replica directly | Heavy aggregation queries — cannot block primary |
| Admin dashboard | Replica via PgBouncer | Reporting queries — read-only |
| Grafana SQL panels | Replica directly | Dashboard queries — analytics only |
The read replica is read-only at the PostgreSQL level — any write attempt will fail with an error. Configure your connection strings explicitly:
DATABASE_URL→ PgBouncer → Primary (spwhi-platform-db)DATABASE_REPLICA_URL→ Replica endpoint directly (spwhi-platform-db-replica) Both connect to the same shared instance; schema isolation is handled at the application level.
Secrets Management
RDS credentials follow the hybrid secrets strategy for the shared instance. The master password is managed by AWS Secrets Manager (native RDS rotation), and all other DB config (host, port, name) is in SSM Parameter Store (free tier). Each application (WardMitra, FieldAssist, etc.) has its own db user credentials that connect to the shared instance but have access only to their own schema.
How credentials flow into pods
AWS Secrets Manager
├── ward-mitra/prod/rds
│ JSON: { username, password, host, port, dbname, search_path }
│ └── ward-mitra-admin user (can read/write wardmitra schema only)
│
└── fieldassist/prod/rds
JSON: { username, password, host, port, dbname, search_path }
└── fieldassist-admin user (can read/write fieldassist schema only)
↓ (ESO syncs every 1 hour)
External Secrets Operator
├── ExternalSecret: ward-mitra-rds-creds
└── ExternalSecret: fieldassist-rds-creds
↓ (creates/updates)
Kubernetes Secrets
├── ward-mitra-rds-creds
│ ├── host → spwhi-platform-db-endpoint.ap-south-1.rds.amazonaws.com
│ ├── port → 5432
│ ├── username → wardmitra_admin
│ ├── password → [rotated automatically]
│ ├── dbname → wardmitra
│ └── search_path → wardmitra
│
└── fieldassist-rds-creds
├── host → spwhi-platform-db-endpoint.ap-south-1.rds.amazonaws.com
├── port → 5432
├── username → fieldassist_admin
├── password → [rotated automatically]
├── dbname → wardmitra (same instance)
└── search_path → fieldassist
↓ (mounted as env vars)
PgBouncer pods + application pods (ward-mitra-api, fieldassist-api, etc.)
ExternalSecret manifest
# gitops/helm-charts/ward-mitra-api/templates/externalsecret-rds.yaml
# Each app has its own ExternalSecret pulling from its own Secrets Manager secret
# but all connect to the shared spwhi-platform-db instance
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: ward-mitra-rds-creds
namespace: ward-mitra-prod
spec:
refreshInterval: 1h # Aligns with Secrets Manager rotation window
secretStoreRef:
name: aws-secretsmanager # ClusterSecretStore pointing to Secrets Manager
kind: ClusterSecretStore
target:
name: ward-mitra-rds-creds # K8s Secret name created/updated by ESO
creationPolicy: Owner
dataFrom:
- extract:
key: ward-mitra/prod/rds # SM secret name — ward-mitra-specific
# extract unpacks the JSON blob into individual K8s Secret keys:
# host, port, username, password, dbname, search_path
SSM parameters for non-sensitive DB config
# Shared instance parameters — all apps read these
resource "aws_ssm_parameter" "db_host" {
name = "/spwhi/platform/db/host"
type = "String"
value = aws_db_instance.spwhi_platform.address
}
resource "aws_ssm_parameter" "db_port" {
name = "/spwhi/platform/db/port"
type = "String"
value = "5432"
}
resource "aws_ssm_parameter" "db_replica_host" {
name = "/spwhi/platform/db/replica-host"
type = "String"
value = aws_db_instance.spwhi_platform_replica.address
}
# Per-app schema parameters
resource "aws_ssm_parameter" "wardmitra_db_name" {
name = "/spwhi/ward-mitra/prod/db/name"
type = "String"
value = "wardmitra"
}
resource "aws_ssm_parameter" "wardmitra_schema" {
name = "/spwhi/ward-mitra/prod/db/schema"
type = "String"
value = "wardmitra"
}
resource "aws_ssm_parameter" "fieldassist_db_name" {
name = "/spwhi/fieldassist/prod/db/name"
type = "String"
value = "wardmitra" # Same instance, different schema
}
resource "aws_ssm_parameter" "fieldassist_schema" {
name = "/spwhi/fieldassist/prod/db/schema"
type = "String"
value = "fieldassist"
}
Secrets Manager auto-rotation
When Secrets Manager rotates the RDS master password:
- Secrets Manager calls the RDS native rotation Lambda
- Lambda updates the password on the RDS instance
- Secrets Manager stores the new password in the secret
- ESO detects the change within
refreshInterval(1h) and updates the K8s Secret - PgBouncer picks up the new password on next connection — no pod restart needed
Because manage_master_user_password = true in Terraform, RDS and Secrets Manager
handle rotation automatically. You never need to touch the password manually.
If you need to force a rotation: AWS Console → Secrets Manager → ward-mitra/prod/rds → Rotate immediately.
Monitoring & Alerts
Key metrics to watch (Grafana / CloudWatch)
| Metric | Warning threshold | Critical threshold | Action |
|---|---|---|---|
CPUUtilization | > 70% for 10min | > 85% for 5min | Scale instance class up |
FreeStorageSpace | < 20 GB | < 10 GB | Autoscaling will trigger (max 500GB) |
DatabaseConnections | > 80% of max | > 95% of max | Tune PgBouncer pool size |
ReplicaLag | > 5 seconds | > 30 seconds | Investigate write load on primary |
ReadLatency | > 10ms | > 50ms | Check slow query log, add indexes |
WriteLatency | > 10ms | > 50ms | Check PgBouncer pool exhaustion |
FreeableMemory | < 2 GB | < 512 MB | Scale instance class up |
Slow query log
Queries taking longer than 1 second are logged to CloudWatch Logs
(configured via log_min_duration_statement = 1000 parameter group).
# View slow queries via AWS CLI
aws logs filter-log-events \
--log-group-name /aws/rds/instance/spwhi-platform-db/postgresql \
--filter-pattern "duration" \
--start-time $(date -d '1 hour ago' +%s000) \
--region ap-south-1 \
| jq '.events[].message' \
| grep "duration"
Performance Insights
AWS RDS Performance Insights is enabled on both primary and replica. Access via: AWS Console → RDS → spwhi-platform-db → Performance Insights
Useful for identifying:
- Top SQL queries by load
- Wait events (lock contention, I/O waits)
- Which application user is generating most load
Common Operations
Connect to RDS via EKS pod (no bastion needed)
# Run a temporary psql pod in the ward-mitra-prod namespace
# This uses the existing cluster network access — no bastion host required
# Connects to the shared spwhi-platform-db, wardmitra schema
kubectl run psql-debug \
--image=postgres:15 \
--restart=Never \
--rm -it \
--namespace=ward-mitra-prod \
--env="PGPASSWORD=$(kubectl get secret ward-mitra-rds-creds \
-n ward-mitra-prod -o jsonpath='{.data.password}' | base64 -d)" \
-- psql \
-h pgbouncer.ward-mitra-prod.svc.cluster.local \
-U wardmitra_admin \
-d wardmitra \
-c "SET search_path TO wardmitra;"
Check PgBouncer pool status
# Connect to PgBouncer admin console
kubectl exec -it deploy/pgbouncer -n ward-mitra-prod -- \
psql -h localhost -p 5432 -U pgbouncer pgbouncer
# Inside psql — PgBouncer admin commands:
SHOW POOLS; -- connection pool stats
SHOW CLIENTS; -- connected clients
SHOW SERVERS; -- RDS server connections
SHOW STATS; -- request rates and latency
Check replication lag
# On the replica — check lag in seconds
# All schemas in the shared instance replicate together
kubectl run psql-replica \
--image=postgres:15 \
--restart=Never \
--rm -it \
--namespace=ward-mitra-prod \
-- psql \
-h spwhi-platform-db-replica.ap-south-1.rds.amazonaws.com \
-U wardmitra_admin \
-d wardmitra \
-c "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::INT AS replication_lag_seconds;"
Force ESO to refresh DB credentials immediately
# Annotate the ExternalSecret to trigger immediate re-sync
# Useful after a manual Secrets Manager rotation
kubectl annotate externalsecret ward-mitra-rds-creds \
-n ward-mitra-prod \
force-sync=$(date +%s) \
--overwrite
Troubleshooting
too many connections error in app logs
PgBouncer pool may be exhausted on the shared instance. Check:
# See current pool utilisation
kubectl exec -it deploy/pgbouncer -n ward-mitra-prod -- \
psql -h localhost -p 5432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
# If cl_active is close to PGBOUNCER_MAX_CLIENT_CONN (500):
# → Increase MAX_CLIENT_CONN in PgBouncer deployment env vars
# If sv_active is close to PGBOUNCER_DEFAULT_POOL_SIZE (25):
# → Increase DEFAULT_POOL_SIZE (check RDS max_connections first)
# NOTE: This pool is shared across all apps (WardMitra, FieldAssist, etc.)
Check RDS max_connections on shared instance:
SHOW max_connections;
-- For db.t3.large: typically 171
-- Calculate: (DEFAULT_POOL_SIZE × number_of_PgBouncer_replicas) per app
-- + admin + overhead < max_connections
password authentication failed after Secrets Manager rotation
ESO hasn't synced yet. Force refresh. The rotation affects the user credential in Secrets Manager for that specific app:
kubectl annotate externalsecret ward-mitra-rds-creds \
-n ward-mitra-prod \
force-sync=$(date +%s) --overwrite
# Wait 30 seconds, check the K8s Secret was updated
kubectl get secret ward-mitra-rds-creds -n ward-mitra-prod -o jsonpath='{.data.password}' | base64 -d
# Then restart PgBouncer to pick up new creds
kubectl rollout restart deploy/pgbouncer -n ward-mitra-prod
High replication lag on replica
# Check write load on the shared primary — is it unusually high?
# This replicates ALL schemas (wardmitra, fieldassist, etc.)
aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name WriteIOPS \
--dimensions Name=DBInstanceIdentifier,Value=spwhi-platform-db \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 300 \
--statistics Average \
--region ap-south-1
If lag is persistent (> 30s), upgrade the replica instance class. Note: All schemas replicate as a single unit; lag affects all apps equally.
Related Documents
- AWS Account Setup & Access Strategy — DB subnet placement, security group context
- Terraform CI/CD — how RDS resources are provisioned and updated
- Kubernetes — EKS namespace where PgBouncer and app pods run
- Monitoring — RDS CloudWatch dashboards and alert configuration
SparkOps Advisory Services · Sanket Pethkar · March 2026 · Confidential — SPW Healthcare Innovations Pvt. Ltd.