Can’t Connect to RDS SQL Server

“Connection timeout expired” is the most common database ticket after any network change, new app server or cost-saving schedule. Solving an RDS SQL Server connection timeout starts with the error class, which tells you whether you’re dealing with DNS, the network, a stopped instance or a login problem, and then a short list of checks.

You’ll build Test-RdsAccess.ps1, which checks saat-sql’s status, DNS, security group and NACLs for a given client and runs a live TCP test from it. Then you’ll break access twice: a new app server outside the allowed security group, and a database someone stopped.

TimeYou needBy the end you can
60 minutesLessons 42 to 44 and 57 done (saat-sql, Start-SqlLab.ps1, DbSg allowing 1433 from AppSg), signed in as saat-admin. Cost: a few cents (saat-sql and two t3.micro instances for under an hour).Read SQL Server connection error classes, understand Publicly Accessible and private DNS, check security groups and NACLs for a specific client, run live TCP tests with Run Command, and fix access without opening the database to the world.

What it is, in one line

An RDS SQL Server connection timeout means the client never completed a TCP connection to port 1433, usually because of network rules, routing, private DNS seen from outside, or an instance that isn’t running.

In Simple Words

Calling an office extension

You dial a colleague’s extension. A number that doesn’t exist gives “invalid number” (DNS), and a blocked line just rings forever (timeout). If you reach the desk but give the wrong name, they hang up on you (login failed).

Each problem needs a different person to fix it. Shouting “the phone doesn’t work” helps nobody.

An internal extension can’t be dialled from outside the office at all. That isn’t broken; it’s private on purpose. Your RDS endpoint is the same.

Engineer's View

What decides whether 1433 answers

Status: the instance must be available. A stopped instance keeps its endpoint name, so DNS works while every connection times out. RDS starts stopped instances again after 7 days.

DNS and Publicly Accessible: with Publicly Accessible set to No, the endpoint resolves to a private IP in the DB subnet group; only clients in the VPC, over VPN or Direct Connect, or through a tunnel can reach it. Setting it to Yes also needs public subnets with an internet gateway route, and is rarely the right fix.

Network rules: the DB security group must allow TCP 1433 from the client’s security group (best) or IP, and NACLs on both subnets must allow 1433 one way and ephemeral ports back. Error classes help: name not known is DNS, a timeout is network or stopped, refused is the wrong port, and Msg 18456 Login failed means the network is fine.

How it works, step by step

Here is the order to check a database connection in.

1Classify the error

Ask for the exact message. “Login failed” ends the network investigation immediately; “timeout” starts it.

What a client needs to reach RDS SQL Server on port 1433

2Check the database side

Status available, endpoint and port as the app has them, and what the name resolves to from the client’s network.

3Check the path from that client

Security group rules for the client’s group, NACLs on both subnets, then a live TCP test from the client itself.

4Match the message to the owner

Each class has its own fix:

RDS connection error classes: DNS, timeout, refused and login failed

Always test from the real client. A connection that works from your laptop through a tunnel proves nothing about the app server’s path.

✅ Checkpoint: You can tell DNS, timeout, refused and login failures apart, and explain why a private RDS endpoint times out from the internet.

Hands-on lab: test saat-sql access from real clients

Sign in and start the SQL lab (it starts saat-sql, the endpoints and the tunnel):

cd C:\aws-lab
aws login --profile saat-admin
$env:AWS_PROFILE = 'saat-admin'
$v = Get-Content C:\aws-lab\saat-vpc.json | ConvertFrom-Json
.\Start-SqlLab.ps1

1See why your laptop can’t connect directly

Paste this whole block. It saves Test-RdsAccess.ps1 (kept for later lessons), tests the endpoint directly from your laptop, then through the tunnel:

