VPS & Server security checklist

Application Security Audit: 5 Checks for Linux Servers

An application security audit checks the software stack sitting on top of your OS: SSL certificates, runtime versions, database access controls and host resources. This page documents all 5 checks in the CtrlOps Application Security 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, 202613 min read
5
checks in this audit
2
rated high severity
10s
automated run time
2
need root or sudo

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

An application security audit reviews the TLS certificates, runtime versions and database access controls on your server to find the misconfigurations that expose your application layer. This page documents the 5 checks in the CtrlOps Application Security audit, in the order the audit runs them.

A hardened SSH config and a clean firewall mean nothing if your Let's Encrypt certificate expired two weeks ago or your MongoDB instance accepts connections without credentials. 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

Five checks cover the application-layer gaps that a firewall and an SSH audit cannot see. Each takes three to five minutes to verify by hand, which is manageable for a single server and painful at scale.

  • Two checks are rated HIGH: SSL certificate expiry and database account exposure. If you only have five minutes, check those two.
  • Two need root: reading Let's Encrypt certificates and probing database access. The other three run as any user.
  • The whole audit takes about 10 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 minutes by hand.
  • End-of-life runtimes receive zero security patches. PHP 7.x, Node 16, MySQL 5.6, PostgreSQL 11 and MongoDB 4.x are all past end-of-life and unfixable, not merely old.
#CheckSeverityWhat it catchesRoot needed
1SSL certificate expiryHIGHCertificates expiring within 30 daysYes
2Certbot auto-renewalMEDIUMMissing certbot timer or cron jobNo
3End-of-life runtimesMEDIUMRuntimes that get no security patchesNo
4Database account exposureHIGHAnonymous or wildcard database accessYes
5Host resourcesLOWPlatform, CPU, memory and disk inventoryNo
How applications are detected. The audit probes for nginx, Apache, PHP, Node.js, Docker, MySQL/MariaDB, PostgreSQL, MongoDB and Redis by checking whether their binaries exist on PATH, whether their processes are running, or whether their config files are present. Applications installed in non-standard paths, or running inside containers, are invisible to it.

Check 1: Are any SSL certificates expiring within 30 days?

High severityNeeds sudo

An expired SSL certificate takes your site offline for every browser that enforces HTTPS, which in 2026 is all of them. The 30-day window matters because Let's Encrypt certificates have a 90-day lifespan and are designed to renew at the 60-day mark. If a certificate is inside 30 days of expiry, the renewal process has already failed at least once.

The audit reads every certificate directory under /etc/letsencrypt/live/, prefers cert.pem and falls back to fullchain.pem, then runs openssl x509 -checkend 2592000 (30 days in seconds) against each one. Certificates managed by other providers or stored elsewhere are not covered.

How to check manually

