Web Server security checklist

Web Server Logging & Monitoring Checklist: 5 Checks (2026)

Logs are the first thing you reach for after an incident and the last thing anyone verifies is working. This page documents all 5 checks in the CtrlOps Logging and Monitoring 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
0
rated high severity
12s
automated run time
2
need root or sudo

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

The server went down overnight. You open the access log and it is empty: not quiet, empty. Rotation ran a fortnight ago, the new file came back with the wrong permissions, and the server has been dropping log writes ever since. This page documents the 5 checks in the CtrlOps Logging and Monitoring audit, in the order the audit runs them.

Every threshold below is transcribed from the audit script itself. Four of these checks are about whether you will have a record when you need one, and the fifth is about whether anything inspects a request before your application does.

Key takeaways

Five checks, no HIGH severity, and one of them is the only check in the category that fails for something being absent rather than wrong.

  • Four are MEDIUM and one is LOW. Nothing here is HIGH, because none of it stops an attack. What it decides is whether you can reconstruct one.
  • Two need root: log permissions and rotation, because both read inside /var/log/nginx, /var/log/apache2 or /var/log/httpd.
  • Only access logging can fail, and only when no access log is configured anywhere. Everything else warns.
  • The WAF check draws a line that matters. Fail2Ban and CrowdSec ban an IP after a request has been served. A WAF inspects the request before it is handled. The audit reports the first as a warning rather than counting it as the second.
  • The whole audit takes about 12 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 minutes of grep and stat per host by hand.
#CheckSeverityWhat it catchesRoot needed
1Access loggingMEDIUMNo access log, or blocks with it disabledNo
2Error loggingLOWNo error log, or debug level in productionNo
3Log permissionsMEDIUMWorld-readable log filesYes
4Log rotationMEDIUMNo rotation, or logs past 100MBYes
5Web application firewallMEDIUMNo request filtering at any layerNo
Checks 3 and 4 look in three fixed directories: /var/log/nginx, /var/log/apache2 and /var/log/httpd. A server logging somewhere else, to a dedicated volume or a per-site path, reports SKIP rather than PASS, and the finding says no log directory was found. A skip here means the audit had nothing to judge, which is deliberately not the same statement as "your logging is fine".

Check 1: Is access logging enabled everywhere?

Medium severityNo sudo needed

The access log is the only record of what was requested, by whom and when. Without it, an incident investigation starts and ends with guesswork: no source addresses, no request paths, no timeline, nothing to hand an insurer or a client.

The audit counts access_log directives in the Nginx config, separating those whose value is literally off from the rest, and counts CustomLog or TransferLog in Apache. That separation is deliberate: an earlier version matched the string off anywhere in the line and treated /var/log/nginx/offers.log as disabled logging.

How to check manually

grep -rnE '^[[:space:]]*access_log' /etc/nginx/ | grep -v '#'
grep -rnE '^[[:space:]]*access_log[[:space:]]+off[[:space:]]*;' /etc/nginx/ | grep -v '#'
grep -rniE '^[[:space:]]*(CustomLog|TransferLog)' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'

sudo tail -1 /var/log/nginx/access.log
Result thresholds this check applies
ResultWhenWhat it means
PASSAt least one access log is configured and no block disables itAccess logging is enabled. Keep it, it is your only record of an attack.
WARNLogging is on globally but disabled in one or more blocksThe number of blocks with access_log off is named. Confirm those are static asset paths rather than application routes.
FAILNo access log is configured anywhereAfter an incident you will have nothing to investigate with.
SKIPNo web server detected on the hostNothing to evaluate.

The warning is the one worth investigating rather than dismissing. Turning off logging for an image directory is a reasonable performance decision. Turning it off for a high-traffic API endpoint to save disk is how the one route worth investigating becomes the one route with no history.

How to fix it

# Per site, so traffic can be separated without parsing one huge file
access_log /var/log/nginx/sitename-access.log combined;
CustomLog /var/log/apache2/sitename-access.log combined

Use the combined format rather than common: it adds the referer and user agent, which are what let you tell a scanner apart from a browser after the fact.

A configured log is not a working log. If rotation recreated the file with the wrong owner, the server keeps running and writes nothing. Check the timestamp on the file, not just the directive: ls -la /var/log/nginx/access.log followed by tail -1 tells you in two seconds whether anything is actually arriving.

Check 2: Is error logging configured at a production level?

Low severityNo sudo needed

The error log is where the first clue lives in almost every debugging session: an upstream timeout, a permission denial, a worker crash. Set too high and those never appear. Set to debug in production, the log fills the disk and can record request bodies, which means credentials in a file that ships to wherever your logs go.

The audit reads the first error_log directive from Nginx and the first ErrorLog or LogLevel from Apache, then checks whether either names debug or trace.

How to check manually

grep -rnE '^[[:space:]]*error_log' /etc/nginx/ | grep -v '#'
grep -rniE '^[[:space:]]*(ErrorLog|LogLevel)' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSAn error log directive exists and the level is not debug or traceError logging is configured at a production-appropriate level.
WARNNo error log directive exists, or the level is debug or traceTwo distinct warnings. The first says errors may be going nowhere. The second says the level fills the disk and can record request bodies.
SKIPNo web server detected on the hostNothing to evaluate.

Note that the check reads the first directive it finds rather than every one. A global warn with a debug override on one vhost can pass, so if you deliberately raised the level somewhere, that is on your list rather than the audit's.

How to fix it

error_log /var/log/nginx/error.log warn;
ErrorLog ${APACHE_LOG_DIR}/error.log
LogLevel warn

warn captures warnings, errors and everything more severe, which is the right production floor. Raise it to info for a specific troubleshooting session and put a reminder somewhere to lower it again.

Debug logging is a temporary state that outlives the incident it was enabled for. On a busy server it produces gigabytes a day and can capture request bodies, so a POST with a password in it ends up in a file that gets shipped, backed up and retained. If this check warns, the question is which incident three months ago left it on.

Check 3: Are log files world-readable?

Medium severityNeeds sudo

Access logs carry more than addresses. They carry every requested URL, which on a badly designed application includes session tokens, password reset links, API keys in query strings and internal paths nobody meant to publish. World-readable means every account on the host can read all of it, including whatever account your application runs as.

The audit looks in the web server log directories for files with the world-readable bit and names up to three.

How to check manually

sudo find /var/log/nginx /var/log/apache2 /var/log/httpd -maxdepth 1 -type f -perm -004 2>/dev/null

sudo ls -la /var/log/nginx/
Result thresholds this check applies
ResultWhenWhat it means
PASSNo world-readable files in the web server log directoriesLogs are not readable by unprivileged accounts.
WARNOne or more log files are world-readableUp to three are named. Access logs carry session tokens and internal URLs, so chmod 640 with group adm.
SKIPNo web server log directory was foundNone of the three standard directories exists. Nothing was evaluated.

The check reads the world-readable bit only. It does not check whether the worker user can write to the logs, which is the other permission question worth asking, so verify ownership by hand while you are there.

How to fix it

sudo chown root:adm /var/log/nginx/*.log
sudo chmod 640 /var/log/nginx/*.log

# RHEL family has no adm group by default
sudo chown root:root /var/log/httpd/*.log
sudo chmod 640 /var/log/httpd/*.log

The master process opens the log files as root and passes the descriptors to the workers, so the worker user needs no permission on them at all. Then fix the rotation config, or the next rotated file arrives with default permissions and undoes this:

create 0640 root adm
Bottom line: fix the mode and the create line in the same change. Doing only the chmod buys you until the next rotation, which is usually tomorrow.

Check 4: Is log rotation keeping up?

Medium severityNeeds sudo

A site producing 50 MB of logs a day fills 18 GB in a year, in one file that takes half a minute to open. On a modest VPS that is a full disk, and a full disk stops the database, the application and the web server at the same moment.

The audit checks three things in order: whether logrotate is installed at all, whether any log file is over 100 MB, and whether a logrotate config exists for the web server.

How to check manually

command -v logrotate || echo "logrotate not installed"
ls /etc/logrotate.d/nginx /etc/logrotate.d/apache2 /etc/logrotate.d/httpd 2>/dev/null

sudo find /var/log/nginx /var/log/apache2 /var/log/httpd -maxdepth 1 -type f -size +100M 2>/dev/null
cat /var/lib/logrotate/status 2>/dev/null | head
Result thresholds this check applies
ResultWhenWhat it means
PASSA logrotate config exists for the web server and no log exceeds 100MBRotation is configured and keeping up.
WARNlogrotate is not installed, a log file is over 100MB, or there is no logrotate config for the web serverThree distinct warnings, checked in that order. The oversized files are named when that is the branch that fires.
SKIPNo web server log directory was foundNothing was evaluated.

A file over 100 MB with rotation configured is the most useful of the three, because it means rotation exists and is not keeping pace: either the schedule is too slow for the traffic, or the postrotate step is failing and the server is still writing to the rotated file.

How to fix it

# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 root adm
    sharedscripts
    postrotate
        [ -f /var/run/nginx.pid ] && kill -USR1 "$(cat /var/run/nginx.pid)"
    endscript
}

Test it without rotating anything:

sudo logrotate -d /etc/logrotate.d/nginx
The postrotate step is the part that breaks silently. Rotation renames the file, but the server keeps writing to the same open descriptor, so the new access.log stays empty while the renamed file keeps growing. Nginx needs kill -USR1, Apache needs apachectl graceful. This is the exact failure behind an access log that exists, is owned correctly, and contains nothing.

Check 5: Is there a WAF in front of the application?

Medium severityNo sudo needed

A web application firewall inspects the request before your application handles it, and refuses the ones that match known attack shapes: injection strings, traversal sequences, the scanner payloads every public host receives continuously. It is not a substitute for input validation. It catches what the code missed, including the bug in a dependency nobody has patched yet.

The audit looks for ModSecurity in the Apache module list or in either config, for NAXSI in the Nginx config, and for a Cloudflare signature in the response headers. Separately, it detects Fail2Ban and CrowdSec, and reports them as a distinct and weaker case.

How to check manually

apache2ctl -M 2>/dev/null | grep -i security
nginx -V 2>&1 | grep -iE 'modsecurity|naxsi'
grep -rniE 'modsecurity[[:space:]]*on|SecRuleEngine[[:space:]]*On' /etc/nginx/ /etc/apache2/ /etc/httpd/ 2>/dev/null

curl -sI https://yourdomain.com | grep -iE 'cf-ray|server: cloudflare|x-sucuri'
Result thresholds this check applies
ResultWhenWhat it means
PASSModSecurity, NAXSI or a Cloudflare signature was foundRequest filtering is present, and what it found is named. Keep its rules updated.
WARNOnly log-based IP banning is present, or nothing at allTwo distinct warnings. The first names Fail2Ban or CrowdSec and explains that they react after a request is served, which does nothing about a single-shot injection. The second says no filtering was detected at any layer.
SKIPNo web server detected on the hostNothing to evaluate.

The separation between a WAF and log-based banning is the point of this check. Fail2Ban is genuinely useful and has its own check in the VPS logging audit. It watches logs and bans repeat offenders, which stops brute force. It cannot stop the first request, which is all an SQL injection needs.

How to fix it

# ModSecurity with the OWASP Core Rule Set, Debian and Ubuntu
sudo apt install libapache2-mod-security2 modsecurity-crs
sudo a2enmod security2
sudo systemctl reload apache2

Start in detection mode and read what it would have blocked before you let it block anything:

# /etc/modsecurity/modsecurity.conf
SecRuleEngine DetectionOnly

Run that way for a full traffic cycle, tune the rules that fire on legitimate requests, then switch to SecRuleEngine On.

Bottom line: if ModSecurity plus the OWASP rule set is more than you want to operate, a CDN WAF gets you most of the value with none of the tuning, and this check detects Cloudflare in the response headers. Either is a real answer. Fail2Ban on its own is not, which is what the first warning is telling you.

What this audit does not cover

This audit reads logging configuration, log file permissions, rotation state and request filtering. It does not check:

  • HTTP response headers. HSTS, CSP, frame protection and cookie flags are the Security Headers audit.
  • TLS and certificates. Protocol versions, ciphers and certificate expiry are the TLS Configuration audit.
  • What the server exposes. Directory listing, dotfiles, backups and status endpoints are the Content Exposure audit.
  • Host-level logging. Fail2Ban state, log rotation health across the system and failed SSH login volume are the VPS Logging and Monitoring audit.
  • What is in the logs. The audit checks that logging works and is protected. It does not read your logs, look for attacks in them, or alert on anything.
  • Log shipping and retention. Whether logs reach a central store, and how long they are kept there, is a question about the receiving end rather than about this host.

The manual audit problem at scale

Five checks by hand is fifteen to twenty minutes per server, and the part that takes the time is not the commands. It is confirming that a log which is configured is also being written to, which means checking the directive, then the file, then the timestamp, then the rotation config that will recreate it tomorrow.

Across ten servers that is most of a morning, and the failure it is designed to catch, a log that exists and is empty, is precisely the one that a config-only check reports as healthy.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 5 checksAbout 2 hoursAbout 95 seconds
Directive versus realityCheck the config, then the file, then the timestampConfig, permissions and file sizes in one pass
Rotation stateRead the config and the status fileChecked against a 100MB size trigger
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 2 hoursAnother 95 seconds

How CtrlOps runs all 5 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Logging and Monitoring 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 Logging & Monitoring

Choose it from the Web Server category. All 5 checks are listed with their descriptions, severity levels and the estimated run time, about 12 seconds in total. Two of them need 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 specific log files, their permissions and their sizes

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 chown across a log directory while the server holds those files open is a change worth reading before it runs.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the score move. Check 4 is the one to re-run after any rotation change, because the failure mode is silent: the config looks right, rotation runs, and the new log stays empty.

Conclusion

Nothing on this page stops an attack. What it decides is whether, on the morning after one, you can say what happened. An access log that was switched off for one route, a rotated file the server never reopened, a permissions change that let every local account read your URLs: each is invisible right up to the moment it is the only thing you needed.

The fifth check is the exception, and it draws a line worth remembering: Fail2Ban reacts to a request that has already been served, a WAF refuses one before it is handled. The CtrlOps Security Audit runs all 5 in about 12 seconds per server.

The neighbouring checklists cover the other web server layers: security headers, TLS configuration, content exposure, configuration hardening and identification and patching.


Frequently asked questions

root:adm with mode 640 on Debian and Ubuntu, or root:root with 640 on RHEL family, which has no adm group by default. The master process opens the files as root and passes the descriptors to the workers, so the worker user needs no permission at all. Set the same values in the logrotate create line, or the next rotation undoes it.

Define a format in the http block with log_format, then reference it: access_log /var/log/nginx/site-access.log combined;. The built-in combined format adds referer and user agent over common, which is what lets you separate a scanner from a browser afterwards. For structured logs, define a JSON format with escape=json.

Almost always the postrotate step. Rotation renames the file, but the server keeps writing to the descriptor it already holds, so the new file stays empty while the renamed one grows. Nginx needs kill -USR1 on its PID, Apache needs apachectl graceful. The other cause is a rotation that recreated the file with an owner the server cannot write to.

Logrotate runs daily from cron or a systemd timer, renames the current file, creates a replacement with the permissions in the create line, then runs the postrotate script that signals Nginx to reopen its files. compress with delaycompress gzips the previous cycle's file, and rotate 14 keeps a fortnight of history.

No, and this audit is explicit about the difference. Fail2Ban and CrowdSec read logs and ban addresses that repeat a pattern, which stops brute force after the fact. A WAF inspects the request body and headers before the application handles them, which is what stops a single-shot injection. The audit reports a host with only log-based banning as a warning naming the tool it found.

Whichever you will actually operate. ModSecurity with the OWASP Core Rule Set is free, runs on your server and needs tuning: start in DetectionOnly, watch what it would have blocked for a full traffic cycle, then enforce. A CDN WAF needs no server-side work and covers you before traffic arrives. Running both usually means two sets of false positives.

Because checks 3 and 4 look in /var/log/nginx, /var/log/apache2 and /var/log/httpd only. If your logs go to a dedicated volume or a per-site path, none of those exists and the audit reports that it found no log directory. That is a skip rather than a pass, because it could not evaluate anything.

For a static asset location, sometimes, where the volume is high and the value is low. For anything that touches the application, no. The endpoint somebody disables to save disk is reliably the endpoint worth investigating later. If volume is the problem, use a leaner log format or a separate file with shorter retention rather than turning the record off.

On a busy server it produces gigabytes a day, which is a disk-space risk on its own, and it can record request bodies. That means a POST containing a password ends up in a file that is backed up, shipped to your log store and retained. Use warn in production and raise the level only for a specific, time-boxed troubleshooting session.

No. It audits whether logging is configured, protected and rotating, which is the layer before shipping. Aggregation, search and alerting belong to Loki, the ELK stack or a hosted service. This audit makes sure the pipeline has something correct to start from, because a centralised store faithfully collects an empty log file too.

Monthly on production, and after any change to logging or rotation. The finding worth scheduling for is check 4, because a log crossing 100MB and a rotation that stopped reopening files both happen with nothing on the server changing. The others only move when somebody edits the config.

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