$tra = @'
param([string]$Db = 'saat-sql', [string]$SourceInstanceId)
function Test-Cidr([string]$Ip, [string]$Cidr) {
  $net, $bits = $Cidr -split '/'
  $toInt = { param($a) $b = ([Net.IPAddress]$a).GetAddressBytes(); [uint32]$b[0] * 16777216 + [uint32]$b[1] * 65536 + [uint32]$b[2] * 256 + [uint32]$b[3] }
  $mask = if ([int]$bits -eq 0) { [uint32]0 } else { [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - [int]$bits)) }
  ((& $toInt $Ip) -band $mask) -eq ((& $toInt $net) -band $mask)
}
function Test-AclPort($Acl, [bool]$Egress, [string]$Ip, [int]$P) {
  $e = $Acl.Entries | Where-Object { $_.Egress -eq $Egress -and $_.CidrBlock -and (Test-Cidr $Ip $_.CidrBlock) -and ($_.Protocol -eq '-1' -or ($_.Protocol -eq '6' -and $_.PortRange.From -le $P -and $_.PortRange.To -ge $P)) } | Sort-Object RuleNumber | Select-Object -First 1
  $e.RuleAction -eq 'allow'
}
function Show([bool]$Ok, [string]$Text) { if ($Ok) { "[ OK ] $Text" } else { "[FAIL] $Text" } }
$r = aws rds describe-db-instances --db-instance-identifier $Db --query "DBInstances[0]" --output json | ConvertFrom-Json
Show ($r.DBInstanceStatus -eq 'available') "Status: $($r.DBInstanceStatus)"
$ep = $r.Endpoint.Address; $port = [int]$r.Endpoint.Port
"       Endpoint: $($ep):$port  PubliclyAccessible: $($r.PubliclyAccessible)"
$dbIp = (Resolve-DnsName $ep -Type A -ErrorAction SilentlyContinue | Where-Object { $_.Type -eq 'A' } | Select-Object -First 1).IPAddress
Show ([bool]$dbIp) "DNS resolves to $dbIp"
$private = $dbIp -and ((Test-Cidr $dbIp '10.0.0.0/8') -or (Test-Cidr $dbIp '172.16.0.0/12') -or (Test-Cidr $dbIp '192.168.0.0/16'))
if ($private) { "       Private IP: reachable only from inside the VPC, a VPN, or a tunnel" }
if (-not $SourceInstanceId) { return }
$s = aws ec2 describe-instances --instance-ids $SourceInstanceId --query "Reservations[0].Instances[0]" --output json | ConvertFrom-Json
$srcIp = $s.PrivateIpAddress; $srcSgs = @($s.SecurityGroups.GroupId)
"       Source: $SourceInstanceId $srcIp in $($s.SubnetId) with $($srcSgs -join ', ')"
$dbSgIds = @($r.VpcSecurityGroups.VpcSecurityGroupId)
$perms = aws ec2 describe-security-groups --group-ids $dbSgIds --query "SecurityGroups[].IpPermissions[]" --output json | ConvertFrom-Json
$allowed = $perms | Where-Object { ($_.IpProtocol -eq '-1' -or ($_.IpProtocol -eq 'tcp' -and $_.FromPort -le $port -and $_.ToPort -ge $port)) -and (($_.UserIdGroupPairs.GroupId | Where-Object { $srcSgs -contains $_ }) -or ($_.IpRanges.CidrIp | Where-Object { Test-Cidr $srcIp $_ })) }
Show ([bool]$allowed) "DB security group ($($dbSgIds -join ', ')) allows TCP $port from the source"
$dbSubnet = aws ec2 describe-network-interfaces --filters "Name=addresses.private-ip-address,Values=$dbIp" --query "NetworkInterfaces[0].SubnetId" --output text
$dbAcl = aws ec2 describe-network-acls --filters "Name=association.subnet-id,Values=$dbSubnet" --query "NetworkAcls[0]" --output json | ConvertFrom-Json
$srcAcl = aws ec2 describe-network-acls --filters "Name=association.subnet-id,Values=$($s.SubnetId)" --query "NetworkAcls[0]" --output json | ConvertFrom-Json
Show ((Test-AclPort $dbAcl $false $srcIp $port) -and (Test-AclPort $dbAcl $true $srcIp 50000)) "DB subnet NACL ($dbSubnet) in $port / out ephemeral"
Show ((Test-AclPort $srcAcl $true $dbIp $port) -and (Test-AclPort $srcAcl $false $dbIp 50000)) "Source subnet NACL out $port / in ephemeral"
'{ "commands": ["timeout 3 bash -c \"</dev/tcp/HOST/PORT\" 2>&1 && echo open || echo \"closed (exit $?)\""] }'.Replace('HOST', $ep).Replace('PORT', "$port") | Set-Content tcp-test.json
$cmd = aws ssm send-command --document-name AWS-RunShellScript --instance-ids $SourceInstanceId --parameters file://tcp-test.json --query Command.CommandId --output text
do { Start-Sleep 3; $st = aws ssm get-command-invocation --command-id $cmd --instance-id $SourceInstanceId --query Status --output text 2>$null } until ($st -in 'Success', 'Failed', 'TimedOut')
$res = (aws ssm get-command-invocation --command-id $cmd --instance-id $SourceInstanceId --query StandardOutputContent --output text).Trim()
Show ($res -eq 'open') "Live TCP test from the source: $res"
'@
$tra | Set-Content C:\aws-lab\Test-RdsAccess.ps1
.\Test-RdsAccess.ps1
$ep = aws rds describe-db-instances --db-instance-identifier saat-sql --query "DBInstances[0].Endpoint.Address" --output text
Test-NetConnection $ep -Port 1433 -InformationLevel Quiet -WarningAction SilentlyContinue
sqlcmd -S "127.0.0.1,11433" -U saatadmin -C -h -1 -Q "SET NOCOUNT ON; SELECT @@SERVERNAME"