# Every Let's Encrypt certificate on the host, with its expiry date
for d in /etc/letsencrypt/live/*/; do
  c="${d}cert.pem"
  [ -f "$c" ] || c="${d}fullchain.pem"
  [ -f "$c" ] || continue
  echo "$(basename "$d"): $(sudo openssl x509 -enddate -noout -in "$c")"
done
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery readable certificate has more than 30 days leftRenewal is working. The count of certificates is reported.
FAILOne or more certificates expire within 30 daysThe affected domain names are listed. Renew now with certbot renew.
SKIPNo /etc/letsencrypt/live directory, no readable certificates in it, or root access was not availableIf TLS terminates elsewhere (Cloudflare, a load balancer, a managed platform), check that platform instead.

A SKIP is not a pass. The three SKIP reasons are reported separately for exactly this reason: "no letsencrypt directory" means the audit found nothing to measure, while "need root to read certificates" means there may well be certificates it could not open.

How to fix it

# Attempt renewal
sudo certbot renew

# If renewal reports nothing due, force it
sudo certbot renew --force-renewal

# Test the renewal path without touching the live certificate
sudo certbot renew --dry-run
Do not wait for the error page. By the time a visitor sees an expired certificate warning, search engines have already flagged the site, monitoring has fired, and everyone who bookmarked the URL is hitting a full-page browser interstitial. A failed renewal is a P1 incident, not a todo item. The 30-day window exists to give you time to diagnose it, which is why the audit fails rather than warns inside that window.

Check 2: Is certbot renewal automated?

Medium severityNo sudo needed

Certbot installed without a renewal timer or cron job means your certificates renew exactly when you remember to run the command. Let's Encrypt certificates expire every 90 days, and the recommended practice is to attempt renewal twice daily so that a transient failure has dozens of retries before it becomes an outage. Without automation, every certificate on the server is on a 90-day countdown to a downtime.

The audit looks for three things and passes if any one of them is present: an active certbot.timer, an active snap.certbot.renew.timer (Snap installations) or the file /etc/cron.d/certbot.

How to check manually

# systemd timers, packaged and snap
systemctl is-active certbot.timer
systemctl is-active snap.certbot.renew.timer

# cron job
ls /etc/cron.d/certbot
Result thresholds this check applies
ResultWhenWhat it means
PASSA certbot renewal timer or cron job is in placeCertificates will attempt to renew on their own.
WARNCertbot is installed but none of the three renewal mechanisms is presentCertificates will lapse unless you renew by hand. Enable the timer and test with certbot renew --dry-run.
SKIPCertbot is not installed at allIf you use a different ACME client (acme.sh, lego, Caddy) verify its own renewal path separately.

This warns rather than fails because a missing timer is not itself an exposure. It is a guarantee of a future one. Check 1 is the check that fails when that future arrives.

How to fix it

# Enable the systemd timer (the packaged path on Debian, Ubuntu and RHEL family)
sudo systemctl enable --now certbot.timer

# Or, on a host without systemd timers
echo "0 */12 * * * root certbot renew --quiet" | sudo tee /etc/cron.d/certbot
Test after enabling. Run sudo certbot renew --dry-run to confirm the renewal will succeed when the timer fires. A timer that runs and fails silently is worse than no timer at all, because it converts an open task into a false sense of safety. The audit can only see that a mechanism exists, not that it works.

Check 3: Are any installed runtimes past end-of-life?

Medium severityNo sudo needed

An end-of-life runtime receives no security patches from its maintainer. A vulnerability discovered tomorrow in PHP 7.4 will never be fixed, and the runtime stays vulnerable for as long as it runs on your server. This is not theoretical: known CVEs against EOL runtimes are the first thing an automated scanner tries after finding an open port.

The audit reads the installed version of nginx, Apache, PHP, Node.js, Docker, MySQL/MariaDB, PostgreSQL, MongoDB and Redis, reports the full stack, and fails on the specific version ranges that are past upstream end-of-life.

How to check manually

nginx -v 2>&1
php -v 2>/dev/null | head -1
node -v 2>/dev/null
docker --version 2>/dev/null
mysqld --version 2>/dev/null
psql --version 2>/dev/null
mongod --version 2>/dev/null | head -1
redis-server --version 2>/dev/null

The audit flags these ranges as end-of-life:

RuntimeVersions flagged as EOL
PHP5.x, 7.x, 8.0
Node.jsv8, v10, v12, v14, v16
MySQL / MariaDB5.5, 5.6
PostgreSQL9.x, 10.x, 11.x
MongoDB3.x, 4.x
Redis2.x through 5.x
Result thresholds this check applies
ResultWhenWhat it means
PASSAt least one runtime was detected and none is past end-of-lifeThe full installed stack is reported. Still check each version against current CVE advisories.
FAILOne or more detected runtimes fall inside the EOL ranges aboveThe EOL runtimes and the full stack are both listed. Upgrade to a supported release.
SKIPNo common application stack was detected on the hostThe server may run applications the audit does not probe for, or run everything in containers.

There is no WARN here on purpose. Reporting versions alone could only ever pass, which makes for a useless check. Being past end-of-life is a binary fact with a published date behind it, so it is treated as a finding in its own right rather than as something to go and look up.

How to fix it

# Example: PHP 7.4 to 8.2 on Ubuntu
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.2
sudo a2dismod php7.4 && sudo a2enmod php8.2
sudo systemctl restart apache2

# Example: Node.js via nvm
nvm install 20
nvm alias default 20
An EOL runtime is not "old but fine." It is a known-vulnerable component that will never be patched, and every day it runs in production is a day you are relying on nobody having found the next CVE. Stage the upgrade properly, though: runtimes are usually EOL because the application on top of them was not updated either, and a PHP 7.4 to 8.2 jump typically needs code changes for deprecated functions, removed extensions and changed defaults. Test suite first, maintenance window second.

Check 4: Can anyone connect to your database without credentials?

High severityNeeds sudo

A database reachable without credentials is the shortest path there is from network access to data breach. The audit detects which engines are present, then asks each of them one question: who can connect without credentials, and from where. Reachable without credentials is a FAIL. Credentialed but scoped to any host is a WARN.

Each engine has its own authentication model, and the audit adapts to each:

  • MySQL/MariaDB: attempts a root login without a password from a non-root shell, then counts accounts with an empty username (anonymous users) and accounts whose host is % (any host).
  • PostgreSQL: resolves the live hba_file path from the running server, strips comments, then counts lines ending in trust (no password at all) and host lines whose address column is 0.0.0.0/0, ::/0 or all.
  • MongoDB: reads mongod.conf for authorization: enabled. Without it, every reachable client has full admin access to every database.
  • Redis: reads redis.conf for requirepass or an ACL (user or aclfile), and separately reads the bind line. No credential plus a bind beyond loopback is the failure; no credential on loopback only is the warning.

The group reports the worst status across the engines actually present, and lists the clean ones alongside it.

How to check manually

# MySQL: passwordless root from a non-root shell, then anonymous and wildcard accounts
mysql -u root -e 'SELECT 1' 2>/dev/null
sudo mysql -N -e "SELECT COUNT(*) FROM mysql.user WHERE user=''"
sudo mysql -N -e "SELECT COUNT(*) FROM mysql.user WHERE host='%'"

# PostgreSQL: find the live pg_hba.conf, then read its rules
sudo -u postgres psql -X -tAc 'SHOW hba_file'
sudo grep -vE '^[[:space:]]*(#|$)' /etc/postgresql/*/main/pg_hba.conf

# MongoDB: authorization must be explicitly enabled
sudo grep 'authorization' /etc/mongod.conf

# Redis: a credential of some kind, and how far it is bound
sudo grep -E '^(requirepass|user |aclfile |bind )' /etc/redis/redis.conf
Result thresholds this check applies
ResultWhenWhat it means
PASSEvery detected engine has credentials and no wildcard host scopeThe clean engines are listed by name, along with any that could not be audited.
WARNOver-broad host scope: MySQL accounts on %, pg_hba entries on 0.0.0.0/0, ::/0 or all, or Redis with no password but bound to loopback onlyCredentials exist but the blast radius is wider than it needs to be. Restrict to specific addresses, and give Redis a requirepass anyway.
FAILReachable with no credentials at all: passwordless MySQL root, anonymous MySQL users, pg_hba trust entries, MongoDB without authorization, or Redis with no password while bound beyond loopbackAnyone who can reach the port has full access. Fix this before anything else on the page.
SKIPAn engine was detected but could not be audited (server down, root needed, config file not found), or no engine is installedEach engine and the reason it was skipped are listed. A host with no database is a SKIP, not a PASS, because nothing was measured.

The Redis distinction is worth reading twice. Loopback-only Redis with no password is a warning rather than a failure because an external attacker cannot reach it directly. It is still a warning rather than a pass because any local process, any compromised application on the same host, and any server-side request forgery in your web app all reach loopback without touching the network.

How to fix it

# MySQL: drops anonymous users and sets a root password
sudo mysql_secure_installation

# PostgreSQL: replace trust with scram-sha-256 in pg_hba.conf
# Change: local   all   all             trust
# To:     local   all   all             scram-sha-256
sudo systemctl reload postgresql

# MongoDB: enable authorization in /etc/mongod.conf
# security:
#   authorization: enabled
sudo systemctl restart mongod

# Redis: set a credential in redis.conf
# requirepass a_long_random_string_here
sudo systemctl restart redis
Do this before the port audit, not after. The firewall and network audit may report your database port as open, and closing it is the right move. It is not sufficient on its own: a compromised web application running on the same host reaches 127.0.0.1 without ever crossing the firewall. Authentication is the control that survives that, which is why this check probes credentials rather than reachability.

Check 5: What are the host resources?

Low severityNo sudo needed

This is an inventory check, not a security finding. It reports the virtualization platform (KVM, Xen, VMware, bare metal), the CPU count, total memory and the size of the root disk. The values are not scored against a threshold. They are in the report so you can confirm the host matches the plan you are paying for and has the resources the applications on it expect.

How to check manually

systemd-detect-virt
nproc
free -m | awk '/^Mem:/{print $2}'
df -h / | awk 'NR==2{print $2}'
Result thresholds this check applies
ResultWhenWhat it means
PASSAt least one of CPU, memory or disk was readableInformational. The platform and the three figures are reported for your review.
SKIPNone of nproc, free or df returned anythingThe tools needed to read host resources are unavailable. Reading nothing is a failed measurement, not a healthy host, so it is deliberately not a PASS.

Why it is in the audit

A server with 512MB of RAM running MySQL, Redis, Node.js and nginx is a server that swaps under load. A server that swaps responds slowly enough for a health check to time out, which cascades into a restart loop that looks exactly like an application bug. Knowing the resource envelope before you go chasing a phantom memory leak saves hours, and it is free to collect while the audit is already connected.


What this audit does not cover

Being explicit about scope is part of the point of publishing thresholds. This audit reads local configuration and probes database access from the host itself.

  • Non-Let's Encrypt certificates. Check 1 reads /etc/letsencrypt/live/ only. Certificates from other CAs, in custom paths, or managed by Cloudflare, AWS ACM or another provider are not detected.
  • Application-level vulnerabilities. There is no DAST scan here, and no check for SQL injection, XSS or the rest of the OWASP Top 10. This audits the infrastructure the application runs on, not the application code.
  • Container-internal runtimes. Check 3 detects runtimes on the host PATH. A PHP 7.4 inside a Docker container is invisible to it. The Docker audits cover image and container concerns separately.
  • Database content and query patterns. Check 4 probes whether authentication exists, not whether the password is strong, whether privileges are least-privilege, or whether queries are parameterised.
  • TLS protocol and cipher configuration. Whether nginx still offers TLS 1.0, or a weak cipher suite, belongs to the web server TLS audit. Check 1 is about the certificate's expiry date, not the handshake.

The manual audit problem at scale

Running these 5 checks on one server takes about 15 minutes, including reading each output and deciding whether it passed, warned or failed. For one server that is a manageable task. For 10 servers across staging and production it is two and a half hours of SSH sessions and hand-written notes.

The certificate checks are the worst offenders at scale. Certbot manages certificates per domain, so a server hosting 8 domains has 8 certificates to inspect individually. Each one means reading an expiry date, comparing it to today, and deciding whether 23 days is close enough to act on. Multiply that by 10 servers and you are comparing 80 dates by hand, on a task where being wrong once is an outage.

The database check is the second worst. Each engine has a different authentication model, a different config file format, and a different definition of "anonymous access." Checking MySQL, PostgreSQL and Redis on a single server means reading three configs and running three unrelated probe commands. On a host running all three, that one check takes five minutes by itself.

And, as with every audit, the real cost is not the first pass. It is the drift. A trust entry added to pg_hba.conf during a migration in March is still there in September unless something re-checks it. That is the same failure mode as managing multiple servers without a central view.


How CtrlOps runs all 5 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Application Security 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 Application Security

Choose the Application Security audit from the catalog. All 5 checks are listed with their descriptions, severity levels and the estimated run time (about 10 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.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the hardening score move. That delta is the thing a spreadsheet never gave you: evidence that the fix landed.

For fleet-wide work, the Audit Reports view on the Home screen runs the same audit across every connected server at once. Each server gets its own report, and the scores line up side by side so you can see which box to open first.

TaskBy hand, 8 serversCtrlOps, 8 servers
Run all 5 checksAbout 2 hoursAbout 90 seconds
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 2 hoursAnother 90 seconds

Conclusion

Securing the application layer of a Linux server does not have to be a manual chore. Checking SSL expiry dates, certbot timers, runtime versions and database credentials by hand across a fleet burns hours of SSH sessions and produces nothing you can hand to a client afterwards.

The CtrlOps Security Audit runs all 5 of these checks in about 10 seconds per server, agentlessly, with a hardening score, a client-ready PDF and AI-assisted commands behind an approval gate. Use it to catch the certificate that stopped renewing and the database that never had a password, before either of them becomes an incident.


Frequently asked questions

An application security audit on a Linux server checks the software stack sitting on top of the OS: SSL/TLS certificate validity and renewal automation, whether installed runtimes such as PHP, Node.js, MySQL, PostgreSQL, MongoDB and Redis are past end-of-life, whether any database accepts connections without credentials, and what resources the host actually has. It covers the application infrastructure, not the application code itself.

Run sudo openssl x509 -checkend 2592000 -noout -in /etc/letsencrypt/live/yourdomain/cert.pem. A non-zero exit code means the certificate expires within 30 days. For the exact date rather than a pass or fail, run sudo openssl x509 -enddate -noout -in /etc/letsencrypt/live/yourdomain/cert.pem. On a host with several domains, loop over /etc/letsencrypt/live/*/ so you do not miss one.

