Find and close internet-exposed SSH, RDP, and database ports
For developers who need to quickly confirm whether SSH, RDP, Postgres, MySQL, or similar management ports are reachable from the public internet and shut them down without guesswork. This runbook gives you exact scan commands, host and firewall checks, cloud security-group fixes, and verification steps that catch the common edge cases.
TL;DR — If SSH, RDP, Postgres, or MySQL is reachable from
0.0.0.0/0or::/0, treat it as an incident: confirm exposure from an external network, identify whether the path is open at the cloud firewall, host firewall, or service bind address, then restrict inbound rules to VPN/bastion/admin CIDRs and bind databases to private interfaces only. Reading time: ~6 min
The scenario
It is Tuesday afternoon, you are tightening up a staging environment before cloning it into production, and a quick external scan shows 22/tcp and 5432/tcp open on the public IP. Nobody remembers intentionally exposing them. The app still works, but auth logs are filling with failed SSH attempts from random addresses, and your security scanner has already opened a ticket marked critical. You need to answer two questions fast: what is actually reachable from the internet, and what exact rule or config change closes it without breaking your deploy path.
Symptoms
- External scan shows management ports open:
$ nmap -Pn -p 22,3389,5432,3306 203.0.113.24
Starting Nmap 7.95 ( https://nmap.org )
Nmap scan report for 203.0.113.24
Host is up (0.021s latency).
PORT STATE SERVICE
22/tcp open ssh
3306/tcp open mysql
3389/tcp closed ms-wbt-server
5432/tcp open postgresql
- SSH brute-force noise in logs:
$ sudo journalctl -u ssh --since "1 hour ago" | tail
sshd[18422]: Invalid user admin from 185.234.219.42 port 49822
sshd[18422]: Received disconnect from 185.234.219.42 port 49822:11: Bye Bye [preauth]
sshd[18451]: Failed password for root from 45.95.147.31 port 60144 ssh2
- RDP exposed on Windows host:
PS> Get-WinEvent -LogName Security -MaxEvents 5 | ? {$_.Id -in 4625,4624} | ft TimeCreated,Id,Message -Auto
Typical failures include logon type 10 and repeated 4625 events.
- Postgres reachable externally:
$ nc -vz 203.0.113.24 5432
Connection to 203.0.113.24 5432 port [tcp/postgresql] succeeded!
- MySQL reachable externally:
$ nc -vz 203.0.113.24 3306
Connection to 203.0.113.24 3306 port [tcp/mysql] succeeded!
- Service listening on all interfaces locally:
$ sudo ss -ltnp '( sport = :22 or sport = :3389 or sport = :5432 or sport = :3306 )'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=812,fd=3))
LISTEN 0 244 0.0.0.0:3306 0.0.0.0:* users:(("mysqld",pid=991,fd=28))
LISTEN 0 244 [::]:5432 [::]:* users:(("postgres",pid=1044,fd=7))
- Cloud firewall/security group rule allows all sources, often shown as
0.0.0.0/0and sometimes::/0for the same port.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
Cloud firewall/security group allows 0.0.0.0/0 or ::/0 to SSH/RDP/DB ports | Very common | In your provider dashboard, open the VM/instance/network security rules and inspect inbound rules for ports 22, 3389, 5432, 3306 |
Service is bound to all interfaces (0.0.0.0 or ::) instead of private/loopback only | Very common | sudo ss -ltnp '( sport = :22 or sport = :3389 or sport = :5432 or sport = :3306 )' |
| Host firewall missing or overridden | Common | sudo nft list ruleset |
| IPv6 left open while IPv4 was restricted | Common | nmap -6 -Pn -p 22,3389,5432,3306 <ipv6-address> |
| Temporary debug rule or bootstrap script re-opened ports after deploy | Occasional | `sudo grep -RInE '0.0.0.0/0 |
| Port forwarding/NAT on edge firewall or router exposes an internal host | Less common | From the edge device dashboard, inspect NAT/port-forward rules for 22, 3389, 5432, 3306 |
Step-by-step diagnosis
- Confirm exposure from outside your network.
nmap -Pn -p 22,3389,5432,3306 <public-ip>
If any target port shows open, continue to step 2. If it shows filtered, an upstream firewall is probably blocking it; still check IPv6 in step 3.
-
Check the cloud/network perimeter first. Open your provider dashboard and inspect the instance NIC, security group, firewall policy, or VPC firewall attached to the host. Look for inbound rules allowing
0.0.0.0/0or::/0to22,3389,5432, or3306. If you find one, this is your problem. Jump to Fixes → Cloud firewall/security group allows0.0.0.0/0or::/0. -
Check IPv6 separately.
nmap -6 -Pn -p 22,3389,5432,3306 <ipv6-address>
If IPv4 is closed but IPv6 is open, this is your problem. Jump to Fixes → IPv6 left open while IPv4 was restricted.
- Check what is actually listening on the host.
sudo ss -ltnp '( sport = :22 or sport = :3389 or sport = :5432 or sport = :3306 )'
Problem output looks like 0.0.0.0:5432, 0.0.0.0:3306, or [::]:5432. For databases, that usually means the service is reachable on any interface if a firewall rule permits it. Jump to Fixes → Service is bound to all interfaces.
- Inspect host firewall rules. Linux with nftables:
sudo nft list ruleset
Linux with UFW:
sudo ufw status numbered
Windows:
Get-NetFirewallRule -Enabled True -Direction Inbound | Get-NetFirewallPortFilter | ? {$_.LocalPort -in 22,3389,5432,3306} | ft Name,Protocol,LocalPort
If there is no deny/default-drop posture, or there is an allow-any rule for these ports, jump to Fixes → Host firewall missing or overridden.
- Check whether automation re-opens the ports.
sudo grep -RInE '0\.0\.0\.0/0|::/0|22|3389|5432|3306' /etc /opt /srv 2>/dev/null | head -50
If you find startup scripts, cloud-init, Terraform, Ansible, Docker publish flags, or compose files adding broad rules, jump to Fixes → Temporary debug rule or bootstrap script re-opened ports after deploy.
- If the host looks clean but the port is still open, inspect edge NAT/port forwarding.
In your firewall/router/provider dashboard, look for NAT or port-forward entries mapping public
22,3389,5432, or3306to an internal host. If present, jump to Fixes → Port forwarding/NAT on edge firewall or router exposes an internal host.
View as text
[Internet] ---- 1. nmap ----> [Public IP]
|
v
[Cloud firewall / SG]
|
v
[Host firewall]
|
v
[Service bind]Fixes
Cloud firewall/security group allows 0.0.0.0/0 or ::/0
Delete broad inbound rules for management ports and replace them with one of:
- your VPN CIDR
- your office/static admin IP
- a bastion host security group/tag
Examples of the target rule shape:
ALLOW tcp/22 from 198.51.100.14/32
ALLOW tcp/22 from 10.8.0.0/24
ALLOW tcp/5432 from 10.0.0.0/16
DENY/NO RULE from 0.0.0.0/0 and ::/0
For databases, the usual correct answer is no public rule at all; allow only app subnets/private networks.
Verify it worked:
nmap -Pn -p 22,3389,5432,3306 <public-ip>
Expected result: filtered or closed from the public internet.
Service is bound to all interfaces (0.0.0.0 or ::) instead of private/loopback only
For Postgres, set listen_addresses to a private IP or localhost.
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = '10.0.1.15,127.0.0.1'/" /etc/postgresql/*/main/postgresql.conf
sudo systemctl restart postgresql
sudo ss -ltnp '( sport = :5432 )'
If clients are only local, use 127.0.0.1 only.
For MySQL/MariaDB:
sudo sed -i 's/^bind-address.*/bind-address = 10.0.1.15/' /etc/mysql/mysql.conf.d/mysqld.cnf
sudo systemctl restart mysql
sudo ss -ltnp '( sport = :3306 )'
For SSH, binding to a private IP is possible but usually you restrict it at the firewall instead. If you do need to pin it:
printf '\nListenAddress 10.0.1.15\n' | sudo tee -a /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl restart ssh
⚠️ If you are connected over SSH to the same host, a bad
sshd_configor wrongListenAddresscan lock you out. Keep a second session open until verification passes.
Verify it worked:
sudo ss -ltnp '( sport = :22 or sport = :5432 or sport = :3306 )'
Expected result: private IPs or 127.0.0.1, not 0.0.0.0 or [::].
Host firewall missing or overridden
Linux nftables example: default drop inbound, allow established traffic, allow SSH only from admin IP, allow Postgres only from app subnet.
sudo tee /etc/nftables.conf >/dev/null <<'EOF'
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
policy drop;
iif "lo" accept
ct state established,related accept
ip saddr 198.51.100.14 tcp dport 22 accept
ip saddr 10.0.0.0/16 tcp dport 5432 accept
ip6 saddr 2001:db8:100::14 tcp dport 22 accept
}
}
EOF
sudo systemctl enable --now nftables
sudo nft -f /etc/nftables.conf
UFW equivalent:
sudo ufw default deny incoming
sudo ufw allow from 198.51.100.14 to any port 22 proto tcp
sudo ufw allow from 10.0.0.0/16 to any port 5432 proto tcp
sudo ufw enable
Windows Firewall example for RDP restricted to one admin IP:
New-NetFirewallRule -DisplayName "RDP from admin IP" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3389 -RemoteAddress 198.51.100.14
Set-NetFirewallProfile -Profile Domain,Private,Public -DefaultInboundAction Block
Verify it worked:
nmap -Pn -p 22,3389,5432,3306 <public-ip>
Expected result: unauthorized source sees filtered.
IPv6 left open while IPv4 was restricted
Mirror your IPv4 restrictions in IPv6 rules. Common miss: you closed 0.0.0.0/0 but forgot ::/0.
Linux nftables example already supports both families with table inet; add explicit ip6 saddr rules and no broad tcp dport {22,3389,5432,3306} accept.
For service binds, disable wildcard IPv6 listeners where not needed. Example Postgres:
sudo sed -i "s/^#\?listen_addresses.*/listen_addresses = '10.0.1.15,127.0.0.1'/" /etc/postgresql/*/main/postgresql.conf
sudo systemctl restart postgresql
Then verify externally over IPv6:
nmap -6 -Pn -p 22,3389,5432,3306 <ipv6-address>
Expected result: filtered or closed.
Temporary debug rule or bootstrap script re-opened ports after deploy
Remove the source of truth that keeps reintroducing exposure. Common offenders:
- Terraform security-group rule with
cidr_blocks = ["0.0.0.0/0"] - cloud-init adding
ufw allow 5432 - Docker/Compose publishing DB ports with
-p 5432:5432
Examples:
grep -RIn '5432:5432\|3306:3306\|22:22\|3389:3389' .
If Docker is publishing a DB port publicly, change:
services:
db:
ports:
- "5432:5432"
To either no published port or loopback-only:
services:
db:
ports:
- "127.0.0.1:5432:5432"
Then recreate:
docker compose up -d
Verify it worked:
docker ps --format '{{.Names}} {{.Ports}}'
Expected result: no 0.0.0.0:5432->5432/tcp style mapping.
Port forwarding/NAT on edge firewall or router exposes an internal host
Delete the port-forward rule on the edge device for public 22, 3389, 5432, or 3306. If remote admin access is required, publish only a VPN endpoint or bastion.
Typical bad rule shape:
WAN TCP 22 -> 10.0.1.15:22
WAN TCP 3389 -> 10.0.1.20:3389
WAN TCP 5432 -> 10.0.1.30:5432
Replace with no forward, or a VPN listener only.
Verify it worked:
nmap -Pn -p 22,3389,5432,3306 <public-ip>
Expected result: filtered or closed.
Prevention
- Add an external exposure check in CI for known public IPs:
for ip in 203.0.113.24 203.0.113.25; do
nmap -Pn -p 22,3389,5432,3306 "$ip" | tee /tmp/nmap-$ip.txt
! grep -Eq '22/tcp\s+open|3389/tcp\s+open|5432/tcp\s+open|3306/tcp\s+open' /tmp/nmap-$ip.txt
done
Fail the pipeline if any management port is open.
- Pin host firewall config in code, not by hand. Example systemd check:
systemctl is-enabled nftables && systemctl is-active nftables
Alert if either exits non-zero.
- Add a startup assertion for service bind addresses:
ss -ltn '( sport = :5432 or sport = :3306 )' | grep -Eq '127\.0\.0\.1:|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.'
If it matches 0.0.0.0 or [::], fail the deploy.
- Log and alert on SSH/RDP brute-force spikes. Linux example:
journalctl -u ssh --since '5 min ago' | grep -c 'Failed password'
Alert when the count jumps above your baseline; it is often the first sign a port is public.
- Prefer VPN or bastion-only admin paths and remove direct database admin from the internet entirely. For app-to-DB traffic, allow only private subnet CIDRs in both cloud firewall and host firewall.
- Audit IPv6 explicitly in the same checks as IPv4:
nmap -6 -Pn -p 22,3389,5432,3306 <ipv6-address>
Do not treat IPv6 as implied by your IPv4 rules; it often is not.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI