Managed Databases
Fully managed relational, NoSQL, graph, and time-series databases.
Introduction
Running databases on EC2 means managing OS patching, replication, backups, and failover yourself. AWS managed database services hand all of that to AWS. You provision, connect, and query; AWS handles the rest. The right database depends entirely on your access patterns, consistency requirements, and scale. This lesson covers AWS's core managed database engines, four specialized data models, and how to build a verifiable audit trail, along with when to use each one.
Quick Navigation
1. RDS
Managed relational SQL
2. Aurora
Cloud-native relational
3. DynamoDB
Serverless NoSQL key-value
4. ElastiCache
In-memory Redis/Memcached
5. DocumentDB
MongoDB-compatible docs
6. Neptune
Graph database
7. Timestream
Time-series / IoT metrics
8. Audit Trails
Verifiable ledger on Aurora
Typical Data Tier Architecture
RDS for standard relational, Aurora for cloud-native relational, DynamoDB for high-throughput key-value, ElastiCache for caching hot reads
1. RDS - Relational Database Service
RDS runs fully managed PostgreSQL, MySQL, MariaDB, Oracle, or SQL Server. AWS handles OS patching, automated backups (up to 35 days retention), and monitoring. You just run SQL.
Multi-AZ Deployment
AWS maintains a synchronous standby in a second AZ. On failure, DNS automatically fails over to the standby in ~60 seconds. Use for production, it adds ~2x cost but eliminates single-AZ risk.
Read Replicas
Asynchronous copies of the primary for read scaling. Point your reporting queries at a replica to offload the primary. Replicas can also be promoted to standalone instances.
# Create a PostgreSQL RDS instance (single-AZ for dev) aws rds create-db-instance \ --db-instance-identifier my-postgres \ --db-instance-class db.t3.micro \ --engine postgres \ --engine-version 16.2 \ --master-username admin \ --master-user-password "S3cr3tP@ss!" \ --allocated-storage 20 \ --db-subnet-group-name my-db-subnet-group \ --vpc-security-group-ids sg-0123456789abcdef0 \ --no-publicly-accessible # Wait until it's available (takes ~5 minutes) aws rds wait db-instance-available \ --db-instance-identifier my-postgres # Get the endpoint aws rds describe-db-instances \ --db-instance-identifier my-postgres \ --query 'DBInstances[0].Endpoint.Address' --output text
2. Aurora - AWS's Cloud-Native Relational Database
Aurora is AWS's proprietary database engine, compatible with both MySQL and PostgreSQL. Unlike standard RDS, which attaches EBS volumes to compute instances, Aurora uses a distributed storage layer: data is automatically replicated 6 ways across 3 AZs, decoupled from the compute tier. This eliminates the synchronous standby lag of Multi-AZ RDS and lets Aurora add read replicas in seconds rather than minutes.
Aurora vs Standard RDS
- Storage: distributed, auto-scales 10 GB to 128 TB with no pre-provisioning
- Read replicas: up to 15 per cluster (same as RDS MySQL/PostgreSQL), created in seconds vs minutes for standard RDS
- Failover: typically under 30 seconds (vs ~60 s for RDS Multi-AZ)
- Cost: higher per-instance price; offset by eliminating separate replica storage charges
Aurora Serverless v2
Compute scales in fine-grained ACU (Aurora Capacity Unit) increments from 0.5 to 256 ACUs. The database is always online: it scales within seconds, not minutes, and there is no cold start.
- Billed per second for the compute actually used
- Ideal for variable workloads: dev/test environments, APIs with spiky traffic
- Works well behind Lambda and ECS; scales down during quiet periods automatically
# Create an Aurora PostgreSQL Serverless v2 cluster aws rds create-db-cluster \ --db-cluster-identifier my-aurora-cluster \ --engine aurora-postgresql \ --engine-version 15.4 \ --serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=8 \ --database-name appdb \ --master-username admin \ --master-user-password "S3cr3tP@ss!" \ --db-subnet-group-name my-db-subnet-group \ --vpc-security-group-ids sg-0123456789abcdef0 # Add a Serverless v2 writer instance to the cluster aws rds create-db-instance \ --db-instance-identifier my-aurora-writer \ --db-instance-class db.serverless \ --engine aurora-postgresql \ --db-cluster-identifier my-aurora-cluster # Get the cluster endpoint (writer) aws rds describe-db-clusters \ --db-cluster-identifier my-aurora-cluster \ --query 'DBClusters[0].Endpoint' --output text
Aurora Global Database: for multi-region disaster recovery or low-latency global reads, Aurora Global Database replicates to up to 5 secondary regions with typically under 1 second lag. A secondary region can be promoted to primary in under 1 minute during a regional outage.
3. DynamoDB - Serverless NoSQL
DynamoDB is a fully managed key-value and document database that scales to any throughput automatically. There are no servers to manage, no connections to pool, and no schema to migrate. Every table has a partition key (required) and an optional sort key.
Design principle: DynamoDB access patterns must be known at design time. You design the table around your queries, not the other way around. Think "what queries will I run?" before creating the table.
Use GSIs (Global Secondary Indexes) to query on non-key attributes. Each GSI has its own partition and sort key and its own read/write capacity.
# Create a DynamoDB table (orders with composite key)
aws dynamodb create-table \
--table-name Orders \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=orderId,AttributeType=S \
--key-schema \
AttributeName=userId,KeyType=HASH \
AttributeName=orderId,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST
# Put an item
aws dynamodb put-item \
--table-name Orders \
--item '{
"userId": {"S": "user-123"},
"orderId": {"S": "ord-456"},
"status": {"S": "pending"},
"total": {"N": "49.99"}
}'
# Query all orders for a user (efficient - uses the partition key)
aws dynamodb query \
--table-name Orders \
--key-condition-expression "userId = :uid" \
--expression-attribute-values '{":uid": {"S": "user-123"}}'The Python boto3 resource interface is more ergonomic for application code:
import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("Orders")
# Query orders for a user
response = table.query(
KeyConditionExpression=Key("userId").eq("user-123")
)
orders = response["Items"]
# Put a new order
table.put_item(Item={
"userId": "user-123",
"orderId": "ord-789",
"status": "pending",
"total": 49.99,
})
# Update status
table.update_item(
Key={"userId": "user-123", "orderId": "ord-789"},
UpdateExpression="SET #s = :status",
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues={":status": "shipped"},
)4. ElastiCache - In-Memory Caching
ElastiCache provides managed Redis or Memcached clusters. Redis is by far the more popular choice, it supports rich data structures (lists, sets, sorted sets, hashes), pub/sub, Lua scripting, persistence, and cluster mode.
Cache-Aside Pattern
1. Check Redis. Hit - return cached value.
2. Miss - query RDS.
3. Store result in Redis with a TTL.
4. Return to caller.
Most API reads are cache hits after warm-up.
Other Use Cases
- Session storage (stateless app servers)
- Rate limiting counters (INCR + EXPIRE)
- Leaderboards (sorted sets)
- Pub/sub for lightweight messaging
Specialized Databases
Beyond the general-purpose engines above, AWS offers three purpose-built databases (for documents, graphs, and time-series data), each designed for a data model that relational and key-value stores handle poorly. The fourth workload shape, an audit trail that must be tamper-evident by construction, no longer has a dedicated AWS service, so we build it as a pattern on Aurora PostgreSQL. You reach for one of these when your workload has a shape that a general-purpose engine would force you to work around: deeply nested documents with ad-hoc queries, highly connected data where traversing relationships is the query, append-only time-ordered measurements, or an immutable verifiable ledger.
Specialized AWS Databases
Each workload shape gets a fitting engine: documents, graphs, time-ordered metrics, and a hash-chained audit journal on Aurora PostgreSQL
5. DocumentDB - MongoDB-Compatible Document Database
DocumentDB is a fully managed document database with MongoDB API compatibility. It stores JSON-like documents with flexible schemas, supports rich ad-hoc queries, and includes a MongoDB-compatible aggregation pipeline. Unlike open-source MongoDB, DocumentDB is an AWS re-implementation that runs on a distributed storage layer similar to Aurora.
DocumentDB vs DynamoDB
- Query flexibility: DocumentDB supports ad-hoc queries,
$lookup(joins), and aggregation pipelines; DynamoDB requires access patterns to be defined at design time - Schema: DocumentDB is schema-free at the document level; DynamoDB is schema-free too, but its key structure must be fixed
- Scale model: DynamoDB is truly serverless and scales to millions of req/s; DocumentDB uses a cluster model with always-on instances
- Pricing: DynamoDB charges per request; DocumentDB charges per instance-hour regardless of load
When to Choose DocumentDB
- Migrating an existing MongoDB workload to AWS without a rewrite
- Content management, product catalogs, or user profiles where documents have variable nested structure
- You need rich ad-hoc queries or aggregation pipelines across documents without knowing access patterns upfront
- Your team already knows the MongoDB query language and you want to preserve that skill set
# Create a DocumentDB cluster (MongoDB-compatible) aws docdb create-db-cluster \ --db-cluster-identifier my-docdb-cluster \ --engine docdb \ --engine-version 5.0.0 \ --master-username admin \ --master-user-password "S3cr3tP@ss!" \ --db-subnet-group-name my-db-subnet-group \ --vpc-security-group-ids sg-0123456789abcdef0 # Add a primary instance to the cluster aws docdb create-db-instance \ --db-instance-identifier my-docdb-instance \ --db-instance-class db.r6g.large \ --engine docdb \ --db-cluster-identifier my-docdb-cluster # Connect and query with pymongo (DocumentDB uses TLS by default) # Download the CA bundle first: # wget https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
Python application code uses pymongo, the standard MongoDB driver:
import pymongo
# Connect to DocumentDB (TLS required)
# Use host/username/password kwargs - avoid embedding special chars in the URI
client = pymongo.MongoClient(
host="my-docdb-cluster.cluster-xyz.us-east-1.docdb.amazonaws.com",
port=27017,
username="admin",
password="S3cr3tP@ss!",
tls=True,
tlsCAFile="global-bundle.pem",
retryWrites=False, # DocumentDB does not support retryWrites
)
db = client["catalog"]
products = db["products"]
# Insert a document (flexible schema - no migration needed)
products.insert_one({
"sku": "WIDGET-42",
"name": "Blue Widget",
"tags": ["sale", "featured"],
"specs": {"weight_kg": 0.5, "color": "blue"},
"price": 19.99,
})
# Rich query: find all featured products under $25 sorted by price
results = products.find(
{"tags": "featured", "price": {"$lt": 25}},
sort=[("price", pymongo.ASCENDING)],
)
# Aggregation pipeline: average price by tag
pipeline = [
{"$unwind": "$tags"},
{"$group": {"_id": "$tags", "avg_price": {"$avg": "$price"}}},
{"$sort": {"avg_price": -1}},
]
for bucket in products.aggregate(pipeline):
print(bucket)Cost note: DocumentDB always has at least one running instance (minimum db.t3.medium). Unlike DynamoDB, there is no "scale to zero." For lightweight or sporadic workloads, DynamoDB on-demand is almost always cheaper.
6. Neptune - Graph Database
Neptune is a fully managed graph database purpose-built for workloads where the relationships between data are as important as the data itself. It stores data as a property graph: vertices (nodes) connected by edges, each with arbitrary key-value properties. Neptune supports two graph query languages: openCypher (used by Neo4j) and Gremlin (Apache TinkerPop).
When Graphs Win
- Social networks: friends-of-friends, influence scoring, community detection
- Fraud detection: find rings of accounts connected through shared devices, addresses, or phone numbers
- Recommendation engines: "users who bought X also bought Y" via multi-hop traversals
- Knowledge graphs: entities and relationships in healthcare, finance, supply chain
- Network topology: IT infrastructure dependencies, impact analysis
Neptune Analytics
Neptune Analytics is an in-memory graph engine for analytical traversals on large graphs. It loads a snapshot of your Neptune data and runs algorithms (PageRank, community detection, shortest paths) in seconds rather than minutes.
- Serverless: pay per query, scales to zero
- Supports openCypher and vector similarity search on graph embeddings
- Ideal for offline analytics, not real-time OLTP graph queries
# Create a Neptune cluster (graph database) aws neptune create-db-cluster \ --db-cluster-identifier my-neptune-cluster \ --engine neptune \ --engine-version 1.3.1.0 \ --db-subnet-group-name my-db-subnet-group \ --vpc-security-group-ids sg-0123456789abcdef0 \ --storage-encrypted # Add a primary instance to the cluster aws neptune create-db-instance \ --db-instance-identifier my-neptune-instance \ --db-instance-class db.r6g.large \ --engine neptune \ --db-cluster-identifier my-neptune-cluster # Get the cluster endpoint aws neptune describe-db-clusters \ --db-cluster-identifier my-neptune-cluster \ --query 'DBClusters[0].Endpoint' --output text
Neptune Analytics uses the neptune-graph boto3 client to run openCypher queries against an in-memory graph snapshot:
# Neptune supports openCypher (graph query language)
# Use the neptune-graph SDK or boto3 neptune-graph client
import boto3
# For Neptune Analytics (in-memory graph - fast traversals)
client = boto3.client("neptune-graph", region_name="us-east-1")
# openCypher query: find all fraud rings (accounts connected through shared devices)
query = """
MATCH (a1:Account)-[:USED]->(d:Device)<-[:USED]-(a2:Account)
WHERE a1 <> a2
AND a1.flagged = true
RETURN a2.accountId AS suspicious, d.deviceId AS sharedDevice
LIMIT 100
"""
response = client.execute_query(
graphIdentifier="my-neptune-graph-id",
queryString=query,
language="OPEN_CYPHER",
)
# openCypher query: shortest path between two users (social graph)
path_query = """
MATCH p = shortestPath(
(u1:User {id: 'user-001'})-[*]-(u2:User {id: 'user-999'})
)
RETURN length(p) AS hops, [n IN nodes(p) | n.id] AS path
"""Graph modeling tip: model entities as vertices and relationships as edges. Put properties that describe the relationship (e.g., since, weight) directly on the edge, not on a separate vertex. The more semantics you encode in edges, the more expressive your traversals become.
7. Timestream - Time-Series Database
Timestream is a fully serverless time-series database optimized for IoT telemetry, DevOps metrics, and application monitoring. Every record is a measurement with a timestamp, a set of dimensions (device ID, region, etc.), and one or more measure values. Timestream automatically tiers data from a fast in-memory store to cost-efficient magnetic storage as it ages.
Automatic Tiered Storage
- Memory store: recent data (hours to days); optimized for real-time queries and fast ingestion
- Magnetic store: historical data (months to years); S3-backed, queried less frequently
- You set the retention window for each tier per table; Timestream moves data automatically
- Queries span both tiers transparently, no join or union required
Built-in Time-Series Functions
Timestream extends SQL with functions that would require complex window aggregations in a general-purpose database:
bin(time, 5m)- group timestamps into fixed bucketsinterpolate_linear()- fill gaps between sparse readingstime_series()- construct time-series objects for ML feature engineeringago(1h)- relative time filter without epoch arithmetic
# Create a Timestream database and table
aws timestream-write create-database \
--database-name IoTMetrics
aws timestream-write create-table \
--database-name IoTMetrics \
--table-name SensorReadings \
--retention-properties '{
"MemoryStoreRetentionPeriodInHours": 24,
"MagneticStoreRetentionPeriodInDays": 365
}'Write records and run analytical queries with the Python boto3 client:
import boto3
from datetime import datetime, timezone
write_client = boto3.client("timestream-write", region_name="us-east-1")
query_client = boto3.client("timestream-query", region_name="us-east-1")
# Write a batch of sensor readings
now_ms = str(int(datetime.now(timezone.utc).timestamp() * 1000))
write_client.write_records(
DatabaseName="IoTMetrics",
TableName="SensorReadings",
Records=[
{
"Dimensions": [
{"Name": "device_id", "Value": "sensor-42"},
{"Name": "location", "Value": "warehouse-A"},
],
"MeasureName": "temperature",
"MeasureValue": "23.7",
"MeasureValueType": "DOUBLE",
"Time": now_ms,
},
],
)
# Query: average temperature per device over the last hour
# Uses Timestream SQL with built-in time functions
sql = """
SELECT device_id,
bin(time, 5m) AS five_min_window,
avg(measure_value::double) AS avg_temp,
max(measure_value::double) AS peak_temp
FROM "IoTMetrics"."SensorReadings"
WHERE measure_name = 'temperature'
AND time >= ago(1h)
GROUP BY device_id, bin(time, 5m)
ORDER BY five_min_window DESC
"""
response = query_client.query(QueryString=sql)
for row in response["Rows"]:
print([col.get("ScalarValue") for col in row["Data"]])8. Verifiable Audit Trails on Aurora PostgreSQL
Why not a purpose-built ledger database? AWS previously offered Amazon QLDB for this exact need, but QLDB was closed to new customers in 2024 and reached end of support in 2025. AWS's own migration guidance points customers to Amazon Aurora PostgreSQL. The good news: an immutable, cryptographically verifiable audit trail is a pattern you can build on any managed relational engine, and it keeps your audit data in the same ACID database as your business data.
When you need a tamper-evident record of every change (who changed what, when, and in what order) the pattern is an append-only journal table combined with a SHA-256 hash chain. Each journal row stores a hash computed over the previous row's hash plus its own data, exactly like a blockchain block. Altering or deleting any historical row breaks the chain from that point forward, and a single SQL query re-verifies the entire history.
How Tamper-Evidence Works
- A trigger appends a new journal row for every INSERT, UPDATE, and DELETE; the business row's full image is stored as JSONB
- Each row's
row_hash=sha256(prev_hash || row_data), chaining every entry to its predecessor - Revoking UPDATE/DELETE/TRUNCATE on the journal makes it append-only at the privilege level
- A single query recomputes and validates the whole chain; any edited or removed row makes verification fail
Use Cases
- Financial systems: bank account ledgers, payment histories, regulatory audit trails
- Supply chain: provenance tracking - who touched this item at every step
- Medical records: immutable patient history with tamper-evident proof
- Insurance claims: policy changes and claim lifecycle with full history
- HR systems: salary and role changes with a verifiable audit trail
-- One-time setup: pgcrypto provides digest() for SHA-256 hashing
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Business table (mutable current state)
CREATE TABLE accounts (
account_id TEXT PRIMARY KEY,
balance NUMERIC(15,2) NOT NULL DEFAULT 0
);
-- Append-only, hash-chained journal: every change becomes a new row.
-- Each row's hash covers the previous row's hash, so altering any
-- historical row breaks the chain from that point onward.
CREATE TABLE audit_log (
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
table_name TEXT NOT NULL,
operation TEXT NOT NULL, -- INSERT / UPDATE / DELETE
row_data JSONB NOT NULL, -- full image of the changed row
prev_hash BYTEA, -- row_hash of the previous journal entry
row_hash BYTEA NOT NULL -- sha256(prev_hash || row_data)
);
-- Trigger that appends a hash-chained entry for every write to accounts
CREATE OR REPLACE FUNCTION append_audit() RETURNS TRIGGER AS $$
DECLARE
last_hash BYTEA;
payload JSONB := to_jsonb(COALESCE(NEW, OLD)); -- OLD on DELETE
BEGIN
SELECT row_hash INTO last_hash
FROM audit_log ORDER BY seq DESC LIMIT 1; -- NULL for the first row
INSERT INTO audit_log (table_name, operation, row_data, prev_hash, row_hash)
VALUES (
TG_TABLE_NAME, TG_OP, payload, last_hash,
digest(COALESCE(last_hash, ''::bytea) || convert_to(payload::text, 'UTF8'), 'sha256')
);
RETURN NULL; -- AFTER trigger: return value is ignored
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER accounts_audit
AFTER INSERT OR UPDATE OR DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION append_audit();
-- Enforce append-only: the application role may read and append, never mutate history
GRANT SELECT, INSERT ON audit_log TO app_role;
REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM app_role;Application code writes business data in a normal ACID transaction; the trigger journals every change transparently. The Python psycopg driver reads history and verifies the chain:
import psycopg # psycopg 3
conn = psycopg.connect(
host="my-aurora-cluster.cluster-xyz.us-east-1.rds.amazonaws.com",
dbname="appdb", user="app_role", password="S3cr3tP@ss!",
)
def transfer(conn, from_id, to_id, amount):
# A single ACID transaction. The AFTER trigger appends a hash-chained
# journal row for each UPDATE automatically - no application code needed.
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE account_id = %s",
(amount, from_id),
)
cur.execute(
"UPDATE accounts SET balance = balance + %s WHERE account_id = %s",
(amount, to_id),
)
transfer(conn, "acct-001", "acct-002", 500)
# Full immutable history of an account: every revision, not just current state
with conn.cursor() as cur:
cur.execute(
"""
SELECT seq, changed_at, operation, row_data
FROM audit_log
WHERE row_data->>'account_id' = %s
ORDER BY seq
""",
("acct-001",),
)
for row in cur.fetchall():
print(row)
# Tamper-evidence check: recompute every hash and confirm the chain is intact
with conn.cursor() as cur:
cur.execute(
"""
SELECT bool_and(
row_hash = digest(
COALESCE(prev_hash, ''::bytea) || convert_to(row_data::text, 'UTF8'),
'sha256'
)
) AS chain_intact
FROM audit_log
"""
)
print("chain intact:", cur.fetchone()[0])When you need decentralized trust instead: this pattern gives you a single-owner, tamper-evident ledger operated inside your own database, which covers the vast majority of audit and compliance needs. If instead multiple untrusted parties must agree on shared state without a central authority, that is a distributed-consensus problem, and a blockchain platform is the right tool rather than a relational database.
9. Which Database for Which Problem?
| Use Case | Best Choice | Why |
|---|---|---|
| Complex relational queries, JOINs, transactions | RDS PostgreSQL | Full SQL, ACID, foreign keys |
| High-performance relational with variable or spiky load | Aurora / Aurora Serverless v2 | Faster failover, auto-scaling storage, Serverless v2 scales compute with demand |
| High-throughput key lookups (millions/sec) | DynamoDB | Single-digit ms latency at any scale, truly serverless |
| Unknown or variable access patterns | RDS or Aurora (start here) | Easier to refactor; optimize later with DynamoDB |
| Session data, hot cache, rate limiting | ElastiCache (Redis) | Sub-millisecond, TTL support, rich data types |
| MongoDB migration or rich document queries | DocumentDB | MongoDB-compatible API, ad-hoc queries, aggregation pipeline |
| Relationship-heavy data (social graphs, fraud, recommendations) | Neptune | Graph model; Gremlin/openCypher multi-hop traversals |
| IoT telemetry, metrics, time-ordered data | Timestream | Built-in time functions, automatic memory/magnetic tiering |
| Immutable audit trail, financial or medical ledger | Aurora PostgreSQL (append-only + hash chain) | Tamper-evident journal in SQL; QLDB, the former purpose-built option, is deprecated |
| Analytics and reporting on large datasets | Redshift or Athena | Columnar storage optimized for aggregations (covered in Big Data course) |
Key Takeaways
- RDS - managed relational SQL; Multi-AZ for HA, read replicas for read scaling
- Aurora - cloud-native relational engine; distributed storage across 3 AZs, faster failover, Serverless v2 for spiky workloads
- DynamoDB - serverless NoSQL; design access patterns first, partition key determines performance
- GSIs - add DynamoDB indexes for non-key query patterns without restructuring the table
- ElastiCache Redis - in-memory store for caching, sessions, rate limiting; use cache-aside pattern
- DocumentDB - MongoDB-compatible document store; use when you need rich ad-hoc queries, aggregation pipelines, or are migrating from MongoDB; cluster-based cost model differs from DynamoDB's serverless pricing
- Neptune - graph database for relationship-centric workloads; Gremlin or openCypher; model vertices and edges when relationships are as important as the data itself
- Timestream - serverless time-series; automatic memory/magnetic tiering; built-in time functions eliminate custom aggregation logic for IoT and metrics workloads
- Verifiable audit trails - build a tamper-evident, append-only journal with SHA-256 hash chaining on Aurora PostgreSQL; AWS's former purpose-built ledger (QLDB) is deprecated, and this pattern keeps audit data in the same ACID database as your business data
- Always in private subnets - databases should never have public IPs; access via your app tier