Web Server security checklist

Web Server TLS Configuration Checklist: 6 Checks (2026)

An expired certificate is the failure your customers report before your monitoring does. This page documents all 6 checks in the CtrlOps TLS Configuration 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
6
checks in this audit
3
rated high severity
12s
automated run time
1
need root or sudo

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

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. NDIR in 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.
#CheckSeverityWhat it catchesRoot needed
1HTTPS enabledHIGHNo TLS anywhere on the hostNo
2TLS protocolsHIGHTLS 1.0 or 1.1 still negotiableNo
3TLS ciphersMEDIUMRC4, 3DES, EXPORT, NULL familiesNo
4Certificate expiryHIGHExpired or expiring within 30 daysYes
5HTTPS redirectMEDIUMPlain HTTP serving contentNo
6OCSP staplingLOWClients querying the CA themselvesNo
Detection covers four servers, grading covers two. The audit detects Nginx, Apache, Caddy and Lighttpd. The config-level checks parse Nginx and Apache directives specifically, so on a Caddy or Lighttpd host the server is recognised and most of these checks skip rather than reporting false failures. Caddy provisions and renews certificates automatically, which is why its absence from the grading is less of a gap than it looks.

Check 1: Is HTTPS configured at all?

High severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSTLS appears in either config, or the probe reached an https URLTLS is configured. The check deliberately does not grade how well, which is what checks 2, 3 and 4 are for.
FAILNo TLS in either config and no HTTPS responseCredentials and session cookies cross the network in clear text. Get a certificate with certbot.
SKIPNo web server detected on the hostNothing 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.com

Certbot 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 certbot
Bottom line: a pass here means TLS exists, nothing more. A host can pass this check while negotiating TLS 1.0 with a certificate that expired last week, which is exactly why the five checks after it exist.

Check 2: Are TLS 1.0 and 1.1 still enabled?

High severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery protocol directive allows only modern TLSThe 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.
WARNNo explicit protocol list exists anywhereYou inherit the package default, which varies by distribution and OpenSSL version. Pin the versions explicitly.
FAILOne or more directives still enable SSLv3, TLS 1.0 or TLS 1.1The offending directives are quoted. Any block that does not override this inherits it.
SKIPNo web server detected on the hostNothing 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.3

Then test and reload:

sudo nginx -t && sudo systemctl reload nginx
sudo apachectl configtest && sudo systemctl reload apache2
Setting it in one server block is not setting it. Protocol directives are inherited, so a hardened http 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?

Medium severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery cipher directive excludes the weak familiesThe number of directives checked is named.
WARNNo explicit cipher list exists anywhereDefaults vary by package and OpenSSL version. Set a modern suite from the Mozilla SSL configuration generator.
FAILA non-negated entry names RC4, MD5, EXPORT, DES or 3DES, aNULL, eNULL or NULLThe families found are listed. Negated entries starting with ! are discounted first, so this is a real permission rather than an exclusion.
SKIPNo web server detected on the hostNothing 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 off

TLS 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.

Bottom line: generate the string from the Mozilla SSL configuration generator for your exact server and OpenSSL version rather than copying one from an article. Cipher lists age badly, and a string copied from a 2021 post is how 3DES is still enabled on a host somebody hardened.

Check 4: Is any certificate expired or expiring within 30 days?

High severityNeeds sudo

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 thresholds this check applies
ResultWhenWhat it means
PASSEvery readable certificate has more than 30 days leftThe number of certificates checked is named.
FAILA certificate is already expired, or expires within 30 daysTwo 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.
SKIPNo web server detected, or no readable certificate files were referenced in the configTwo 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.com

Then confirm the automation rather than the certificate:

systemctl list-timers | grep certbot
sudo certbot renew --dry-run
A renewed certificate on disk is not a renewed certificate in memory. Both servers read the certificate at startup, so a successful certbot 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?

Medium severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSThe probe got a redirect to an https location, or a redirect rule exists in the configTwo 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.
WARNPlain HTTP answers with something other than a redirectThe status code it got is named. Visitors stay on an unencrypted connection.
SKIPNo web server detected, or nothing answered on plain HTTP at allTwo 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.

Bottom line: pair the redirect with HSTS. The redirect handles the first visit, which is the one HSTS cannot protect, and HSTS handles every visit after that, which is the one an attacker would otherwise get to intercept.

Check 6: Is OCSP stapling enabled?

Low severityNo sudo needed

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 thresholds this check applies
ResultWhenWhat it means
PASSStapling is switched on in either configClients skip a round trip to the CA.
WARNNeither directive is presentEach visitor may query the CA directly, which is slower and leaks browsing data.
SKIPNo web server detected on the hostNothing to evaluate.
This check has drifted for Let's Encrypt certificates, and the last command above is how you tell. Let's Encrypt stopped including OCSP URLs in its certificates on 7 May 2025 and switched off its responders on 6 August 2025. With no responder URL in the certificate, 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 900

The 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 openssl and libssl updates 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.sh or 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.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 6 checksAbout 80 minutesAbout 95 seconds
Certificate discoveryGrep the config, build a listEvery referenced path, automatically
Directive coverageWhichever file you openedEvery directive in the effective config
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 80 minutesAnother 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.

  1. 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.

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.

Windows

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