CLOUD 16 min

Secure Private RDS in AWS: VPC, SSM & Bastion Guide

Learn to design a secure VPC network in AWS. We implement public/private subnets, an EC2 bastion host, and an isolated RDS MySQL database inaccessible from the Internet.

Written by Samir El Azem Crovetto • Senior Backend Engineer & DevSecOps

The Critical Risk of Public Exposure in Cloud Databases

When provisioning cloud architectures on Amazon Web Services (AWS), one of the most dangerous configuration oversights is enabling Publicly Accessible = true on managed relational database instances such as Amazon RDS or Amazon Aurora. This setting binds a public IPv4 address directly to the database engine's Elastic Network Interface (ENI), exposing critical ports like 3306 (MySQL/MariaDB) or 5432 (PostgreSQL) directly to the open Internet.

Internet-scale scanning engines like Shodan, Censys, and Shadowserver continuously scan the global IPv4 address space, detecting exposed database instances in a matter of minutes. The immediate threats include:

  • Brute force and credential stuffing attacks: Automated botnets launch thousands of login attempts per second targeting administrative accounts such as root or admin.
  • Zero-Day and N-Day exploitation: Any vulnerability in the database handshake or underlying protocol can be exploited directly without traversal through intermediate inspection layers.
  • Connection pool exhaustion (DoS): TCP SYN floods directed at the database port exhaust available connection tables (max_connections), severely disrupting legitimate workloads.
  • Regulatory non-compliance: Governance frameworks such as PCI-DSS v4.0, ISO/IEC 27001, and GDPR explicitly mandate network isolation for sensitive data stores away from public access routes.

The defense-in-depth security model mandates that relational databases reside within isolated private subnets with no route to an Internet Gateway (IGW), with all administrative access routed through identity-governed, auditable bastions.


Secure Network Architecture: Multi-Subnet VPC Topology

A well-architected Virtual Private Cloud (VPC) segments workloads across at least three distinct hierarchical tiers across multiple Availability Zones (Multi-AZ) to guarantee high availability and fault isolation:

  1. Public Subnet Tier: Houses components that must receive inbound Internet traffic, such as Application Load Balancers (ALBs) or NAT Gateways. Route tables for these subnets route 0.0.0.0/0 to an Internet Gateway (IGW).
  2. Private Application Subnet Tier: Hosts containerized workloads (ECS/EKS), microservices, or backend EC2 instances. They have no public IP addresses; outbound connectivity to the Internet (e.g. for downloading updates or calling third-party APIs) routes strictly through the NAT Gateway in the public subnet.
  3. Isolated Database Subnet Tier: Private subnets dedicated to Amazon RDS. Their route tables contain no route to 0.0.0.0/0 (neither IGW nor NAT Gateway). Consequently, database instances cannot receive Internet traffic nor initiate outbound egress, effectively neutralizing direct data exfiltration attempts.
graph TD
    subgraph Internet ["🌐 Public Internet"]
        Client["Remote Operator / Workstation"]
        Attacker["⚠️ Port Scanners / Shodan"]
    end

    subgraph AWS_Cloud ["☁️ AWS Cloud - VPC (10.0.0.0/16)"]
        IGW["Internet Gateway (IGW)"]

        subgraph Public_Subnet ["Public Subnet (10.0.1.0/24)"]
            NAT["NAT Gateway"]
            ALB["Application Load Balancer"]
        end

        subgraph Private_App_Subnet ["Private App Subnet (10.0.10.0/24)"]
            App["Microservices / Backend Instances"]
        end

        subgraph Private_DB_Subnet ["Isolated DB Subnet (10.0.20.0/24) - No Internet Route"]
            RDS_Primary["Amazon RDS Primary (3306)"]
            RDS_Standby["Amazon RDS Standby (Multi-AZ)"]
        end

        subgraph Bastion_Subnet ["Private Management Subnet (10.0.15.0/24)"]
            SSM_Bastion["EC2 Bastion (SSM Agent) - 0 Inbound Ports Open"]
        end
    end

    Attacker -.->|Blocked: No Public IP| RDS_Primary
    Client -->|AWS IAM + SSM Session Manager| SSM_Bastion
    SSM_Bastion -->|TCP 3306 - Cross-Referenced SG| RDS_Primary
    ALB -->|HTTP/gRPC| App
    App -->|TCP 3306| RDS_Primary
    RDS_Primary -.->|Synchronous Replication| RDS_Standby

    classDef public fill:#e1f5fe,stroke:#0288d1,stroke-width:2px;
    classDef private fill:#e8f5e9,stroke:#388e3c,stroke-width:2px;
    classDef isolated fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
    classDef threat fill:#ffebee,stroke:#d32f2f,stroke-width:2px;

    class Client,ALB,NAT,IGW public;
    class App,SSM_Bastion private;
    class RDS_Primary,RDS_Standby isolated;
    class Attacker threat;

Cross-Referenced Security Group Configuration

Virtual firewalls at the network interface level in AWS are governed by Security Groups (SG), which operate with stateful tracking. A prevalent antipattern is allowing database access via broad CIDR blocks (such as 10.0.0.0/16).

The recommended standard is Security Group Referencing. The database security group does not evaluate incoming traffic by source IP address; instead, it checks whether incoming packets originate from network interfaces bound to an authorized source Security Group ID:

1. Application Security Group (sg-app-backend)

  • Inbound Rules: Ports 8080/443 from ALB (sg-alb).
  • Outbound Rules: TCP 3306 destined for the database security group (sg-rds-mysql).

2. SSM Bastion Security Group (sg-bastion-ssm)

  • Inbound Rules: Zero inbound rules (0 inbound ports open; even SSH port 22 is disabled).
  • Outbound Rules: TCP 3306 destined for sg-rds-mysql and HTTPS 443 towards AWS Systems Manager VPC endpoints or NAT.

3. Amazon RDS Security Group (sg-rds-mysql)

  • Inbound Rules:
  • Type: MySQL/Aurora (TCP 3306) | Source: sg-app-backend
  • Type: MySQL/Aurora (TCP 3306) | Source: sg-bastion-ssm
  • Outbound Rules: No outbound rules needed (return TCP traffic for established connections is handled statefully by AWS).

The Bastion Evolution: From Legacy SSH to AWS Systems Manager (SSM)

Historically, private subnet database access required maintaining an SSH bastion host (jump box) configured with a public IP address and port 22 open to the world or office IP ranges. This introduced operational overhead in local key management and constant port scanning exposure, as explored in the SSH server hardening guide.

AWS Systems Manager (SSM) Session Manager completely removes the need to expose inbound listening ports or distribute SSH private keys:

Feature Legacy SSH Bastion AWS SSM Session Manager Bastion
Inbound Ports Open Requires open TCP 22 0 open inbound ports
Public IP Address Mandatory on bastion host Not required (can reside in private subnet)
Credential Management Static private keys .pem / id_rsa Short-lived federated credentials via AWS IAM
Audit Logging & Tracing Volatile local auth.log files Comprehensive logging via AWS CloudTrail, S3, and CloudWatch
Access Control Shared keys or manual Linux user setup Granular IAM policies with mandatory MFA enforcement
sequenceDiagram
    autonumber
    actor Dev as Engineer / DBA
    participant CLI as AWS CLI + Session Manager Plugin
    participant IAM as AWS IAM (MFA)
    participant SSM as AWS Systems Manager Service
    participant Bastion as EC2 Bastion (SSM Agent)
    participant RDS as Private Amazon RDS (3306)

    Dev->>CLI: aws ssm start-session (Port Forwarding)
    CLI->>IAM: Authenticate temporary credentials + MFA
    IAM-->>CLI: STS authorization token
    CLI->>SSM: Request encrypted TLS WebSocket tunnel
    SSM->>Bastion: Issue command via outbound persistent control channel
    Bastion->>RDS: Open internal TCP socket to port 3306
    SSM-->>CLI: Local tunnel listening on 127.0.0.1:3307
    Dev->>CLI: Connect DBeaver/MySQL Client to localhost:3307
    CLI->>Bastion: Encapsulated SQL stream over WebSocket
    Bastion->>RDS: Forwarded SQL queries over private VPC link
    RDS-->>Bastion: Query result set
    Bastion-->>Dev: Secured response streamed back

Step-by-Step Implementation: Zero-Port Port Forwarding to RDS

Here is the operational configuration for routing local database clients directly into private Amazon RDS over SSM:

Step 1: IAM Role for the Bastion EC2 Instance

The bastion host must have an instance profile attached containing the managed policy AmazonSSMManagedInstanceCore. This grants the pre-installed amazon-ssm-agent permission to maintain outbound communication with AWS Systems Manager:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Step 2: Bastion EC2 Deployment in Private Subnet

Deploy a cost-efficient EC2 instance (such as a t4g.nano or t4g.micro Graviton instance) running Amazon Linux 2023 in a private subnet configured with outbound NAT Gateway access (or AWS VPC endpoints for ssm, ssmmessages, and ec2messages for strictly isolated environments).

Step 3: Remote Port Forwarding Session Execution

From your local development machine with AWS CLI and Session Manager plugin installed, execute:

aws ssm start-session \
    --region eu-west-1 \
    --target i-0a1b2c3d4e5f67890 \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters '{
        "host":["production-db.c1234567890.eu-west-1.rds.amazonaws.com"],
        "portNumber":["3306"],
        "localPortNumber":["3307"]
    }'

The terminal verifies tunnel establishment:

Starting session with SessionId: samir-0987654321fedcba
Port 3307 opened for host production-db.c1234567890.eu-west-1.rds.amazonaws.com:3306.
Waiting for connections...

Now, any SQL client (DBeaver, DataGrip, MySQL Workbench, or CLI) can connect directly to 127.0.0.1:3307, with all traffic safely tunneled through AWS infrastructure to the private database.


RDS Engine Hardening: Encryption at Rest, TLS, and IAM Database Auth

Perimeter network isolation must be reinforced with engine-level hardening, aligning with container storage practices discussed in deploying production MySQL with containers:

1. KMS Encryption at Rest

Always configure RDS instances with Customer Managed Keys (KMS CMK) rather than the default AWS managed key. This ensures:

  • Transparent encryption of underlying EBS volumes.
  • Automated snapshot and read replica encryption.
  • Immediate key revocation capability during security incident response.

2. Enforcing In-Transit TLS 1.3

By default, MySQL engines may permit unencrypted fallback connections if the client does not explicitly request TLS. Enforce TLS by associating a custom DB Parameter Group:

# Enforce encrypted connections for all clients
require_secure_transport = ON

# Restrict deprecated cryptographic protocols
tls_version = TLSv1.3,TLSv1.2

Once applied, unencrypted connection attempts are rejected immediately with ERROR 3159 (HY000): Connections using insecure transport are prohibited while --requiresecuretransport=ON.

3. IAM Database Authentication

Instead of embedding hardcoded database credentials in application configuration files, Amazon RDS supports IAM Database Authentication. Microservices request short-lived (15-minute) SigV4-signed tokens via AWS STS:

# Generate short-lived authentication token
TOKEN=$(aws rds generate-db-auth-token \
    --hostname production-db.c1234567890.eu-west-1.rds.amazonaws.com \
    --port 3306 \
    --region eu-west-1 \
    --username app_user)

# Connect passing the ephemeral token as password
mysql --host=127.0.0.1 --port=3307 -u app_user --password="$TOKEN" --enable-cleartext-plugin mydb

In MySQL, configure the user to authenticate through the AWS plugin:

CREATE USER 'app_user'@'%' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'%';

This eliminates static password sprawl, seamlessly integrating database access with IAM roles and the least-privilege principles of enterprise microservices architecture.


Automated Operational Connection Script

To streamline developer onboarding without sacrificing security posture, provide an automated wrapper script:

#!/usr/bin/env bash
set -euo pipefail

INSTANCE_ID="i-0a1b2c3d4e5f67890"
RDS_ENDPOINT="production-db.c1234567890.eu-west-1.rds.amazonaws.com"
LOCAL_PORT=3307

echo "Initiating secure SSM tunnel to RDS (${RDS_ENDPOINT}:3306)..."
echo "Local port available on 127.0.0.1:${LOCAL_PORT}"

aws ssm start-session \
    --target "${INSTANCE_ID}" \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters "{\"host\":[\"${RDS_ENDPOINT}\"],\"portNumber\":[\"3306\"],\"localPortNumber\":[\"${LOCAL_PORT}\"]}"

Production Security Checklist

  • [x] RDS instance deployed inside dedicated private subnets with no route to an Internet Gateway.
  • [x] Publicly Accessible flag strictly set to false.
  • [x] Security groups rely on cross-referencing, avoiding broad 0.0.0.0/0 ingress rules.
  • [x] Administrative maintenance routed through AWS SSM Session Manager with 0 open inbound ports.
  • [x] Database storage and snapshots encrypted with customer-managed AWS KMS keys.
  • [x] requiresecuretransport = ON parameter group strictly enforced for TLS encryption in transit.
  • [x] IAM database authentication adopted for microservices to eliminate static credentials.

If you need to audit your organization's cloud architecture, implement Zero Trust bastions, or harden container clusters and relational databases, explore my Cybersecurity Consulting Services or reach out directly via About Me.