You renewed that certificate. Probably. The staging host uses a different one, the API subdomain has its own, and one of them lapses at 2am on a Saturday. This page documents the 6 checks in the CtrlOps TLS Configuration audit, in the order the audit runs them.
Every threshold below is transcribed from the audit script itself. Three of these checks read your effective configuration across every directive rather than the first one, which matters for TLS specifically: an http-level default is inherited by every server block that does not override it, so the weak setting is rarely in the file you were looking at.
Key takeaways
Six checks. Three of them are the ones that take a site down or leave it readable, and one of those is a clock.
- Three are rated HIGH: HTTPS enabled, protocol versions and certificate expiry. Two are MEDIUM, one is LOW.
- Only certificate expiry needs root, because private key directories are not world-readable. The other five read config text and probe loopback.
- Certificate expiry has two failure branches, already expired and expiring within 30 days, and both are failures. Thirty days is Certbot's own renewal trigger, so a certificate inside that window means renewal should already have happened and did not.
- Every protocol and cipher directive is read, not just the first.
NDIRin the pass message is the count of directives checked, which is how you tell whether the audit saw the config you think it did. - The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 10 minutes of grep and openssl per host by hand.
| # | Check | Severity | What it catches | Root needed |
|---|---|---|---|---|
| 1 | HTTPS enabled | HIGH | No TLS anywhere on the host | No |
| 2 | TLS protocols | HIGH | TLS 1.0 or 1.1 still negotiable | No |
| 3 | TLS ciphers | MEDIUM | RC4, 3DES, EXPORT, NULL families | No |
| 4 | Certificate expiry | HIGH | Expired or expiring within 30 days | Yes |
| 5 | HTTPS redirect | MEDIUM | Plain HTTP serving content | No |
| 6 | OCSP stapling | LOW | Clients querying the CA themselves | No |
Check 1: Is HTTPS configured at all?
Without TLS, every password, session cookie and form submission crosses the network as readable text, and any device between the visitor and your server can read or rewrite it. Browsers mark the site as not secure, and a form posted over HTTP is a compliance finding in every regime that has an opinion.
The audit counts listen ... ssl and ssl_certificate lines in the Nginx config and SSLEngine on or SSLCertificateFile in Apache, and separately checks whether the loopback probe reached an https:// URL. Any one of the three is enough to pass.
How to check manually
grep -rnE 'ssl_certificate|listen.*ssl' /etc/nginx/ | grep -v '#'
grep -rniE 'SSLEngine|SSLCertificateFile' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1/| Result | When | What it means |
|---|---|---|
| PASS | TLS appears in either config, or the probe reached an https URL | TLS is configured. The check deliberately does not grade how well, which is what checks 2, 3 and 4 are for. |
| FAIL | No TLS in either config and no HTTPS response | Credentials and session cookies cross the network in clear text. Get a certificate with certbot. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
How to fix it
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Apache
sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d yourdomain.comCertbot writes the certificate paths into the config and sets up the renewal timer in the same run. Confirm both afterwards:
curl -I https://yourdomain.com
systemctl list-timers | grep certbotCheck 2: Are TLS 1.0 and 1.1 still enabled?
TLS 1.0 and 1.1 were deprecated by RFC 8996 in 2021 and dropped by every major browser before that. They are vulnerable to attacks that modern versions are not, and keeping them enabled buys compatibility with clients that no longer visit you.
The audit collects every ssl_protocols directive from Nginx and every SSLProtocol from Apache, deduplicates them, and inspects each one. For Apache it understands the negation form: a directive containing -TLSv1 is not flagged, while +TLSv1, +SSLv3 or a bare all is.
How to check manually
grep -rn 'ssl_protocols' /etc/nginx/ | grep -v '#'
grep -rni 'SSLProtocol' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | Every protocol directive allows only modern TLS | The number of directives checked is named. That count is worth reading: it tells you whether the audit saw all your config or only part of it. |
| WARN | No explicit protocol list exists anywhere | You inherit the package default, which varies by distribution and OpenSSL version. Pin the versions explicitly. |
| FAIL | One or more directives still enable SSLv3, TLS 1.0 or TLS 1.1 | The offending directives are quoted. Any block that does not override this inherits it. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
The failure message includes a specific hint worth repeating: distribution defaults in nginx.conf have historically shipped this way. If you have never edited the http block, the weak directive is probably in the packaged file rather than in anything you wrote.
How to fix it
# In the http block of /etc/nginx/nginx.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;# In the ssl module config
SSLProtocol -all +TLSv1.2 +TLSv1.3Then test and reload:
sudo nginx -t && sudo systemctl reload nginx
sudo apachectl configtest && sudo systemctl reload apache2http block covers every site that does not override it, while a hardened single vhost leaves every other vhost on the default. That is why this check reads every directive and fails if any one of them is weak.Check 3: Do any cipher directives permit weak families?
Cipher families like RC4, 3DES and the EXPORT grades are broken in ways with names and papers attached. NULL and aNULL suites offer no encryption or no authentication at all. A modern suite excludes all of them, and most published cipher strings do exactly that by appending negated entries.
That negation is the subtlety this check handles. The audit splits every cipher string on : and discards tokens beginning with ! before looking for weak families, because every Mozilla-recommended suite ends with !aNULL:!MD5:!3DES and a naive substring match would flag the best configuration available.
How to check manually
grep -rn 'ssl_ciphers' /etc/nginx/ | grep -v '#'
grep -rni 'SSLCipherSuite' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | Every cipher directive excludes the weak families | The number of directives checked is named. |
| WARN | No explicit cipher list exists anywhere | Defaults vary by package and OpenSSL version. Set a modern suite from the Mozilla SSL configuration generator. |
| FAIL | A non-negated entry names RC4, MD5, EXPORT, DES or 3DES, aNULL, eNULL or NULL | The families found are listed. Negated entries starting with ! are discounted first, so this is a real permission rather than an exclusion. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
How to fix it
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
SSLHonorCipherOrder offTLS 1.3 ignores these directives entirely: its cipher suites are fixed and all of them are strong. What you configure here applies to TLS 1.2 negotiation only.
Check 4: Is any certificate expired or expiring within 30 days?
An expired certificate is a full-page browser warning for every visitor, a failed API call for every integration, and a support inbox that fills faster than your monitoring alerts. It is also entirely predictable, which is what makes it embarrassing.
The audit collects every certificate path referenced by ssl_certificate in Nginx and SSLCertificateFile in Apache, then runs openssl x509 -checkend against each one: first at zero seconds for already expired, then at 2592000 seconds for the 30 day window.
How to check manually
grep -rn 'ssl_certificate ' /etc/nginx/ | grep -v '#' | awk '{print $2}' | tr -d ';' | sort -u | while read c; do
printf '%s ' "$c"
sudo openssl x509 -checkend 2592000 -noout -in "$c" >/dev/null 2>&1 && echo "OK" || echo "EXPIRES WITHIN 30 DAYS"
done| Result | When | What it means |
|---|---|---|
| PASS | Every readable certificate has more than 30 days left | The number of certificates checked is named. |
| FAIL | A certificate is already expired, or expires within 30 days | Two distinct failures, and already-expired takes priority. Both name the certificate files. Thirty days is Certbot renewal territory, so a certificate in that window means automation did not run. |
| SKIP | No web server detected, or no readable certificate files were referenced in the config | Two distinct skips. The second is the one to read carefully: it means the audit found no certificate to check, which is not the same as finding healthy ones. |
That second skip usually means one of three things: TLS terminates somewhere else, the certificate paths in the config point at files that do not exist, or the audit account could not read them. All three are worth knowing about.
How to fix it
sudo certbot renew
sudo systemctl reload nginx
# Force one domain if the timer has been failing quietly
sudo certbot certonly --nginx -d yourdomain.comThen confirm the automation rather than the certificate:
systemctl list-timers | grep certbot
sudo certbot renew --dry-runcertbot renew with no reload leaves the old one serving until something restarts the process. Certbot's packaged deploy hooks handle this; a hand-rolled renewal cron job frequently does not.Check 5: Does plain HTTP redirect to HTTPS?
Visitors type domains without a scheme, old links point at http://, and QR codes printed last year still say it. Without a redirect, every one of those sessions stays unencrypted for its whole life, and HSTS cannot help because HSTS only applies after a first successful HTTPS visit.
The audit requests http://127.0.0.1/ and reads the status code and Location header, then falls back to searching the config for a redirect rule.
How to check manually
curl -sI http://127.0.0.1/ | head -5
grep -rnE 'return[[:space:]]+30[12][[:space:]]+https|rewrite[^;]*https://' /etc/nginx/ | grep -v '#'
grep -rniE 'Redirect[[:space:]]+(permanent|30[12])[[:space:]]+/[[:space:]]+https|RewriteRule.*https://' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'| Result | When | What it means |
|---|---|---|
| PASS | The probe got a redirect to an https location, or a redirect rule exists in the config | Two distinct passes. The live one names the status code. The config-only one says the probe did not exercise it and asks you to verify per vhost. |
| WARN | Plain HTTP answers with something other than a redirect | The status code it got is named. Visitors stay on an unencrypted connection. |
| SKIP | No web server detected, or nothing answered on plain HTTP at all | Two distinct skips. Nothing answering on port 80 is a legitimate configuration: it means there is no plain HTTP listener to redirect from. |
How to fix it
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}<VirtualHost *:80>
ServerName yourdomain.com
Redirect permanent / https://yourdomain.com/
</VirtualHost>Use 301 rather than 302: it is cached by the browser, so the second visit never touches HTTP at all, and search engines treat it as the permanent answer.
Check 6: Is OCSP stapling enabled?
OCSP stapling has the server fetch the certificate's revocation status from the CA, cache it, and attach it to the handshake. Without it, the visitor's browser may make that request itself, which adds latency and tells the CA which sites that person visits.
The audit looks for ssl_stapling on in Nginx or SSLUseStapling on in Apache. It reads the configuration only, because whether stapling actually occurs depends on the certificate and on the CA.
How to check manually
grep -rn 'ssl_stapling' /etc/nginx/ | grep -v '#'
grep -rniE 'SSLUseStapling|SSLStaplingCache' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
# Does the certificate even name a responder
sudo openssl x509 -noout -ocsp_uri -in /etc/letsencrypt/live/yourdomain.com/cert.pem| Result | When | What it means |
|---|---|---|
| PASS | Stapling is switched on in either config | Clients skip a round trip to the CA. |
| WARN | Neither directive is present | Each visitor may query the CA directly, which is slower and leaks browsing data. |
| SKIP | No web server detected on the host | Nothing to evaluate. |
ssl_stapling on does nothing except log "ssl_stapling ignored, no OCSP responder URL in the certificate". If -ocsp_uri returns nothing, this warning is not applicable to you and can be ignored. Certificates from CAs that still run responders are unaffected. The known issues page lists this and the other checks whose logic has drifted from the software they audit.How to fix it
Check the certificate first. If it names a responder, enable stapling:
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;SSLUseStapling on
SSLStaplingCache shmcb:/var/run/ocsp(128000)
SSLStaplingResponseMaxAge 900The resolver line is not optional on Nginx: without it the server cannot look up the responder hostname and stapling silently does nothing.
What this audit does not cover
This audit reads TLS configuration, certificates on disk and the plain HTTP response. It does not check:
- HTTP response headers. HSTS is graded in the Security Headers audit, alongside CSP, frame protection and cookie flags.
- What the server exposes. Directory listing, dotfiles, backup files and status endpoints are the Content Exposure audit.
- Permissions on the key files. World-readable private keys are caught by the Configuration Hardening audit.
- OpenSSL patch level. Pending
opensslandlibsslupdates are the Identification and Patching audit. - What the handshake actually negotiates. These checks read configuration rather than running a full TLS negotiation matrix. For that, point
testssl.shor SSL Labs at the public hostname. - Certificates that are not referenced in the config. A certificate on disk that no vhost points at is invisible here, as is anything terminated at a load balancer or CDN.
The manual audit problem at scale
The commands themselves are quick. What takes time is assembling the certificate list out of the config, remembering that -checkend takes seconds rather than days, and knowing that a weak ssl_protocols in the packaged nginx.conf outranks the hardened one you added to a vhost.
On eight servers with a mix of Nginx and Apache that is the better part of an hour, and the failure mode is not a wrong answer, it is a certificate you never listed.
| Task | By hand, 8 hosts | CtrlOps, 8 hosts |
|---|---|---|
| Run all 6 checks | About 80 minutes | About 95 seconds |
| Certificate discovery | Grep the config, build a list | Every referenced path, automatically |
| Directive coverage | Whichever file you opened | Every directive in the effective config |
| Get a scored report | Not available | Automatic |
| Re-audit after fixing | Another 80 minutes | Another 95 seconds |
How CtrlOps runs all 6 checks in one click
Instead of running these commands on every server by hand, CtrlOps runs the whole TLS Configuration 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 and 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.
Step 1: Open the Audit tab
Connect to your server in CtrlOps and open the Audit tab in the left sidebar.
Step 2: Select TLS Configuration
Choose it from the Web Server category. All 6 checks are listed with their descriptions, severity levels and the estimated run time, about 12 seconds in total. Only the certificate check needs root, and the UI says so before you run anything.
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 naming the certificate files and the exact directives it graded
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 a protocol or cipher change takes effect on reload and a mistake in the directive takes every site on the host with it.
Step 6: Re-run and compare
Run the same audit again after reloading and watch the score move. Check 4 is the one to run on a schedule rather than after changes, because a certificate moves into the 30 day window on its own while nothing on the server changes at all.
Conclusion
TLS is the one area where the configuration can be perfect and the site still breaks, because a certificate has a date on it and dates arrive. Three of these six are HIGH severity: no TLS at all, protocol versions that should have been retired years ago, and the clock.
The other three are the ones that decide whether the encryption you configured is the encryption your visitors actually get. The CtrlOps Security Audit runs all 6 in about 12 seconds per server, across every directive and every referenced certificate rather than the first one it finds.
The neighbouring checklists cover the other web server layers: security headers, content exposure, configuration hardening, identification and patching and logging and monitoring.
Frequently asked questions
Run openssl x509 -enddate -noout -in /path/to/cert.pem for the date, or openssl x509 -checkend 2592000 -noout -in /path/to/cert.pem to test the 30 day window, where a non-zero exit means it expires inside it. This audit runs the second form against every certificate your config references.
TLS 1.2 and 1.3 only. TLS 1.0 and 1.1 were deprecated by RFC 8996 in 2021 and no current browser negotiates them. Set ssl_protocols TLSv1.2 TLSv1.3; in the Nginx http block, or SSLProtocol -all +TLSv1.2 +TLSv1.3 in the Apache SSL config, so every vhost inherits it.
Because it reads every directive, not the first one. Protocol settings are inherited, so a weak ssl_protocols in the packaged nginx.conf applies to every block that does not override it, even when your own vhost is correct. The failure message quotes the offending directive, which is usually in the distribution file rather than in anything you wrote.
Because the check splits the string on colons and discards tokens starting with ! before looking for weak families. Every Mozilla suite ends with exclusions like !aNULL:!MD5:!3DES, and a naive substring match would fail the best configuration available. Only a family that is permitted, rather than negated, is reported.
Run sudo certbot renew, or sudo certbot certonly --nginx -d yourdomain.com to force one domain, then sudo systemctl reload nginx. The reload matters: both servers hold the certificate in memory from startup, so a renewal without one leaves the expired certificate serving.
Either no web server was detected, or no readable certificate file was referenced in the config. The second case covers TLS terminating at a load balancer, a config pointing at paths that no longer exist, and the audit account lacking permission to read the key directory. A skip means nothing was checked, which is different from everything being healthy.
- It is cached by the browser, so a returning visitor goes straight to HTTPS without touching port 80, and search engines treat it as permanent. A 302 asks the browser to check again every time, which leaves the plain HTTP request in place on every visit.
It depends on your CA. Let's Encrypt stopped including OCSP URLs in certificates in May 2025 and shut off its responders in August 2025, so stapling is inert for those certificates and Nginx logs that it is ignoring the directive. Check with openssl x509 -noout -ocsp_uri -in cert.pem: if it prints nothing, the warning does not apply to you.
Check the timer first with systemctl list-timers | grep certbot, and enable it with sudo systemctl enable --now certbot.timer if it is missing. Then run sudo certbot renew --dry-run: the common failures are a changed DNS record, port 80 closed so the HTTP-01 challenge cannot complete, or a webroot path that moved since the certificate was first issued.
Not exhaustively. It reads the effective configuration and does one loopback probe, which is enough to grade protocol and cipher directives and certificate dates in about 12 seconds. For a full negotiation matrix against the public hostname, including what a real client ends up with through your CDN, use SSL Labs or testssl.sh alongside it.
The audit detects both, but the config-level grading parses Nginx and Apache directives, so most checks skip on those hosts rather than reporting false failures. For Caddy that matters less than it sounds: it provisions and renews certificates automatically and defaults to modern protocols, which is most of what this audit is looking for.