You should see something like this:

[ OK ] Status: available
       Endpoint: saat-sql.c1a2b3c4d5e6.ap-south-1.rds.amazonaws.com:1433  PubliclyAccessible: False
[ OK ] DNS resolves to 10.20.21.143
       Private IP: reachable only from inside the VPC, a VPN, or a tunnel
False
EC2AMAZ-ABC123

Your laptop resolves the name to a private 10.20.x.x address it can’t route to, so a direct connection times out. That’s correct and safe; the tunnel through the jump host is the way in. Many “RDS is down” tickets are exactly this.

2Test from a real app server

Paste this whole block. app62 sits in AppSg, which DbSg already allows:

$ami = aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 --query Parameter.Value --output text
$app = aws ec2 run-instances --image-id $ami --instance-type t3.micro --subnet-id $v.PrivateA --security-group-ids $v.AppSg --iam-instance-profile Name=saat-ec2-ssm-profile --metadata-options HttpTokens=required --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app62},{Key=course,Value=saatvedha-aws},{Key=lesson,Value=62}]' --query "Instances[0].InstanceId" --output text
do { Start-Sleep 20; $p = aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$app" --query "InstanceInformationList[0].PingStatus" --output text } until ($p -eq 'Online')
.\Test-RdsAccess.ps1 -SourceInstanceId $app

You should see something like this:

[ OK ] Status: available
       Endpoint: saat-sql.c1a2b3c4d5e6.ap-south-1.rds.amazonaws.com:1433  PubliclyAccessible: False
[ OK ] DNS resolves to 10.20.21.143
       Private IP: reachable only from inside the VPC, a VPN, or a tunnel
       Source: i-0d4e5f6a7b8c90123 10.20.11.88 in subnet-0a1b2c3d4e5f60718 with sg-0app...
[ OK ] DB security group (sg-0db...) allows TCP 1433 from the source
[ OK ] DB subnet NACL (subnet-0f1e2d3c4b5a69788) in 1433 / out ephemeral
[ OK ] Source subnet NACL out 1433 / in ephemeral
[ OK ] Live TCP test from the source: open

Every check passes and the live test from app62 opens port 1433. The script reads exactly what AWS will enforce, then proves it from the client.

RDS SQL Server connection timeout lab output: Test-RdsAccess checklist
The Test-RdsAccess checklist from app62. Something like this.

3Learn the error classes

Paste this whole block. It shows a DNS failure and a timeout from app62, and a login failure from your laptop:

function Invoke-Linux([string]$Id, [string[]]$Lines) {
  @{ commands = $Lines } | ConvertTo-Json | Set-Content lx-cmd.json
  $cmd = aws ssm send-command --document-name AWS-RunShellScript --instance-ids $Id --parameters file://lx-cmd.json --query Command.CommandId --output text
  do { Start-Sleep 5; $st = aws ssm get-command-invocation --command-id $cmd --instance-id $Id --query Status --output text 2>$null } until ($st -in 'Success', 'Failed', 'TimedOut')
  aws ssm get-command-invocation --command-id $cmd --instance-id $Id --query StandardOutputContent --output text
}
Invoke-Linux $app @(
  'timeout 3 bash -c "</dev/tcp/saat-sql-old.example.internal/1433" 2>&1; echo "dns test exit $?"',
  ('timeout 3 bash -c "</dev/tcp/' + $ep + '/1434" 2>&1; echo "wrong port exit $?"'))
sqlcmd -S "127.0.0.1,11433" -U saatadmin -P "WrongPassword1!" -C -Q "SELECT 1"

You should see something like this:

bash: line 1: saat-sql-old.example.internal: Name or service not known
bash: line 1: /dev/tcp/saat-sql-old.example.internal/1433: Invalid argument
dns test exit 1
wrong port exit 124
Sqlcmd: Error: Microsoft ODBC Driver 18 for SQL Server : Login failed for user 'saatadmin'..

Three different failures: a name that doesn’t resolve, a port the security group doesn’t allow (timeout, exit 124), and a wrong password that reached SQL Server and was refused. Only the middle one is a network ticket.

RDS connectivity and security tab for saat-sql with endpoint, port 1433 and one inbound rule
saat-sql’s Connectivity & security tab: the name to resolve, port 1433, and the only inbound rule, so any other port times out.

✅ Checkpoint: Test-RdsAccess.ps1 shows a private IP from your laptop, all OK plus “open” from app62, and you’ve seen DNS, timeout and login failures side by side.

Break It, Then Fix It

Two timeouts that happen after perfectly normal changes.

1Break it: a new app server in its own security group

A team launches app62b with a new security group, as new services usually get:

$sg2 = aws ec2 create-security-group --group-name saat-app62-sg --description "New billing service" --vpc-id $v.VpcId --query GroupId --output text
$app2 = aws ec2 run-instances --image-id $ami --instance-type t3.micro --subnet-id $v.PrivateA --security-group-ids $sg2 --iam-instance-profile Name=saat-ec2-ssm-profile --metadata-options HttpTokens=required --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=app62b},{Key=course,Value=saatvedha-aws},{Key=lesson,Value=62}]' --query "Instances[0].InstanceId" --output text
do { Start-Sleep 20; $p = aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$app2" --query "InstanceInformationList[0].PingStatus" --output text } until ($p -eq 'Online')
.\Test-RdsAccess.ps1 -SourceInstanceId $app2 | Select-String 'FAIL|OK'

You’ll get something like this:

[ OK ] Status: available
[ OK ] DNS resolves to 10.20.21.143
[FAIL] DB security group (sg-0db...) allows TCP 1433 from the source
[ OK ] DB subnet NACL (subnet-0f1e2d3c4b5a69788) in 1433 / out ephemeral
[ OK ] Source subnet NACL out 1433 / in ephemeral
[FAIL] Live TCP test from the source: closed (exit 124)

DbSg allows AppSg, and app62b isn’t in AppSg. The tempting fix, allowing 0.0.0.0/0 or the whole VPC CIDR, opens the database to everything. Fix: allow the new service’s security group, with a description:

aws ec2 authorize-security-group-ingress --group-id $v.DbSg --ip-permissions "IpProtocol=tcp,FromPort=1433,ToPort=1433,UserIdGroupPairs=[{GroupId=$sg2,Description=1433-from-billing}]" | Out-Null
.\Test-RdsAccess.ps1 -SourceInstanceId $app2 | Select-String 'security group|Live'

You should see something like this:

[ OK ] DB security group (sg-0db...) allows TCP 1433 from the source
[ OK ] Live TCP test from the source: open

2Break it: someone stopped the database

Stop saat-sql as a cost-saving schedule might, then test from app62:

aws rds stop-db-instance --db-instance-identifier saat-sql | Out-Null
do { Start-Sleep 30; $st = aws rds describe-db-instances --db-instance-identifier saat-sql --query "DBInstances[0].DBInstanceStatus" --output text } until ($st -eq 'stopped')
.\Test-RdsAccess.ps1 -SourceInstanceId $app | Select-String 'FAIL|OK'

You’ll get something like this:

[FAIL] Status: stopped
[ OK ] DNS resolves to 10.20.21.143
[ OK ] DB security group (sg-0db...) allows TCP 1433 from the source
[ OK ] DB subnet NACL (subnet-0f1e2d3c4b5a69788) in 1433 / out ephemeral
[ OK ] Source subnet NACL out 1433 / in ephemeral
[FAIL] Live TCP test from the source: closed (exit 124)
RDS connection failures: security group missing the app and a stopped instance
Both failures and their fixes. Something like this.

The name still resolves and every rule is fine, which is why stopped databases fool people into chasing the network. Fix: start it, then find who stopped it (Get-RecentChanges.ps1 from Lesson 56) and agree a schedule with the app team:

aws rds start-db-instance --db-instance-identifier saat-sql | Out-Null
aws rds wait db-instance-available --db-instance-identifier saat-sql
.\Test-RdsAccess.ps1 -SourceInstanceId $app | Select-String 'Status|Live'
.\Get-RecentChanges.ps1 -Minutes 30 | Select-String 'StopDBInstance'

You should see something like this:

[ OK ] Status: available
[ OK ] Live TCP test from the source: open
16:41:07 saat-admin rds/StopDBInstance

Now clean up. Test-RdsAccess.ps1 stays:

aws ec2 revoke-security-group-ingress --group-id $v.DbSg --ip-permissions "IpProtocol=tcp,FromPort=1433,ToPort=1433,UserIdGroupPairs=[{GroupId=$sg2}]" | Out-Null
aws ec2 terminate-instances --instance-ids $app $app2 | Out-Null
aws ec2 wait instance-terminated --instance-ids $app $app2
aws ec2 delete-security-group --group-id $sg2
Remove-Item tcp-test.json, lx-cmd.json
.\Stop-SqlLab.ps1

✅ Checkpoint: You fixed a missing security group rule for a new service and a stopped database, and traced who stopped it. app62, app62b, saat-app62-sg and the extra DbSg rule are gone, and saat-sql is stopping.

This happened at work

The Monday morning “database outage”

A logistics company’s dispatch app failed at 7 a.m. one Monday with connection timeouts to RDS SQL Server. The on-call engineer checked security groups, NACLs and route tables for an hour; nothing had changed.

The database had been stopped on Friday evening by a cost-saving Lambda that someone had pointed at the wrong tag. The instance still showed up in the console list, the endpoint still resolved, and the alarms only watched CPU, which simply had no data.

They fixed the Lambda’s tag filter, added an EventBridge rule that alerts whenever a production database is stopped, and added “status available?” as the first line of their connection runbook.

Common mistakes and troubleshooting

MistakeWhat happensWhat to do
Testing from a laptop instead of the app serverWrong conclusions either wayTest from the real client
Making RDS public to “fix” a timeoutDatabase exposed to the internetUse VPN, tunnel or in-VPC clients
Allowing 0.0.0.0/0 or the VPC CIDR on 1433Anything can reach the databaseAllow specific security groups
Ignoring the error classNetwork team chases login failuresClassify: DNS, timeout, refused, login
Forgetting stopped instancesHours on network checksCheck status first; alert on StopDBInstance
Old endpoint in config after restoreName not known or wrong databaseUse a CNAME you control, or update config

Interview questions

Click a question to open it. Each one is written the way an interviewer would ask it. Under it you will see why they ask it and one way you could answer, in your own words.

Fresher level

1. A developer says their app times out connecting to RDS SQL Server. What would you check?

Why they ask this: They want to see that you check the path step by step, and that you test from where the app really runs.

How you might answer: First I check the instance is available and that they’re using the right endpoint and port. Then I check DNS resolves the endpoint, and the DB security group allows the app’s security group on port 1433.

I also check the NACLs in both directions. Finally I test the connection from the app host itself, not from my laptop.

2. Can you explain what “Publicly Accessible = No” means on an RDS instance?

Why they ask this: They want to check that you understand how RDS is exposed, and why you can’t reach it from home.

How you might answer: It means the endpoint resolves to a private IP. You can only reach it from inside the VPC or from networks connected to it, like a VPN.

That’s the setting I want for almost every database.

3. What’s the difference between “Login failed” with error 18456 and a timeout?

Why they ask this: They want to see that you can tell a network problem from a credentials problem in seconds.

How you might answer: Login failed means the network path works and SQL Server got my request, but it rejected the credentials. So I check the username, password or the login itself.

A timeout means the connection never completed, so I look at the network: security groups, NACLs, routes and DNS.

2–5 years experience

4. Why would you allow a security group in the database’s rules instead of an IP range?

Why they ask this: They want to see that you design access rules that stay correct as the app scales and changes.

How you might answer: When I allow the app’s security group, the rule follows the instances as they scale or change IPs. I don’t need to update anything.

It’s also tighter, because only members of that group can connect, not everything in a range.

5. You need to test port 1433 from a Linux server that has no SQL tools installed. How would you do it?

Why they ask this: They want to see that you can test connectivity with basic tools that are almost always there.

How you might answer: I’d use bash’s /dev/tcp with timeout, or nc -zv, or curl telnet:// to the endpoint on 1433. Any of these tells me if the port opens.

If I can’t SSH to the server, I run the same test through Run Command.

6. The database is reachable from one app subnet but not from another. What could explain that?

Why they ask this: They want to see that you compare the working path with the broken one, which is the fastest way to find the difference.

How you might answer: The subnets might have different NACLs, or the other app’s security group isn’t allowed on the DB. The second subnet could also use a different route table, for example one that sends traffic through a firewall.

If it’s actually in a different VPC, it needs peering or Transit Gateway routes to get there.

Senior level

7. Apps in 10 different VPCs need to reach shared databases. How would you design the connectivity?

Why they ask this: They want to see how you build private, controlled network access at scale, and keep the database rules under control.

How you might answer: I’d use a shared services VPC or a Transit Gateway with route tables. I’d reference security groups where that’s possible, which is in the same VPC or over peering, and use private DNS.

I’d add RDS Proxy where it helps, keep every endpoint private, and put changes to DB security groups under monitored change control.

8. A cost-saving script stopped a production database at night. How would you stop that from happening again?

Why they ask this: They want to see that you add controls that protect production without killing the cost savings.

How you might answer: I’d make the schedules tag-based with strict filters, so they only touch non-production. Then I’d add SCPs or IAM conditions that deny StopDBInstance on anything tagged as production.

EventBridge would alert us on every stop event, and we’d write a runbook for it.

Quick quiz

Q1. From your laptop, a private RDS endpoint resolves to?

  1. A public IP
  2. A private 10.x IP
  3. Nothing
  4. 127.0.0.1
Show answer

B — Publicly Accessible is No.

Q2. “Login failed for user” means?

  1. Network blocked
  2. Network fine, credentials rejected
  3. DNS broken
  4. Instance stopped
Show answer

B — You reached SQL Server.

Q3. Why did app62b time out?

  1. Wrong password
  2. Its security group wasn’t allowed by DbSg
  3. DNS
  4. Stopped DB
Show answer

B — DbSg allowed only AppSg.

Q4. A stopped RDS instance’s endpoint?

  1. Disappears
  2. Still resolves, but times out
  3. Becomes public
  4. Redirects
Show answer

B — DNS stays.

Q5. Best fix for a new app server needing the DB?

  1. 0.0.0.0/0 on 1433
  2. Allow its security group on 1433
  3. Make RDS public
  4. Turn off SGs
Show answer

B — Narrow and follows the instances.

Quick recap

  • Classify the error: DNS, timeout, refused or login failed.
  • Private RDS endpoints time out from the internet by design.
  • Check status, DNS, the DB security group for the client’s SG, and NACLs.
  • Test from the real client with a live TCP check.
  • Stopped databases still resolve: check status first.

Next lesson

Lesson 63: Native restore failures → You’ll fix the errors rds_restore_database gives when roles, permissions, files or option groups are wrong.

Further reading: RDS SQL Server connection timeout

Want to go deeper into RDS SQL Server connection timeout? Read Can’t connect to Amazon RDS DB instance and Working with a DB instance in a VPC. You can also see the full plan for this course on the AWS Cloud Administration course page.

Scroll to Top