RDS PostgreSQL
Primary Database
Amazon RDS hosts the PostgreSQL database containing all Amply data including the tamper-evident ledger.
Configuration
Production Instance
Engine: PostgreSQL 15
InstanceClass: db.t3.small # Start small, scale up
AllocatedStorage: 100 # GB, gp3
MaxAllocatedStorage: 500 # Auto-scaling enabled
MultiAZ: true # High availability
StorageEncrypted: true
DatabaseName: amply
MasterUsername: amply_admin
Port: 5432
BackupRetentionPeriod: 7 # Days
PreferredBackupWindow: "03:00-04:00"
PreferredMaintenanceWindow: "sun:04:00-sun:05:00"
EnablePerformanceInsights: true
PerformanceInsightsRetentionPeriod: 7
EnableCloudwatchLogsExports:
- postgresql
- upgrade
DeletionProtection: true
Parameter Group
ParameterGroupFamily: postgres15
Parameters:
# Connection
max_connections: 200
# Logging
log_statement: ddl
log_min_duration_statement: 1000 # Log queries > 1s
# Performance
shared_buffers: "{DBInstanceClassMemory/4}"
effective_cache_size: "{DBInstanceClassMemory*3/4}"
work_mem: 16384 # 16MB
# Write-ahead log
wal_buffers: 16384
checkpoint_completion_target: 0.9
# Locale (for proper sorting)
lc_collate: en_US.UTF-8
lc_ctype: en_US.UTF-8
Schema
Core Tables
See Data Model for full schema.
Key tables:
organisationsfundsprojectsledger_entries(append-only)transactionsuserscampaignsbusinessescheckpoints
Ledger Table (Critical)
CREATE TABLE ledger_entries (
id VARCHAR(20) PRIMARY KEY,
organisation_id VARCHAR(20) NOT NULL REFERENCES organisations(id),
type VARCHAR(50) NOT NULL,
amount BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL,
visibility VARCHAR(30) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
prev_entry_hash VARCHAR(71),
entry_hash VARCHAR(71) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT valid_hash CHECK (entry_hash ~ '^sha256:[a-f0-9]{64}$')
);
-- CRITICAL: Prevent modification
CREATE OR REPLACE FUNCTION prevent_ledger_modification()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Ledger entries cannot be modified or deleted';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_no_update
BEFORE UPDATE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION prevent_ledger_modification();
CREATE TRIGGER ledger_no_delete
BEFORE DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION prevent_ledger_modification();
-- CRITICAL: Validate hash chain on INSERT (defense-in-depth)
CREATE OR REPLACE FUNCTION validate_chain_on_insert()
RETURNS TRIGGER AS $$
DECLARE
actual_prev_hash TEXT;
BEGIN
-- Get the actual latest entry for this organisation
SELECT entry_hash INTO actual_prev_hash
FROM ledger_entries
WHERE organisation_id = NEW.organisation_id
ORDER BY created_at DESC, id DESC
LIMIT 1;
-- First entry for org: prev_entry_hash should be NULL
-- Subsequent entries: prev_entry_hash should match actual previous
IF actual_prev_hash IS NULL THEN
IF NEW.prev_entry_hash IS NOT NULL THEN
RAISE EXCEPTION 'First entry for organisation must have NULL prev_entry_hash';
END IF;
ELSE
IF NEW.prev_entry_hash IS DISTINCT FROM actual_prev_hash THEN
RAISE EXCEPTION 'prev_entry_hash mismatch. Expected: %, Got: %',
actual_prev_hash, NEW.prev_entry_hash;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_validate_chain
BEFORE INSERT ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION validate_chain_on_insert();
Row-Level Security
-- Enable RLS
ALTER TABLE ledger_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE funds ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their organisation's data
CREATE POLICY org_isolation ON ledger_entries
FOR ALL
USING (organisation_id = current_setting('app.current_org_id')::text);
-- Application sets context before queries
SET app.current_org_id = 'org_xyz789';
Migrations
Using Alembic:
# migrations/env.py
from alembic import context
from sqlalchemy import engine_from_config
from amply.db.base import Base
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=Base.metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
Migration workflow:
# Create migration
alembic revision --autogenerate -m "Add campaign table"
# Review generated migration
# Apply to staging first
alembic upgrade head
# Apply to production
alembic upgrade head
Connection Management
Connection Pooling
Application uses SQLAlchemy async with connection pooling:
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
engine = create_async_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True, # Check connection health
pool_recycle=3600, # Recycle connections hourly
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
Connection String
postgresql+asyncpg://user:password@host:5432/amply?sslmode=require
Stored in AWS Secrets Manager.
Backup & Recovery
Automated Backups
- Daily snapshots at 03:00 UTC
- 7-day retention
- Point-in-time recovery enabled (5-minute granularity)
Manual Snapshots
# Create snapshot before major changes
aws rds create-db-snapshot \
--db-instance-identifier amply-prod \
--db-snapshot-identifier amply-prod-pre-migration-2025-01-15
Disaster Recovery
RTO: 4 hours RPO: 5 minutes (point-in-time recovery)
Recovery procedure:
- Restore from snapshot or point-in-time
- Update DNS/connection strings
- Verify data integrity
- Resume application
Read Replicas
For scaling read-heavy workloads:
# Read replica configuration
SourceDBInstanceIdentifier: amply-prod
DBInstanceClass: db.t3.small
MultiAZ: false # Replica doesn't need multi-AZ
# Application uses read replica for:
# - Public ledger queries
# - Reporting
# - Analytics
Connection routing in application:
# Write to primary
primary_engine = create_async_engine(settings.database_url)
# Read from replica (for read-heavy operations)
replica_engine = create_async_engine(settings.database_replica_url)
async def get_ledger_entries(org_id: str):
# Use replica for read-only queries
async with AsyncSession(replica_engine) as session:
return await session.execute(
select(LedgerEntry).where(...)
)
Monitoring
CloudWatch Metrics
- CPUUtilization
- FreeableMemory
- DatabaseConnections
- ReadIOPS / WriteIOPS
- ReadLatency / WriteLatency
- FreeStorageSpace
Alarms
# High CPU
- AlarmName: rds-amply-high-cpu
MetricName: CPUUtilization
Threshold: 80
Period: 300
EvaluationPeriods: 3
# Low storage
- AlarmName: rds-amply-low-storage
MetricName: FreeStorageSpace
Threshold: 10737418240 # 10 GB
ComparisonOperator: LessThanThreshold
# High connections
- AlarmName: rds-amply-high-connections
MetricName: DatabaseConnections
Threshold: 180 # 90% of max
Performance Insights
Enabled for query analysis:
- Top SQL queries by load
- Wait events
- Database load
Slow Query Log
-- Queries logged to CloudWatch
-- log_min_duration_statement = 1000 (1 second)
Security
Encryption
- At rest: AWS KMS (default key or CMK)
- In transit: SSL/TLS required
# Connection string enforces SSL
database_url = "postgresql://...?sslmode=require"
Network Security
- Private subnet only (no public access)
- Security group allows only ECS tasks
- No public IP
Credentials
- Master password in Secrets Manager
- Application user with limited privileges
- Separate read-only user for replicas
-- Application user (limited privileges)
CREATE USER amply_app WITH PASSWORD '...';
GRANT CONNECT ON DATABASE amply TO amply_app;
GRANT USAGE ON SCHEMA public TO amply_app;
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA public TO amply_app;
GRANT UPDATE ON transactions, users, organisations, ... TO amply_app;
-- Note: No UPDATE on ledger_entries (enforced by trigger anyway)
-- Read-only user
CREATE USER amply_readonly WITH PASSWORD '...';
GRANT CONNECT ON DATABASE amply TO amply_readonly;
GRANT USAGE ON SCHEMA public TO amply_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO amply_readonly;
Maintenance
Updates
- Minor version updates: Automatic
- Major version updates: Manual (with testing)
Vacuuming
PostgreSQL auto-vacuum handles routine maintenance. Monitor for:
- Dead tuples
- Table bloat
- Index bloat
Related: