Scaling RDS: From Prototype to Production
Amazon Relational Database Service (RDS) is a managed relational database offering that makes it straightforward to set up, operate, and scale a database in the cloud. While spinning up an RDS instance for a prototype takes only minutes, moving that same database into a production environment that can handle real traffic, maintain high availability, and keep costs under control requires careful planning. This tutorial walks through the journey of scaling an RDS deployment from a single-instance prototype to a robust, production-ready architecture.
Why Scaling RDS Matters
In the prototype phase, a single small RDS instance is usually sufficient. You have a handful of users, low query volume, and downtime is acceptable. As your application grows, however, several problems emerge: the database becomes a bottleneck under load, a single instance represents a single point of failure, storage limits may be reached, and query performance can degrade as data volume increases. Scaling RDS is about addressing all of these concerns — performance, availability, durability, and cost — in a way that matches your application's growth trajectory.
The key dimensions of scaling RDS include vertical scaling (moving to a larger instance class), horizontal scaling (read replicas), storage scaling, connection management, and architectural patterns for high availability. Let's explore each of these in detail.
Understanding RDS Instance Classes and Vertical Scaling
Vertical scaling is the simplest form of scaling: you upgrade your RDS instance to a larger size with more CPU, memory, and network bandwidth. RDS offers several instance families optimized for different workloads. The db.t3 and db.t4g classes are burstable and good for development. The db.m5 and db.m6g classes are general-purpose and balanced. The db.r5 and db.r6g classes are memory-optimized and ideal for database workloads.
For most production databases, memory-optimized instances are preferred because database performance is often bound by how much data can fit in the buffer cache. Here is how you modify an RDS instance using the AWS CLI:
# Upgrade from a prototype instance to a production-grade instance
aws rds modify-db-instance \
--db-instance-identifier my-prod-database \
--db-instance-class db.r6g.2xlarge \
--apply-immediately
# For a controlled approach with minimal downtime, use a maintenance window
aws rds modify-db-instance \
--db-instance-identifier my-prod-database \
--db-instance-class db.r6g.2xlarge \
--no-apply-immediately
When you modify an instance class, RDS performs a failover for Multi-AZ deployments or a brief restart for single-AZ deployments. The --apply-immediately flag triggers the change right away, while omitting it schedules the change for the next maintenance window. For production systems, always test vertical scaling in a staging environment first and schedule changes during low-traffic periods.
Choosing the Right Instance Size
A common mistake is over-provisioning from the start. A better approach is to monitor your workload and scale up only when metrics indicate pressure. Key CloudWatch metrics to watch include:
CPUUtilization— sustained values above 70% suggest CPU pressureDatabaseConnections— approaching the instance limit indicates connection exhaustionFreeableMemory— low values mean the buffer cache is under pressureReadIOPSandWriteIOPS— high values relative to provisioned IOPS indicate storage bottlenecksBufferCacheHitRatio— values below 95% suggest you need more memory or better indexing
Storage Scaling and Provisioned IOPS
Storage is often the first bottleneck in a growing database. RDS supports several storage types: magnetic (legacy), General Purpose SSD (gp2 and gp3), and Provisioned IOPS SSD (io1 and io2). For production workloads, gp3 or Provisioned IOPS are recommended.
General Purpose gp3 storage allows you to decouple storage capacity from IOPS performance, which is a significant cost advantage over the older gp2 type. Provisioned IOPS delivers predictable performance for latency-sensitive workloads. Here is how to scale storage:
# Scale storage capacity and provision IOPS independently (gp3)
aws rds modify-db-instance \
--db-instance-identifier my-prod-database \
--storage-type gp3 \
--allocated-storage 500 \
--iops 6000 \
--apply-immediately
# For high-performance workloads, use Provisioned IOPS (io2)
aws rds modify-db-instance \
--db-instance-identifier my-prod-database \
--storage-type io2 \
--allocated-storage 1000 \
--iops 10000 \
--apply-immediately
Important notes about storage scaling: storage modifications cannot be reversed for six hours, reducing storage is not supported, and the process happens online with no downtime but may impact performance during the modification. Always plan storage capacity with headroom for growth.
Read Replicas for Horizontal Scaling
When a single instance can no longer handle the read load, read replicas allow you to distribute read queries across multiple database instances. RDS uses asynchronous replication, meaning writes to the primary are propagated to replicas with a small lag — typically under a second but can vary under heavy load.
Read replicas are ideal for read-heavy workloads such as reporting, analytics, and serving user-facing content. Here is how to create and manage read replicas:
# Create a read replica in the same region
aws rds create-db-instance-read-replica \
--db-instance-identifier my-prod-database-replica1 \
--source-db-instance-identifier my-prod-database \
--db-instance-class db.r6g.2xlarge
# Create a cross-region read replica for disaster recovery
aws rds create-db-instance-read-replica \
--db-instance-identifier my-prod-database-dr \
--source-db-instance-identifier my-prod-database \
--source-region us-east-1 \
--region us-west-2 \
--db-instance-class db.r6g.2xlarge
In your application code, you need to route write queries to the primary and read queries to replicas. Here is a Python example using SQLAlchemy with a read/write split:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import random
# Primary engine for writes
write_engine = create_engine(
'postgresql://user:password@primary-endpoint:5432/mydb',
pool_size=20,
max_overflow=10,
pool_pre_ping=True
)
# Read replica engines for reads
read_engines = [
create_engine(
'postgresql://user:password@replica-1-endpoint:5432/mydb',
pool_size=20,
max_overflow=10,
pool_pre_ping=True
),
create_engine(
'postgresql://user:password@replica-2-endpoint:5432/mydb',
pool_size=20,
max_overflow=10,
pool_pre_ping=True
),
]
WriteSession = sessionmaker(bind=write_engine)
def get_read_session():
"""Return a session bound to a random read replica."""
engine = random.choice(read_engines)
return sessionmaker(bind=engine)()
# Usage example
def get_user(user_id):
session = get_read_session()
try:
user = session.query(User).filter_by(id=user_id).first()
return user
finally:
session.close()
def create_user(name, email):
session = WriteSession()
try:
user = User(name=name, email=email)
session.add(user)
session.commit()
return user
except Exception:
session.rollback()
raise
finally:
session.close()
Be aware of replication lag. If your application reads immediately after writing and requires strict consistency, route those reads to the primary. You can monitor replication lag using the CloudWatch metric ReplicaLag.
Multi-AZ Deployments for High Availability
While read replicas improve read performance, Multi-AZ deployments provide high availability and failover support. In a Multi-AZ configuration, RDS automatically provisions and maintains a synchronous standby replica in a different Availability Zone. If the primary fails, RDS automatically fails over to the standby with no manual intervention.
Multi-AZ is not a scaling solution — the standby is not used for reads. It is purely for availability. You can combine Multi-AZ with read replicas to get both high availability and read scaling. Here is how to enable Multi-AZ:
# Enable Multi-AZ on an existing instance
aws rds modify-db-instance \
--db-instance-identifier my-prod-database \
--multi-az \
--apply-immediately
# Create a new Multi-AZ instance from the start
aws rds create-db-instance \
--db-instance-identifier my-prod-database \
--db-instance-class db.r6g.2xlarge \
--engine postgres \
--master-username admin \
--master-user-password SecurePassword123! \
--allocated-storage 200 \
--storage-type gp3 \
--multi-az \
--backup-retention-period 7 \
--vpc-security-group-ids sg-12345678
During a failover, RDS updates the DNS record to point to the new primary, so your application should always connect using the RDS endpoint DNS name rather than a hardcoded IP address. Failover typically takes 60 to 120 seconds.
Connection Management with RDS Proxy
As your application scales horizontally with more servers and connections, you may exhaust the database's connection limit. Each connection consumes memory on the database instance, and connection churn from serverless or microservices architectures can overwhelm the database. Amazon RDS Proxy solves this by pooling and sharing database connections.
RDS Proxy sits between your application and the database, maintaining a pool of connections that your application can reuse. It also improves failover resilience by pinning connections and automatically reconnecting to the new primary after a failover. Here is how to set up RDS Proxy using Terraform:
resource "aws_db_proxy" "main" {
name = "my-prod-proxy"
debug_logging = false
engine_family = "POSTGRESQL"
idle_client_timeout = 1800
require_tls = true
role_arn = aws_iam_role.proxy.arn
vpc_subnet_ids = aws_subnet.private[*].id
vpc_security_group_ids = [aws_security_group.proxy.id]
auth {
auth_scheme = "SECRETS"
description = "RDS Proxy auth"
iam_auth = "DISABLED"
secret_arn = aws_secretsmanager_secret.db_credentials.arn
}
tags = {
Name = "my-prod-proxy"
}
}
resource "aws_db_proxy_default_target_group" "default" {
db_proxy_name = aws_db_proxy.main.name
connection_pool_config {
connection_borrow_timeout = 120
max_connections_percent = 80
max_idle_connections_percent = 40
}
}
resource "aws_db_proxy_target" "main" {
db_proxy_name = aws_db_proxy.main.name
target_group_name = aws_db_proxy_default_target_group.default.name
db_instance_identifier = aws_db_instance.main.identifier
}
Update your application to connect to the proxy endpoint instead of the direct database endpoint. The proxy endpoint appears as a standard database connection string, so no application code changes are needed beyond updating the host:
# Before: direct connection
DATABASE_URL=postgresql://user:pass@my-prod-database.cluster-abc123.us-east-1.rds.amazonaws.com:5432/mydb
# After: connection through RDS Proxy
DATABASE_URL=postgresql://user:pass@my-prod-proxy.proxy-abc123.us-east-1.rds.amazonaws.com:5432/mydb
Best Practices for Production RDS
Security
- Enable encryption at rest using AWS KMS when creating the instance — it cannot be added later without a snapshot restore
- Use SSL/TLS for all connections by setting
require_tls = trueon RDS Proxy - Place RDS instances in private subnets with no direct internet access
- Use security groups to restrict access to only the application servers
- Use IAM database authentication for short-lived credentials where possible
- Store database credentials in AWS Secrets Manager and enable automatic rotation
Backup and Recovery
Automated backups are essential for production. RDS takes automatic backups during a configurable backup window and retains them for up to 35 days. For longer retention, use manual snapshots. Here is a script for automated snapshot management:
#!/bin/bash
# Create a weekly manual snapshot and clean up old ones
DB_IDENTIFIER="my-prod-database"
RETENTION_WEEKS=12
DATE=$(date +%Y-%m-%d)
SNAPSHOT_ID="${DB_IDENTIFIER}-weekly-${DATE}"
# Create the snapshot
aws rds create-db-snapshot \
--db-instance-identifier $DB_IDENTIFIER \
--db-snapshot-identifier $SNAPSHOT_ID
# Wait for completion
aws rds wait db-snapshot-available \
--db-snapshot-identifier $SNAPSHOT_ID
# Delete snapshots older than retention period
CUTOFF_DATE=$(date -d "$RETENTION_WEEKS weeks ago" +%Y-%m-%d)
aws rds describe-db-snapshots \
--db-instance-identifier $DB_IDENTIFIER \
--snapshot-type manual \
--query "DBSnapshots[?DBSnapshotIdentifier.contains(@, 'weekly') && SnapshotCreateTime < '${CUTOFF_DATE}'].DBSnapshotIdentifier" \
--output text | \
while read snapshot; do
echo "Deleting old snapshot: $snapshot"
aws rds delete-db-snapshot --db-snapshot-identifier $snapshot
done
Performance Optimization
- Enable Performance Insights to identify slow queries and wait events without additional cost
- Use Enhanced Monitoring for OS-level metrics at one-second granularity
- Regularly review and optimize indexes — unused indexes waste storage and slow down writes
- Use query plan analysis to catch sequential scans on large tables
- Consider partitioning very large tables to improve query performance and maintenance operations
- Implement connection pooling at the application level even when using RDS Proxy, as it reduces overhead further
Cost Optimization
Production RDS can become expensive quickly. Use these strategies to control costs:
# Example: Use Terraform to deploy with cost-optimized settings
resource "aws_db_instance" "main" {
identifier = "my-prod-database"
engine = "postgres"
engine_version = "15.4"
instance_class = "db.r6g.large"
allocated_storage = 200
storage_type = "gp3"
iops = 3000
storage_throughput = 125
multi_az = true
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
deletion_protection = true
copy_tags_to_snapshot = true
storage_encrypted = true
kms_key_id = aws_kms_key.rds.arn
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
# Auto-scaling storage to avoid manual intervention
max_allocated_storage = 1000
tags = {
Environment = "production"
CostCenter = "engineering"
}
}
# Auto-stop non-production instances to save costs
resource "aws_db_instance" "staging" {
identifier = "my-staging-database"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
storage_type = "gp3"
multi_az = false
backup_retention_period = 1
skip_final_snapshot = true
auto_minor_version_upgrade = true
}
Monitoring and Alerting
Set up CloudWatch alarms for critical metrics so you are notified before users are affected. Here is a CloudFormation snippet for essential alarms:
Resources:
HighCPUAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: "RDS-HighCPU"
AlarmDescription: "CPU utilization above 80% for 5 minutes"
MetricName: CPUUtilization
Namespace: AWS/RDS
Statistic: Average
Period: 300
EvaluationPeriods: 1
Threshold: 80
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: DBInstanceIdentifier
Value: my-prod-database
AlarmActions:
- !Ref SNSTopicArn
HighConnectionsAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: "RDS-HighConnections"
AlarmDescription: "Database connections above 80% of max"
MetricName: DatabaseConnections
Namespace: AWS/RDS
Statistic: Average
Period: 300
EvaluationPeriods: 2
Threshold: 1600
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: DBInstanceIdentifier
Value: my-prod-database
AlarmActions:
- !Ref SNSTopicArn
ReplicaLagAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: "RDS-HighReplicaLag"
AlarmDescription: "Replica lag above 30 seconds"
MetricName: ReplicaLag
Namespace: AWS/RDS
Statistic: Average
Period: 60
EvaluationPeriods: 3
Threshold: 30
ComparisonOperator: GreaterThanThreshold
Dimensions:
- Name: DBInstanceIdentifier
Value: my-prod-database-replica1
AlarmActions:
- !Ref SNSTopicArn
Migration Strategies for Scaling
When you need to make significant changes — such as switching database engines, moving to Aurora, or restructuring — plan the migration carefully. For zero-downtime migrations, consider these approaches:
- Blue-green deployment: Set up a new database, replicate data, and switch traffic atomically
- Logical replication: Use PostgreSQL logical replication or MySQL binlog replication to keep two databases in sync during migration
- Aurora migration: RDS provides built-in tools to migrate from standard RDS to Aurora with minimal downtime using Aurora's migration feature
Here is an example of migrating from RDS PostgreSQL to Aurora PostgreSQL:
# Create an Aurora cluster from an RDS snapshot
aws rds restore-db-cluster-from-snapshot \
--db-cluster-identifier my-aurora-cluster \
--snapshot-identifier my-rds-snapshot \
--engine aurora-postgresql \
--engine-version 15.3 \
--db-subnet-group-name my-subnet-group \
--vpc-security-group-ids sg-12345678
# Add an Aurora instance to the cluster
aws rds create-db-instance \
--db-instance-identifier my-aurora-instance-1 \
--db-instance-class db.r6g.2xlarge \
--engine aurora-postgresql \
--db-cluster-identifier my-aurora-cluster
# Add a reader instance for scaling
aws rds create-db-instance \
--db-instance-identifier my-aurora-instance-2 \
--db-instance-class db.r6g.2xlarge \
--engine aurora-postgresql \
--db-cluster-identifier my-aurora-cluster \
--db-parameter-group-name default.aurora-postgresql15
Aurora offers significant advantages for scaling: it separates compute from storage, allowing up to 15 low-latency read replicas sharing the same storage volume, automatic storage growth up to 128TB, and faster failover times. For applications that have outgrown standard RDS, Aurora is often the natural next step.
Conclusion
Scaling RDS from prototype to production is a multi-faceted journey that involves vertical scaling for immediate capacity, read replicas for horizontal read scaling, Multi-AZ for high availability, RDS Proxy for connection management, and careful attention to storage, security, monitoring, and cost. The key is to scale incrementally based on observed metrics rather than guessing capacity needs upfront. Start with a reasonably sized instance, enable Multi-AZ and automated backups from day one, add read replicas as read traffic grows, implement RDS Proxy when connection management becomes a concern, and consider migrating to Aurora when you need more advanced scaling capabilities. By following the patterns and practices outlined in this tutorial, you can build a database infrastructure that grows with your application while maintaining performance, reliability, and cost efficiency.