Database hardening is the set of configuration changes between "the database is installed" and "the database is production-ready": removing installer leftovers, running the engine unprivileged, keeping it patched, and making sure the backups both exist and are protected. This page documents the 7 checks in the CtrlOps Database Configuration and Hardening audit, in the order the audit runs them.
It is a different layer from authentication (who can connect) and network isolation (who can reach the port). Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.
Key takeaways
Seven checks, and four of them are about backups. That weighting is deliberate: a hardened database you cannot restore is still a total loss.
- Four are rated HIGH: the service user, pending patches, backup permissions and backup encryption. Two are LOW, and both of those are about resilience rather than exposure.
- Five need root. The service-user check reads the process table and the patch check reads package metadata, both of which work unprivileged. Everything touching backup paths and database internals needs sudo.
- The whole audit takes about 19 seconds when CtrlOps runs it over your existing SSH connection, against roughly 20 to 30 minutes by hand.
- This is the longest database audit for a reason. It reads the process table, queries two engines, compares mount points, greps every cron source and walks five backup directories.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | Service user | HIGH | An engine running as root | No |
| 2 | Installer defaults | LOW | test database, open public schema | Yes |
| 3 | Database patches | HIGH | Pending engine security updates | No |
| 4 | Log placement | LOW | Logs and data on one partition | Yes |
| 5 | Scheduled backups | MEDIUM | No backup job anywhere on the host | Yes |
| 6 | Backup permissions | HIGH | World-readable dump files | Yes |
| 7 | Backup encryption | HIGH | Plaintext dumps with no encryption in sight | Yes |
/var/backups, /backup, /opt/backup, /srv/backup and /root/backups, for files ending .sql, .sql.gz, .dump and .bak. If your dumps go somewhere else, or straight to object storage without landing on disk, these checks will report SKIP rather than PASS. A skip here means the audit found nothing to judge, which is a different statement from "your backups are fine".Check 1: Do the database engines run as root?
Every database engine ships with a dedicated unprivileged account: mysql, postgres, mongodb, redis. If the process runs as root instead, a vulnerability in the engine stops being a database problem and becomes a full server compromise. The service account is the containment boundary, and without it there is no containment.
The audit finds the running process for each of mysqld, mariadbd, postgres, mongod and redis-server, then reads the owner from the process table. No privileged access is needed, which is why this HIGH check needs no sudo.
How to check manually
ps -eo user,comm | grep -E 'mysqld|mariadbd|postgres|mongod|redis-server'| Result | When | What it means |
|---|---|---|
| PASS | Every running engine is owned by a non-root account | The engine and its account are both named in the result. |
| FAIL | Any engine process is owned by root | The engines running as root are listed. An exploit in one becomes instant full-server compromise. |
| SKIP | No database process is currently running | Nothing to inspect. Note that an installed but stopped engine reports SKIP here, not PASS. |
The SKIP is worth reading carefully. This check inspects running processes, not unit files, so a database that is installed and stopped produces a skip rather than a pass. If you expected a result and got a skip, the first question is whether the service is actually up.
How to fix it
# Set the account on the unit
sudo systemctl edit mysql
# [Service]
# User=mysql
# Group=mysql
# Give the new account its data
sudo systemctl stop mysql
sudo chown -R mysql:mysql /var/lib/mysql
sudo systemctl daemon-reload
sudo systemctl start mysqlCheck 2: Are installer defaults still in place?
Installers leave artefacts behind. The audit asks two specific questions rather than surveying everything:
- MySQL: does the
testdatabase still exist? It ships writable by any account and is the thingmysql_secure_installationremoves first. - PostgreSQL: does
PUBLICstill holdCREATEon thepublicschema? That was the default before PostgreSQL 15, and it means any role can create objects in a schema every role can see.
MongoDB is always reported as not audited here, because checking its default test database needs an interactive mongosh session that a read-only audit will not open.
How to check manually
# MySQL
sudo mysql -N -e "SHOW DATABASES LIKE 'test'"
# PostgreSQL
sudo -u postgres psql -X -tAc "SELECT has_schema_privilege('public','public','CREATE')"| Result | When | What it means |
|---|---|---|
| PASS | Every engine the audit could assess is clean | The clean engines are named, along with any that could not be audited. |
| WARN | The MySQL test database exists, or PUBLIC still holds CREATE on the public schema | Each finding is named with its fix. This is LOW severity: it is an unnecessary privilege, not an open door. |
| SKIP | No database engine was detected, or nothing assessable was present | The engines that are present but unassessable are listed, MongoDB among them. |
How to fix it
# MySQL: the whole-job fix
sudo mysql_secure_installation
# Or by hand
sudo mysql -e "DROP DATABASE IF EXISTS test; DELETE FROM mysql.db WHERE Db LIKE 'test%'; FLUSH PRIVILEGES;"
# PostgreSQL: close the public schema
sudo -u postgres psql -c "REVOKE CREATE ON SCHEMA public FROM PUBLIC;"public schema. Revoking it is a one-line change, and the only thing it usually breaks is an application that was relying on creating its own tables as a non-owner, which is worth knowing about anyway.Check 3: Are there pending database security patches?
An unpatched database engine with a published CVE is the most direct attack path there is: the vulnerability is documented, the exploit is usually public, and the version number is often visible from outside. This check filters your package manager's pending update list down to the database engines.
How to check manually
# Refresh first, or the answer is meaningless
sudo apt update
# Debian/Ubuntu
apt-get -s upgrade 2>/dev/null | grep '^Inst ' | grep -iE 'mysql|mariadb|postgres|mongo|redis'
# RHEL/Rocky/Alma
dnf check-update 2>/dev/null | grep -iE 'mysql|mariadb|postgres|mongo|redis'| Result | When | What it means |
|---|---|---|
| PASS | No pending updates for any database package | The engines are current as far as the metadata on the host knows. |
| FAIL | On apt, one or more pending database updates are flagged as security | The security count is reported. Apply now; database CVEs are weaponised quickly. |
| WARN | Pending database updates exist but none is identified as security, or the package manager is dnf, yum, zypper or apk | The count is reported. Schedule the upgrade. |
| SKIP | No supported package manager was found | Nothing was measured. |
FAIL only happens on apt. Separating security updates from ordinary ones is only done on the Debian family here, so on RHEL, SUSE and Alpine every pending database update reports as a WARN regardless of what it fixes. A warning on a Rocky host is not evidence that nothing security-related is waiting; run dnf updateinfo list security yourself.
The other thing to know: this check does not refresh package metadata. Every check in the catalog is strictly read-only and apt update writes to disk, so a host that has not refreshed in three weeks can report a clean pass while patches sit waiting upstream.
How to fix it
sudo apt update
sudo apt install --only-upgrade mysql-server postgresql redis-server
# RHEL family
sudo dnf upgrade --securitymysqld --version reports the new version while the process serving queries is still the old one. Check the running instance, not just the installed package, and restart in a window rather than discovering it during an incident.Check 4: Do transaction logs share a disk with the data?
MySQL binary logs and the PostgreSQL write-ahead log are the recovery mechanism. If they live on the same partition as the data files and that partition fills, you lose the ability to write data and the ability to recover from that at the same moment.
The audit compares mount points rather than paths. For MySQL it only looks when log_bin is actually on, then compares the mount of the data directory against the mount of the binary log directory using df. For PostgreSQL it tests whether pg_wal inside the data directory is a symlink, which is how a separate WAL disk is conventionally arranged.
How to check manually
# MySQL: is binary logging even on, and where does it write?
sudo mysql -N -e "SELECT @@log_bin, @@datadir, @@log_bin_basename"
df -P /var/lib/mysql /var/log/mysql | awk 'NR>1{print $6, $1}'
# PostgreSQL: is pg_wal a symlink to another disk?
PDD=$(sudo -u postgres psql -X -tAc 'SHOW data_directory')
sudo test -L "$PDD/pg_wal" && echo "separate" || echo "same disk"| Result | When | What it means |
|---|---|---|
| PASS | Logs are on a different mount from the data, or binary logging is off | The per-engine finding is reported. Engines showing binlog-off are worth reviewing for point-in-time recovery needs. |
| WARN | Logs share the data mount on any engine | The engine is named. One full partition stops writes and recovery together. |
| SKIP | No engine could be inspected | The server is down, or root was unavailable for the privileged query. |
This is LOW severity because on a single-disk VPS you often cannot fix it, and the check knows that. Treat a warning here as a documented limitation rather than a task: it tells you that a full disk is a two-failure event on this host, which is useful when you are deciding how much headroom to leave and how loudly to alert on disk usage.
How to fix it
# MySQL: point the binary log at another mount
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
log_bin = /var/log/mysql/mysql-bin
# PostgreSQL: symlink pg_wal onto a separate disk (needs downtime)
sudo systemctl stop postgresql
sudo mv "$PDD/pg_wal" /mnt/waldisk/pg_wal
sudo ln -s /mnt/waldisk/pg_wal "$PDD/pg_wal"
sudo chown -h postgres:postgres "$PDD/pg_wal"
sudo systemctl start postgresqlCheck 5: Is there a scheduled database backup?
A database with no scheduled backup is a database you have decided you can lose. This check looks for evidence that something dumps it on a schedule.
The search is broader than most people expect. It matches mysqldump, mariadb-dump, pg_dump, pg_basebackup, mongodump, xtrabackup, mariabackup, and also borg and restic, across /etc/crontab, the four /etc/cron.* directories, /etc/cron.d, user crontabs under /var/spool/cron, and finally systemd timers whose name mentions backup or dump.
How to check manually
sudo grep -rhiE 'mysqldump|mariadb-dump|pg_dump|pg_basebackup|mongodump|xtrabackup|mariabackup|borg|restic' \
/etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly /var/spool/cron 2>/dev/null
systemctl list-timers --all --no-legend | grep -iE 'backup|dump'| Result | When | What it means |
|---|---|---|
| PASS | A matching backup job was found in cron or in a systemd timer | The first matching job is quoted back. Restore-test it: an untested backup is a hope, not a backup. |
| WARN | Nothing matched anywhere | One bad migration or one ransomware event means total data loss. |
Two limits worth stating. The check confirms a job exists, never that it runs, completes, or produces a valid dump. And it only sees scheduling that lives on this host, so a backup driven by an external orchestrator, a cloud snapshot policy or a managed service will warn here despite being perfectly real.
How to fix it
# /etc/cron.d/db-backup, encrypted at creation
0 2 * * * root umask 077; mysqldump --all-databases --single-transaction \
| gzip | gpg --encrypt --recipient backups@your-domain \
> /var/backups/mysql/$(date +\%Y\%m\%d).sql.gz.gpg--single-transaction against a live InnoDB database. Restore to a scratch server quarterly and run a real query against it.Check 6: Are backup files world-readable?
A dump file is the entire database in one file: every table, every row, every credential your application stored. If it is world-readable, any account on the server can copy the lot without ever connecting to the database or knowing a password.
The audit walks the five backup paths for the four dump extensions and tests the world-readable bit.
How to check manually
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \) \
-perm -004 2>/dev/null| Result | When | What it means |
|---|---|---|
| PASS | Dump files were found and none is world-readable | The count is reported. Keep an encrypted offsite copy as well. |
| FAIL | One or more dump files are world-readable | The first three paths are listed. Set them to 600 and encrypt at rest. |
| SKIP | No dump files were found in the searched paths | Nothing to judge. Without root, /root/backups is not scanned and the result says so. |
The test is world-readable specifically, so mode 640 passes here. That is looser than it looks: the group on a backup file is usually root, so 640 and 600 are effectively the same. Set 600 anyway, because the group can change without anyone noticing.
How to fix it
sudo chmod 600 /var/backups/mysql/*.sql.gz*
# Fix the cause, not the symptom
# At the top of the backup script:
umask 077Check 7: Are backups encrypted at rest?
Permissions protect a dump from other accounts on this server. Encryption protects it from everywhere else it ends up: the offsite copy, the object storage bucket, the laptop someone pulled it onto to debug something.
This check has the most nuanced logic on the page. It counts plaintext dumps, counts encrypted archives (.gpg, .enc, .age, .aes), and separately checks whether an encrypting backup tool (borg, restic or duplicity) is installed at all.
How to check manually
# Plaintext dumps
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.dump' -o -name '*.bak' \) 2>/dev/null | wc -l
# Encrypted archives
sudo find /var/backups /backup /opt/backup /srv/backup /root/backups -maxdepth 3 -type f \
\( -name '*.gpg' -o -name '*.enc' -o -name '*.age' -o -name '*.aes' \) 2>/dev/null | wc -l
# An encrypting tool at all
command -v borg restic duplicity| Result | When | What it means |
|---|---|---|
| PASS | At least one encrypted archive is present | The counts of both are reported. Verify the key is stored somewhere the host itself cannot reach. |
| FAIL | Plaintext dumps exist, no encrypted archives exist, and no encrypting backup tool is installed | The dump count is reported. Nothing on this host is encrypting anything. |
| WARN | Plaintext dumps exist with no encrypted archives, but borg, restic or duplicity is installed | Confirm these are staging files that get encrypted before they leave the host. |
| SKIP | Root was unavailable, or no dumps and no encrypted archives were found at all | Nothing was assessed. |
The WARN branch is the interesting one and it exists to avoid a common false alarm. A perfectly good restic setup often writes a plaintext dump to a staging directory, encrypts it into the repository, and deletes it, so an audit that happens to land mid-cycle sees plaintext. Rather than failing that host, the check notices the tool is installed and asks you to confirm. If you have no such tool and plaintext dumps on disk, that ambiguity does not exist and it fails.
How to fix it
# Encrypt during creation so plaintext never lands
mysqldump --all-databases --single-transaction \
| gzip | gpg --encrypt --recipient backups@your-domain \
> /var/backups/mysql/$(date +%Y%m%d).sql.gz.gpg
# Or with age
... | age -r age1yourpublickeyhere > backup.sql.gz.age/root is protected against a stolen disk and against nothing else: anyone who compromises the host to steal the backup also has the key. Keep the passphrase in a password manager, or use a public-key tool such as gpg or age so the server only ever holds the encrypting half and never the decrypting one.What this audit does not cover
Being explicit about scope is part of the point of publishing thresholds.
- Whether the database asks for a credential. That is the authentication audit.
- Whether the port is reachable. That is the network isolation audit.
- Whether backups actually restore. Checks 5, 6 and 7 confirm a job exists, that dumps are not world-readable and that something is encrypting. None of them opens a dump or tests a restore.
- Backup paths outside the five searched. Dumps written to object storage, to a mounted share, or to a custom directory produce a SKIP on checks 6 and 7 rather than a pass.
- Retention and offsite copies. A single encrypted dump from last March passes checks 6 and 7 exactly as well as a healthy rotation does.
- Managed database services. RDS, Cloud SQL and Azure Database expose none of these processes, sockets or files to a host session.
The manual audit problem at scale
Running these 7 checks by hand takes 20 to 30 minutes per server, and it is the most tedious database audit in the set because every check reads a different kind of thing: a process table, two live SQL queries, package metadata, mount points, four cron locations, and a walk over five backup directories.
The backup half is where manual auditing fails in practice. Confirming a cron entry exists is fast. Confirming that the entry has been producing non-empty, non-truncated, encrypted output for the last thirty days is not something anyone does from a terminal, so it does not get done, and the gap only surfaces when someone needs a restore.
Patch state has the opposite problem: it is easy to check and it goes stale immediately. A clean result from last Tuesday says nothing about today, which 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 Configuration and Hardening 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 Configuration & Hardening
Choose it from the Database category. All 7 checks are listed with their descriptions, severity levels and the estimated run time (about 19 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: several of these fixes stop a database service.
Step 6: Re-run and compare
Run the same audit again after applying the fixes and watch the hardening score move. Patch state in particular is worth re-running weekly, because it is the one finding on this page that reappears on its own.
| Task | By hand, 8 servers | CtrlOps, 8 servers |
|---|---|---|
| Run all 7 checks | About 3 hours | About 2.5 minutes |
| 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 |
| Re-audit after fixing | Another 3 hours | Another 2.5 minutes |
Conclusion
Hardening is the layer nobody revisits. The service account was correct on install day, the test database was going to be dropped later, the backup job was written once and never restore-tested, and the dumps have been sitting in /var/backups unencrypted since the day they started.
Four of these seven are HIGH severity, and three of those four are about backups, because a database you cannot restore safely is a database you have already lost. The CtrlOps Security Audit runs the set in about 19 seconds per server, agentlessly.
The neighbouring checklists cover the other database layers: authentication, credential storage, least-privilege permissions, network isolation and transport encryption.
Frequently asked questions
No. Running as root means any vulnerability in the engine, a buffer overflow or a privilege escalation bug, gives full system access rather than access to the database files. The packaged service accounts (mysql, postgres, mongodb, redis) exist to contain that. Package installations set this correctly; manual builds and copied container entrypoints are where it usually goes wrong.
Because PUBLIC holding CREATE on the public schema was the default for two decades and only changed in PostgreSQL 15. On any cluster created before that, or upgraded in place, every role can create objects in a schema every role can read. REVOKE CREATE ON SCHEMA public FROM PUBLIC closes it. It is LOW severity because it is an unnecessary privilege rather than an open door.
Only on Debian and Ubuntu. Separating security updates from ordinary ones is done on apt only, so on RHEL family, SUSE and Alpine every pending database update reports as a warning regardless of what it fixes. A warning on a Rocky host is not proof that nothing security-related is waiting; run dnf updateinfo list security to find out.
Because it reads the package metadata already on the host and never refreshes it. Every check in this catalog is strictly read-only and apt update writes to disk. Run sudo apt update or sudo dnf makecache yourself, then re-run the audit, before trusting a pass on this check.
Set them to 600, encrypt during creation rather than afterwards, and store the key somewhere the server cannot read. Add umask 077 to the top of the backup script so new dumps are created restricted without a separate chmod. Then restore-test quarterly, because the common failure is not a missing backup but a backup that has been silently producing unusable output.
Because a working restic or borg setup often writes a plaintext dump to a staging directory, encrypts it into the repository and deletes it. An audit that lands mid-cycle sees plaintext on a host that is doing everything right. So when plaintext dumps exist with no encrypted archives but an encrypting tool is installed, the check warns and asks you to confirm. With no such tool installed, that ambiguity is gone and it fails.
That no dump files were found in the five paths the audit searches: /var/backups, /backup, /opt/backup, /srv/backup and /root/backups, to a depth of three. If your backups go to object storage, a mounted share, or a custom directory, you will get a skip. That means the audit found nothing to judge, which is deliberately not the same statement as a pass.
Because they are the recovery mechanism. If both share a partition and it fills, you lose the ability to write data and the ability to recover from that at the same moment, and a disk failure destroys both. On a single-disk VPS you often cannot fix it, which is why this is LOW severity: treat the warning as a documented limitation that should inform how much disk headroom you leave.
Weekly at minimum, daily for anything internet-facing, and immediately for a published CVE in an engine you run. Enable unattended security updates so patching does not depend on someone remembering. Then check that the daemon actually restarted onto the new version, because on several distributions a package upgrade leaves the old binary serving queries until the service is restarted.
Because it reads running processes, not unit files. A database that is installed but stopped has no process to inspect, so the honest answer is a skip rather than a pass. If you expected a real result and got a skip, check whether the service is actually running before anything else.
It reads processes, sockets, package metadata and filesystem paths on databases you administer. It does not apply to managed services such as Amazon RDS, Google Cloud SQL or Azure Database, where the engine process, the config and the backup mechanism are all the provider's and none of them are visible from a host session. For those, use the hardening assessment built into the provider console.