Database security checklist

7-Point Database Hardening Checklist for VPS (2026)

Database installers optimise for getting you running, not for production security. This page documents all 7 checks in the CtrlOps Database Configuration and Hardening audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202614 min read
7
checks in this audit
4
rated high severity
19s
automated run time
5
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the database-security-hardening audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

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.
#CheckSeverityWhat it catchesRoot needed
1Service userHIGHAn engine running as rootNo
2Installer defaultsLOWtest database, open public schemaYes
3Database patchesHIGHPending engine security updatesNo
4Log placementLOWLogs and data on one partitionYes
5Scheduled backupsMEDIUMNo backup job anywhere on the hostYes
6Backup permissionsHIGHWorld-readable dump filesYes
7Backup encryptionHIGHPlaintext dumps with no encryption in sightYes
Where the backup checks look. Checks 6 and 7 search exactly five paths to a depth of three: /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?

High severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery running engine is owned by a non-root accountThe engine and its account are both named in the result.
FAILAny engine process is owned by rootThe engines running as root are listed. An exploit in one becomes instant full-server compromise.
SKIPNo database process is currently runningNothing 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 mysql
Package installs usually get this right; manual and container installs usually do not. If you built the engine from source, followed a tutorial that ran the daemon by hand, or copied a container entrypoint onto a host, root is the likely default. Fix the ownership of the data directory in the same change, because a service that switches user without it will fail to start and you will be tempted to switch back.

Check 2: Are installer defaults still in place?

Low severityNeeds sudo

Installers leave artefacts behind. The audit asks two specific questions rather than surveying everything:

  • MySQL: does the test database still exist? It ships writable by any account and is the thing mysql_secure_installation removes first.
  • PostgreSQL: does PUBLIC still hold CREATE on the public schema? 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 thresholds this check applies
ResultWhenWhat it means
PASSEvery engine the audit could assess is cleanThe clean engines are named, along with any that could not be audited.
WARNThe MySQL test database exists, or PUBLIC still holds CREATE on the public schemaEach finding is named with its fix. This is LOW severity: it is an unnecessary privilege, not an open door.
SKIPNo database engine was detected, or nothing assessable was presentThe 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;"
The PostgreSQL half surprises people. It is not about a leftover database; it is about a default grant that stood for two decades and changed in PostgreSQL 15. On any cluster created before 15, or upgraded in place, every role can still create tables in the 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?

High severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSNo pending updates for any database packageThe engines are current as far as the metadata on the host knows.
FAILOn apt, one or more pending database updates are flagged as securityThe security count is reported. Apply now; database CVEs are weaponised quickly.
WARNPending database updates exist but none is identified as security, or the package manager is dnf, yum, zypper or apkThe count is reported. Schedule the upgrade.
SKIPNo supported package manager was foundNothing 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 --security
Upgrading the package does not always restart the daemon. On several distributions a database package upgrade leaves the old binary running until someone restarts the service, which means mysqld --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?

Low severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSLogs are on a different mount from the data, or binary logging is offThe per-engine finding is reported. Engines showing binlog-off are worth reviewing for point-in-time recovery needs.
WARNLogs share the data mount on any engineThe engine is named. One full partition stops writes and recovery together.
SKIPNo engine could be inspectedThe 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 postgresql

Check 5: Is there a scheduled database backup?

Medium severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSA matching backup job was found in cron or in a systemd timerThe first matching job is quoted back. Restore-test it: an untested backup is a hope, not a backup.
WARNNothing matched anywhereOne 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
Restore-test on a schedule, not after an incident. The failure mode that actually costs people their data is not a missing backup job, it is a job that has been running and producing something unusable for months: a dump that was truncated by a full disk, or one taken without --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?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSDump files were found and none is world-readableThe count is reported. Keep an encrypted offsite copy as well.
FAILOne or more dump files are world-readableThe first three paths are listed. Set them to 600 and encrypt at rest.
SKIPNo dump files were found in the searched pathsNothing 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 077

Check 7: Are backups encrypted at rest?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSAt least one encrypted archive is presentThe counts of both are reported. Verify the key is stored somewhere the host itself cannot reach.
FAILPlaintext dumps exist, no encrypted archives exist, and no encrypting backup tool is installedThe dump count is reported. Nothing on this host is encrypting anything.
WARNPlaintext dumps exist with no encrypted archives, but borg, restic or duplicity is installedConfirm these are staging files that get encrypted before they leave the host.
SKIPRoot was unavailable, or no dumps and no encrypted archives were found at allNothing 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
Store the key somewhere the server cannot read. A backup encrypted with a key sitting next to it in /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.

TaskBy hand, 8 serversCtrlOps, 8 servers
Run all 7 checksAbout 3 hoursAbout 2.5 minutes
Get a scored reportNot availableAutomatic
Export a PDF for a clientNot availableOne click
Fix what failedCopy-paste from your notesAI-generated commands behind an approval gate
Re-audit after fixingAnother 3 hoursAnother 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.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals