← Back to DevBytes

Scaling RDS: From Prototype to Production

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:

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

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

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles