Keep databases in private subnets
I've watched teams lose production data because they thought a strong password was enough security. The truth is simpler: keep databases in private subnets, and you've already eliminated the most common attack vector. This single architectural decision stops external attackers cold and forces legitimate access through controlled entry points.
Why private subnets matter for database security
A private subnet is a network segment with no direct route to the internet. No public IP, no internet gateway, no way for an external attacker to find your database server and attempt a connection. This isn't paranoia—this is how every mature production platform operates.
When you launch an RDS instance with a public IP, you're essentially running a database server on the open internet. AWS CloudWatch will show you connection attempts within minutes, often from scanners probing common ports like 3306 (MySQL) or 5432 (PostgreSQL). Even if your security group restricts access, you're still playing defense against the entire internet.
Private subnets flip this model: instead of defending against infinite attackers, you control exactly which internal resources can reach your database. This is the principle of least privilege in network design.
Private subnets with no internet route
A private subnet is defined by its route table. While a public subnet has a route sending traffic destined for 0.0.0.0/0 to an internet gateway, a private subnet either has no such route, or sends it to a NAT gateway (which is one-way outbound only).
Here's what a typical private subnet route table looks like:
| Destination | Target | Status |
|---|---|---|
| 10.0.3.0/24 | Local | Active |
| 0.0.0.0/0 | NAT Gateway (nat-xyz) | Active |
In this configuration, instances in the 10.0.3.0/24 range can reach each other directly, and they can initiate outbound connections to the internet through the NAT gateway. But nothing from the internet can initiate a connection inbound—the NAT gateway is stateful and only allows return traffic for connections you started.
For a database that should never need outbound internet access, you might configure it with an even stricter route table containing only the local route:
| Destination | Target | Status |
|---|---|---|
| 10.0.3.0/24 | Local | Active |
This means your database can only communicate with other resources in its VPC. It can't reach the internet at all, which is exactly what you want. If your database has no business reason to contact external systems, this isolation provides defense in depth.
Allow access only from app security groups
Once your database is isolated in a private subnet, the next layer of control is the security group. A security group is a stateful firewall that defines which traffic can reach your database instance.
The best practice is to create a security group for your application tier (your ECS tasks, EC2 instances, or Lambda functions) and then allow only that security group to reach your database security group on the specific port it uses.
Let's say you have a Node.js API running in ECS with security group sg-app-tier and a PostgreSQL database with security group sg-database. Your database security group should have exactly one inbound rule:
- Type: PostgreSQL (5432)
- Protocol: TCP
- Port: 5432
- Source: sg-app-tier (the application security group, not an IP range)
Here's the Terraform equivalent:
resource "aws_security_group" "database" {
name = "production-db-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app_tier.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "production-db-sg"
}
}Notice that this rule refers to another security group by ID, not by IP address. This is powerful because if your application tier auto-scales and instances are replaced, the rule still works. You're saying "allow traffic from anything tagged with the app-tier security group," regardless of IP.
In a real multi-tier architecture, you might have multiple security groups: one for your load balancer (allows port 443 from 0.0.0.0/0), one for your app tier (allows port 8080 from the load balancer security group), and one for your database (allows port 5432 from the app tier security group). Each layer can only see the layer it needs to.
Use bastion hosts or SSM for admin access
Even if your database is in a private subnet with restrictive security groups, you still need a way for database administrators or developers to connect for maintenance, schema updates, or debugging.
A bastion host (sometimes called a jump box) is a hardened server in a public subnet that acts as a gateway to private resources. You SSH into the bastion, then SSH or use other tools to reach your database from there.
However, bastion hosts require additional maintenance, security patching, and key management. A better modern approach is AWS Systems Manager Session Manager, which provides secure shell access without exposing SSH to the internet.
With Session Manager, you:
- Attach an IAM role to an EC2 instance in your private subnet with the
AmazonSSMManagedInstanceCorepolicy - Use the AWS CLI to open a session:
aws ssm start-session --target i-1234567890abcdef0 - Your shell runs on that instance, from which you can reach your database
- All sessions are logged and audited in CloudTrail
For direct database access, you can also use AWS Systems Manager Port Forwarding to tunnel through to your private database:
aws ssm start-session \
--target i-1234567890abcdef0 \
--document-name AWS-StartPortForwardingSession \
--parameters "localPortNumber=5432,portNumber=5432,host=your-db-instance.cxxxxxx.us-east-1.rds.amazonaws.com"This command establishes a secure tunnel from your local machine to the database through the private instance, all without exposing the database to the internet.
Public RDS is a common breach cause
I've reviewed incident reports from teams that suffered data breaches, and a shocking number had a simple root cause: the RDS instance had PubliclyAccessible = true. This is usually set accidentally during development and then forgotten, or set intentionally "for debugging" and never reverted.
When RDS is publicly accessible, AWS assigns it a public IP and allows traffic from the internet to reach it (assuming the security group permits it). Attackers use automated scanners to find these instances, probe for weak credentials, and establish a foothold.
Here's a Terraform example of the wrong way:
resource "aws_db_instance" "unsafe" {
identifier = "my-database"
engine = "postgres"
publicly_accessible = true # NEVER do this in production
skip_final_snapshot = true
tags = {
Name = "my-database"
}
}And the correct way:
resource "aws_db_instance" "safe" {
identifier = "my-database"
engine = "postgres"
publicly_accessible = false # Always false in production
db_subnet_group_name = aws_db_subnet_group.private.name
skip_final_snapshot = true
tags = {
Name = "my-database"
}
}The second version places the database in a DB subnet group containing only private subnets. Even if you accidentally set publicly_accessible = true, it still can't receive traffic from outside because the underlying subnets have no internet route.
This is defense in depth: multiple layers working together. Your private subnets block external routing, your security groups block unauthorized internal connections, and your publicly_accessible = false setting blocks the RDS service from even attempting to assign a public IP.
Common mistakes
Mistake 1: Launching RDS in the default VPC. The default VPC has public subnets by default. Always create a custom VPC with dedicated private subnets for databases.
Mistake 2: Allowing overly permissive security group rules. I've seen rules allowing 0.0.0.0/0 (the entire internet) on port 5432. Your database security group should reference only the security groups that actually need access—usually just your application tier.
Mistake 2: Forgetting to restrict egress. While ingress rules often receive scrutiny, egress rules are overlooked. If your database doesn't need to initiate outbound connections, restrict egress to prevent data exfiltration.
Mistake 4: Using weak credentials as compensation. I've heard: "We expose port 3306 but the password is really strong, so it's fine." This is security theater. Use both network isolation and strong credentials. The network isolation should be your primary defense.
Mistake 5: Mixing management access with application access. Your application tier needs to connect to the database for reads and writes. Your administrative tools need to connect for schema changes and backups. Create separate security group rules or even separate database users for these different purposes. An overly permissive application user is a liability if an application server is compromised.
Key takeaways
- Private subnets have no internet route, making them invisible to external attackers
- Database security groups should allow ingress only from application security groups, not from broad IP ranges
- Use AWS Systems Manager Session Manager for secure admin access instead of exposing databases or bastion hosts to the internet
- Always set
publicly_accessible = falseon RDS instances and place them in private subnet groups - Defense in depth means combining network isolation, security groups, restrictive credentials, and access logging
- A database that cannot be reached from the internet cannot be breached by external attackers, regardless of password strength






Comments
Loading comments…