Database network isolation controls which interfaces your database listens on, whether its port is reachable from outside, and whether a web admin tool is offering a second way in. This page documents the 7 checks in the CtrlOps Database Network Isolation audit, in the order the audit runs them.
It is the layer before everything else. A database bound to 0.0.0.0 accepts connection attempts from any address that can route to it; one bound to 127.0.0.1 is invisible to the internet regardless of how weak its password is. Every threshold below is transcribed from the audit script itself, not from general advice.
Key takeaways
Seven checks: one inventory, four per-engine bindings, one socket-level reality check, and one about the admin tool nobody remembers installing.
- Five are rated HIGH. The inventory is LOW because it is informational, and the admin-tool check is MEDIUM because it finds a footprint rather than proving it is reachable.
- Three need no root at all. The engine inventory, the socket table and the admin-tool search all read things any user can see. The four per-engine binding checks need privileged access to query the server or read its config.
- The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 10 to 15 minutes by hand.
- Check 6 is the one that settles arguments. Checks 2 to 5 read what each engine is configured to do. Check 6 reads the socket table, which is what is actually happening.
| # | Check | Engine | Severity | Root needed |
|---|---|---|---|---|
| 1 | Engine inventory | All | LOW | No |
| 2 | MySQL binding | MySQL / MariaDB | HIGH | Yes |
| 3 | PostgreSQL binding | PostgreSQL | HIGH | Yes |
| 4 | MongoDB binding | MongoDB | HIGH | Yes |
| 5 | Redis binding | Redis | HIGH | Yes |
| 6 | Exposed ports | All | HIGH | No |
| 7 | Web admin tools | All | MEDIUM | No |
nmap from another machine.Check 1: Which database engines are installed?
Before auditing exposure the audit establishes what exists. It detects MySQL and MariaDB, PostgreSQL, MongoDB and Redis by binary, process or config file, reports the version of each, and separately notes an MSSQL process if one is running.
This check never fails. It exists so the other six have a scope, and so the report tells you what is on the host as well as what is wrong with it.
How to check manually
ps -eo comm | grep -E 'mysqld|mariadbd|postgres|mongod|redis-server|sqlservr' | sort -u
mysqld --version 2>/dev/null
psql --version 2>/dev/null
mongod --version 2>/dev/null | head -1
redis-server --version 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | One or more engines were detected | Informational. Each engine and its version are listed, and the checks below are scoped to what was found. |
| SKIP | No database engine was detected | Nothing to audit on this host. The remaining six checks will also skip. |
An MSSQL process is reported as needing manual review rather than audited, because the remaining checks read config formats it does not use.
Check 2: Is MySQL bound to localhost?
MySQL's bind-address decides which interfaces accept TCP connections. The default varies by distribution and by major version, and the MySQL 8 default when no directive is present is all interfaces, which is why an absent setting is not a safe one.
The audit tries three routes in order: query the running server for the live values, read the config as root, or read the config unprivileged. Each route reports which one it used, because a config file and a running server can disagree.
How to check manually
# Ask the running server. These are the values that matter.
sudo mysql -N -e "SELECT @@bind_address, @@skip_networking"
# What the config says, which may differ
sudo grep -rhE '^[[:space:]]*bind-address' /etc/mysql /etc/my.cnf /etc/my.cnf.d 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | skip_networking is on, or the bind address is 127.0.0.1, localhost or ::1 | TCP is disabled entirely, or the socket is loopback-only. The value found is reported. |
| WARN | The bind address is any other value, or no bind-address is set at all | The value is reported. An absent directive means all interfaces on MySQL 8. Set 127.0.0.1 unless the application is genuinely remote, and firewall 3306. |
| SKIP | MySQL is not installed, the bind address could not be read, or no readable config was found without root | The reason is named. Query the server as root to confirm. |
There is no FAIL branch. Binding beyond loopback warns rather than failing, because a database on a private address serving an application tier on another host is a normal and correct architecture. The audit cannot tell that apart from an accidental exposure, so it surfaces the value and leaves the judgement to you. Check 6 is where an actually-exposed port turns into a failure.
skip_networking is worth knowing about: it disables TCP entirely and leaves only the Unix socket, which is the strongest isolation available and the right answer when the application runs on the same host.
How to fix it
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
bind-address = 127.0.0.1
# Or, for an app on another host, the private address only
# bind-address = 10.0.1.2
sudo systemctl restart mysql
ss -tln | grep 3306Check 3: Is PostgreSQL listening beyond localhost?
PostgreSQL's listen_addresses defaults to localhost, which is the safe value. It gets changed to * during setup when somebody needs remote access, and then it stays that way.
The audit asks the running cluster first and falls back to reading postgresql.conf as root.
How to check manually
sudo -u postgres psql -X -tAc 'SHOW listen_addresses'| Result | When | What it means |
|---|---|---|
| PASS | The value is localhost, 127.0.0.1, empty (socket only), unset in the config, or a list of specific addresses | The value is reported. Where specific addresses are listed, confirm each one is intended. |
| WARN | The value is * or contains 0.0.0.0 | Reachable on every interface. Restrict to localhost unless the application is remote, and firewall 5432. |
| SKIP | PostgreSQL is not installed, or root was unavailable | The two reasons are reported separately. |
Note that a specific address list passes. listen_addresses = '127.0.0.1, 10.0.1.2' is a deliberate architecture, so the audit accepts it and tells you to confirm the addresses rather than warning. Only the wildcard forms warn.
Binding is only half of PostgreSQL's answer, though: listen_addresses decides which interfaces accept a connection, and the host rules file decides which source addresses are allowed. Check 6 of the least-privilege audit covers the second half.
How to fix it
# postgresql.conf
listen_addresses = 'localhost'
# Or, for a remote app tier
# listen_addresses = '127.0.0.1, 10.0.1.2'
sudo systemctl restart postgresqlCheck 4: Is MongoDB bound to all interfaces?
MongoDB before 3.6 defaulted to binding every interface. Newer versions default to 127.0.0.1, but a config carried forward from an older install keeps the old value, and that combination, open binding plus authorization disabled, is what produced the mass ransom campaigns against exposed MongoDB instances.
The audit reads bindIp from mongod.conf.
How to check manually
sudo grep -E '^[[:space:]]*bindIp' /etc/mongod.conf| Result | When | What it means |
|---|---|---|
| PASS | bindIp contains only loopback values | The value is reported. MongoDB accepts local connections only. |
| FAIL | bindIp contains 0.0.0.0 or the IPv6 wildcard | Open MongoDB instances are mass-scanned and ransomed. Bind to 127.0.0.1. |
| WARN | No mongod.conf was found, no bindIp line exists, or bindIp lists other addresses | The reason is named. Confirm every address is intended and firewalled. |
| SKIP | MongoDB is not installed | Nothing to inspect. |
This is one of only two checks on the page that can fail, and the reason it is stricter than the MySQL and PostgreSQL equivalents is history rather than architecture: an explicit 0.0.0.0 in a MongoDB config has a much worse track record than the same value elsewhere.
How to fix it
# /etc/mongod.conf
net:
bindIp: 127.0.0.1
port: 27017sudo systemctl restart mongodCheck 5: Is Redis reachable from the network?
Redis is designed to be reached from the same machine. It executes commands immediately, which makes an exposed instance a remote code execution path rather than just a data leak: CONFIG SET can be used to write an SSH key or a crontab entry.
Two settings interact here. bind lists the interfaces. protected-mode is a safety net that refuses external connections when no password and no explicit bind are configured. The audit reads both and grades the combination.
How to check manually
sudo grep -E '^(bind|protected-mode)[[:space:]]' /etc/redis/redis.conf| Result | When | What it means |
|---|---|---|
| PASS | The bind list is loopback only, or there is no bind directive and protected-mode is not disabled | Loopback-only in practice. Set an explicit bind anyway rather than relying on protected-mode. |
| FAIL | Redis binds beyond loopback and protected-mode is no | The bind list is reported. Both safety nets are off at once. This is remote code execution, not just data exposure. |
| WARN | Redis binds beyond loopback with protected-mode still on | protected-mode helps, but bind to 127.0.0.1 and firewall 6379 rather than relying on it. |
| SKIP | Redis is not installed, or root was unavailable to read redis.conf | The two reasons are reported separately. A missing redis.conf warns instead, because the audit cannot confirm either way. |
The FAIL condition needs both things to be wrong, which is the useful part of this check. Bound externally with protected-mode yes is a warning, because protected-mode will still refuse anonymous external connections. Bound externally with protected-mode no has removed every guard at once.
How to fix it
# /etc/redis/redis.conf
bind 127.0.0.1 ::1
protected-mode yes
requirepass a-long-random-string
sudo systemctl restart redisCheck 6: Are any database ports listening externally?
Checks 2 to 5 read what each engine is configured to do. This one reads the kernel socket table, which is what is actually happening. It is the check that settles the disagreement when a config says one thing and reality says another, and it catches engines the other checks do not cover at all.
Seven ports are tested against non-loopback addresses: 3306 (MySQL and MariaDB), 5432 (PostgreSQL), 27017 (MongoDB), 6379 (Redis), 1433 (MSSQL), 9200 (Elasticsearch) and 11211 (Memcached).
How to check manually
ss -tln | grep -vE '127\.0\.0\.1:|\[::1\]:' \
| grep -E ':(3306|5432|27017|6379|1433|9200|11211)\b'| Result | When | What it means |
|---|---|---|
| PASS | No database port is listening on a non-loopback address | Nothing is reachable from off the host, whatever the configs say. |
| FAIL | One or more of the seven ports is bound externally | The port numbers are listed. Bind to 127.0.0.1 or firewall them to the application host only. |
| SKIP | Neither ss nor netstat is available | The socket table could not be read. Install iproute2. |
This check fails where checks 2 and 3 only warn, and the difference is evidence. A bind-address of 10.0.1.2 might be a deliberate private-network design. A socket actually listening on a routable interface is a fact about exposure, and two of the seven ports it tests, Elasticsearch and Memcached, have no dedicated check elsewhere in this audit at all.
How to fix it
# Primary control: remove the socket
# (see checks 2 to 5 for the per-engine setting)
# Second layer: filter the port
sudo ufw deny 3306
sudo ufw deny 5432
sudo ufw deny 27017
sudo ufw deny 6379
# Verify from another machine
nmap -Pn -p 3306,5432,27017,6379 your-server-ipCheck 7: Is a web database admin tool installed?
phpMyAdmin, Adminer and pgAdmin are convenient and they are also among the most scanned paths on the internet. Automated bots probe /phpmyadmin, /pma, /adminer.php and a dozen variants on every address they find, and a reachable login form starts collecting brute-force attempts within minutes.
The audit looks for a footprint several ways: the packaged directories under /usr/share, an installed package according to dpkg or rpm, a running pgAdmin process, and any adminer*.php or phpmyadmin path inside the web roots.
How to check manually
ls -d /usr/share/phpmyadmin /usr/share/phpMyAdmin /usr/share/adminer 2>/dev/null
dpkg -l phpmyadmin 2>/dev/null | grep '^ii'
pgrep -f pgadmin
find /var/www /srv/www /usr/share/nginx -maxdepth 4 \
\( -iname 'adminer*.php' -o -iname 'phpmyadmin' \) 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | No phpMyAdmin, Adminer or pgAdmin footprint was found | No web-based database administration surface on this host. |
| WARN | Any of them was found | What was found is listed. Put it behind authentication, HTTPS and an IP allowlist, or remove it. |
This is MEDIUM and it warns rather than failing, because finding the files does not prove they are reachable. A phpMyAdmin package installed but not wired into any virtual host is a footprint, not an exposure. The audit cannot tell the difference without making an HTTP request, which it does not do, so it reports the footprint and asks you to check.
How to fix it
# Best: remove it
sudo apt remove phpmyadmin
sudo rm -f /var/www/html/adminer.php
# Or restrict to localhost and reach it through an SSH tunnel
# nginx:
# location /phpmyadmin { allow 127.0.0.1; deny all; }
ssh -L 8080:localhost:80 user@your-server
# then browse http://localhost:8080/phpmyadmin/phpmyadmin to a random string stops the low-effort scanners and does nothing about a targeted attacker, and it leaves an unauthenticated administrative interface reachable by anyone who finds the path. An SSH tunnel gives you the same convenience with no public surface at all, and costs one extra command.What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds.
- External reachability. Nothing here connects from outside. Every check reads local config or the local socket table, so a cloud security group filtering a port upstream is invisible and check 6 will still report the port as exposed.
- Firewall rules. The audit does not read
ufw,firewalldoriptables. The VPS firewall and network audit covers that layer, including the Docker rules that bypass the host firewall entirely. - Whether the database asks for a credential. That is the authentication audit. Binding and authentication are separate controls and an exposed database should have both.
- Whether admin tools are actually reachable. Check 7 finds a footprint on disk. It never makes an HTTP request to confirm the tool is served.
- MSSQL, Elasticsearch and Memcached configuration. Their ports are tested by check 6, but none has a dedicated binding check, so an MSSQL process is reported as needing manual review.
- Managed database services. RDS, Cloud SQL and Azure Database expose no config files or sockets to a host session; their network access lives in the provider console.
The manual audit problem at scale
Running these 7 checks by hand takes 10 to 15 minutes per server, and the awkward part is that each engine keeps its answer somewhere different: a server variable in MySQL, a setting in PostgreSQL, a YAML key in MongoDB, two interacting directives in Redis. Four engines, four places to look, four ways to be wrong.
The bigger problem is that this is the audit category most likely to change without a deploy. Someone needs remote access for an afternoon, flips a bind address, and never flips it back, because nothing breaks when they forget. The config is now wrong and the application is now working, which is the worst combination for getting noticed.
Check 6 exists precisely because of that: it reads reality rather than intent. But a single run only tells you today's reality, and what you want to know is what changed since last month. That is the same server management drift problem that compounds across every audit category.
How CtrlOps runs all 7 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole Network Isolation audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.
Watch it run
The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select Network Isolation
Choose it from the Database category. All 7 checks are listed with their descriptions, severity levels and the estimated run time (about 12 seconds in total). Toggle off any check you want to skip.
Step 3: Run it
The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.
Step 4: Read the report
You get a summary containing:
- A hardening score out of 100
- A severity breakdown: how many HIGH, MEDIUM and LOW findings
- Pass, warning, failed and skipped counts
- A findings table you can sort and filter
Step 5: Fix what failed
Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it, which matters here because every one of these fixes restarts a database.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. This is the audit worth re-running after any change to a database config, because a bind address that moved for an afternoon tends to stay moved.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 7 checks across 4 engines | About 1.5 hours | About 100 seconds |
| Compare against last month | Only if you saved the output | Automatic |
| Get a scored report | Not available | Automatic |
| Export a PDF for a client | Not available | One click |
| Fix what failed | Copy-paste from your notes | AI-generated commands behind an approval gate |
Conclusion
Network isolation is the cheapest database security control there is. Binding to 127.0.0.1 takes one line and removes the database from internet-wide scanning completely, which is more protection than any password gives you.
Five of these seven checks are HIGH severity for that reason, and the two that fail rather than warn, MongoDB bound to all interfaces and a database port actually listening externally, are the two where the evidence is unambiguous. The CtrlOps Security Audit runs the set in about 12 seconds per server, agentlessly.
The neighbouring checklists cover the other database layers: authentication, credential storage, configuration and hardening, least-privilege permissions and transport encryption. For the firewall itself, see the VPS firewall and network audit.
Frequently asked questions
Run ss -tln and look for database ports against an address other than 127.0.0.1 or ::1. That tells you what the host is offering. To learn what the internet actually sees, run nmap -Pn -p 3306,5432,27017,6379 your-server-ip from another machine, because a cloud security group may be filtering the port upstream in a way no local command can see.
Almost never. If the application runs on the same host, bind to 127.0.0.1, or disable TCP entirely with skip_networking on MySQL. If the application is on another host, bind to the private network address and firewall the port to that host specifically. 0.0.0.0 means every interface including the public one, which is a different decision from "the app server needs to reach it".
Because they are different kinds of evidence. A bind-address of 10.0.1.2 is very often a deliberate private-network design, and the audit cannot distinguish that from a mistake, so it reports the value and warns. A socket actually listening on a routable interface is a fact about exposure rather than an intention, so check 6 fails on it. MongoDB is the exception among the binding checks: an explicit 0.0.0.0 there fails outright.
It refuses connections from non-loopback addresses when Redis has no password and no explicit bind directive. It is a guard rail added because too many instances were exposed accidentally, and it stops applying the moment you set a password or a bind, which are exactly the changes made when opening Redis up on purpose. Treat the bind address and a firewall rule as the control; protected-mode is a backstop, which is why binding externally with it still enabled warns rather than passing.
3306 for MySQL and MariaDB, 5432 for PostgreSQL, 27017 for MongoDB, 6379 for Redis, 1433 for MSSQL, 9200 for Elasticsearch and 11211 for Memcached. Those are the seven this audit tests. Allow them only from 127.0.0.1 or from the specific private addresses your application servers use.
When it is publicly reachable, yes. Its default paths are among the most probed on the internet and brute-force attempts begin within minutes of discovery. Remove it if you do not need it. If you do, restrict it to localhost and reach it over an SSH tunnel. Renaming the directory to something obscure stops opportunistic scanners and does nothing about anyone who finds the new path.
Because it finds files, not exposure. A phpMyAdmin package installed but never wired into a virtual host is a footprint on disk, and confirming whether it is actually served would require an HTTP request, which no check in this catalog makes. So the audit reports what it found and leaves the reachability test to you.
Set bindIp to 127.0.0.1 in /etc/mongod.conf, enable security.authorization, and block port 27017 at the firewall. That combination is what the mass ransom campaigns needed you to get wrong: they targeted instances that were both bound openly and running without authentication. For a replica set, bind to the private address and use TLS with client certificates rather than opening it up.
No. It reads engine configs and the kernel socket table, not ufw, firewalld or iptables. That is deliberate, because firewall state is a host concern rather than a database one and it has its own audit: the VPS firewall and network checklist, which also covers the Docker rules that bypass the host firewall entirely.
It inventories the installed engines and their versions, reads bind_address and skip_networking from a live MySQL connection, listen_addresses from PostgreSQL, bindIp from mongod.conf and the bind plus protected-mode pair from redis.conf, then reads the kernel socket table for seven database ports and searches for web admin tool footprints. All seven checks are read-only and take about 12 seconds.
It performs local checks on databases you administer, so it does not validate firewall rules from outside the network or replace an external port scan. Pair it with nmap or a service such as Shodan for the external view. It also does not apply to managed databases such as RDS or Cloud SQL, or to Kubernetes network policies, where network access is configured entirely outside the host.