PHP 7.4 reached end-of-life on 28 November 2022. Since that date it receives no security patches from the PHP team, so any vulnerability found in it after that date stays unfixed permanently. The audit flags PHP 5.x, 7.x and 8.0. Upgrade to PHP 8.2 or 8.3 for active security support, and expect to make code changes for deprecated functions and removed extensions along the way.

Run sudo grep 'authorization' /etc/mongod.conf and look for security.authorization: enabled. If the line is missing or set to disabled, every client that can reach the MongoDB port has full admin access to every database on it. This is the single most common MongoDB exposure, and it is the mechanism behind the hundreds of thousands of MongoDB instances that have been found open and ransomed.

It depends on the bind address. Redis with no requirepass and no ACL, bound beyond loopback, is a FAIL: anyone who can route to the port owns the data. The same Redis bound to 127.0.0.1 only is a WARN, because an external attacker cannot reach it directly. It is still a warning rather than a pass, because any local process, any compromised app on the same host, and any SSRF bug in your web application all reach loopback without crossing the firewall.

No. CtrlOps audits the infrastructure layer: certificates, runtime versions, database access controls and host resources. It does not run DAST scans, review code, or check for OWASP Top 10 issues in your application. For application-layer testing, pair this audit with a DAST tool such as OWASP ZAP, or a SAST tool wired into your CI pipeline.

If the renewal timer fires but the renewal itself fails, because of a DNS change, port 80 being blocked, or a plugin misconfiguration, the certificate keeps counting down with no alert. You find out when a visitor hits the browser warning or a monitor catches the expired certificate. Run sudo certbot renew --dry-run monthly to verify the renewal path still works, and read /var/log/letsencrypt/letsencrypt.log after any failure. Check 1 exists precisely because check 2 passing does not prove renewal works.

Three of the four probes need privileged reads. MySQL socket authentication needs root to connect without a password, pg_hba.conf is owned by the postgres user and unreadable by others, and /etc/mongod.conf and /etc/redis/redis.conf are typically mode 640. Without root the audit reports SKIP for those engines and names the reason, rather than reporting a pass it did not earn.

Yes. Every check works on any Linux host reachable over SSH: EC2, DigitalOcean Droplets, Linode, Vultr, Hetzner, bare metal or a VPS from any other provider. The audit script detects the distribution family (Debian, Ubuntu, RHEL, Rocky, Alma, Amazon Linux, Fedora, Oracle, SUSE, Alpine) and adapts its commands accordingly.

If your applications run entirely on a managed PaaS such as Heroku, Railway or Render, or on a serverless platform such as Lambda or Cloud Functions, you do not control the runtime versions, the certificates or the database configuration, and this audit has nothing to read. It is for servers where you install and configure the application stack yourself.

Lynis installs on the host and runs a broad system audit spanning hundreds of checks across every layer. This audit runs agentlessly over SSH, covers 5 application-layer checks with published thresholds, and feeds the failures into a guided fix step with an approval gate. Lynis gives you breadth across the whole system; this gives you depth on the application stack plus remediation. They complement each other rather than compete.

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