Web Server security checklist

Web Server Configuration Hardening Checklist: 6 Checks (2026)

Web server configuration hardening is the gap between installed and production-ready. This page documents all 6 checks in the CtrlOps Configuration Hardening audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202614 min read
6
checks in this audit
2
rated high severity
11s
automated run time
2
need root or sudo

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

Distribution packages optimise for getting you online, not for keeping you there safely. The config directory is readable by more accounts than it needs to be, the web root is owned by the same user that runs the application, there is no cap on request size, and Apache has modules enabled that this host has never used. This page documents the 6 checks in the CtrlOps Configuration Hardening audit, in the order the audit runs them.

Every threshold below is transcribed from the audit script itself. Two of these checks describe how far a compromise spreads once it starts, and four are about refusing work the server should never have accepted.

Key takeaways

Six checks, two HIGH, and the two HIGH ones both answer the same question: what can something already on this box write to?

  • Two are rated HIGH: config file permissions and web root ownership. Two are MEDIUM, two are LOW.
  • Only two need root. The permission and ownership checks walk directories that are deliberately not world-readable. The other four read config text, which any account can do.
  • Four of the six cannot fail. Request limits, timeouts, rate limiting and risky modules warn at most, because the right value is workload-dependent and the audit will not invent one for you.
  • Check 6 skips entirely on an Nginx-only host. Nginx compiles in what it needs rather than loading modules at runtime, so there is no equivalent list to grade.
  • The whole audit takes about 11 seconds when CtrlOps runs it over your existing SSH connection, against roughly 15 minutes of find, stat and grep per host by hand.
#CheckSeverityWhat it catchesRoot needed
1Config permissionsHIGHWorld-writable configs, world-readable keysYes
2Web root ownershipHIGHWritable web content, worker-owned directoriesYes
3Request size limitsMEDIUMNo cap on request body sizeNo
4Connection timeoutsLOWSlow requests holding workers openNo
5Rate limitingMEDIUMUnthrottled login and API endpointsNo
6Risky Apache modulesLOWOptional modules widening the attack surfaceNo
Where the audit looks for your configuration. Nginx is read from /etc/nginx/nginx.conf, /etc/nginx/conf.d/*.conf and /etc/nginx/sites-enabled/*. Apache is read from the enabled paths only: apache2.conf or httpd.conf, conf.d, conf-enabled, sites-enabled and mods-enabled. Document roots come from root and DocumentRoot directives in that text, up to ten of them. A site whose config lives somewhere unusual is invisible to these checks, and the finding will say it found no document root rather than passing.

Check 1: Are the config files and private keys locked down?

High severityNeeds sudo

A world-writable config file means any local account, any compromised application, any cron job running as any user can rewrite where your server sends traffic and what it serves. A world-readable private key means the TLS that protects everything else is available to anyone who can log in.

The audit walks the config directories for files with the world-writable bit, then searches the config directories plus /etc/ssl/private and /etc/letsencrypt for *.key and privkey*.pem files that are world-readable. It reports up to three of each.

How to check manually

sudo find /etc/nginx /etc/apache2 /etc/httpd -type f -perm -0002 2>/dev/null

sudo find /etc/nginx /etc/apache2 /etc/httpd /etc/ssl/private /etc/letsencrypt \
  -type f \( -name '*.key' -o -name 'privkey*.pem' \) -perm -004 2>/dev/null
Result thresholds this check applies
ResultWhenWhat it means
PASSNo world-writable config files and no world-readable private keysBoth halves of the check are clean.
FAILWorld-writable config files exist, or private keys are world-readableTwo distinct failures, reported in that order: the writable configs are named first, and only if there are none does the check report readable keys. Fix the configs, then re-run to see whether keys are also flagged.
SKIPNo web server config directory was foundNothing to evaluate.

That ordering is worth knowing when you read a report: the two failures share one check, so a host with both problems shows only the first until it is fixed.

How to fix it

# Remove the world-writable bit from config files
sudo find /etc/nginx -type f -perm -0002 -exec chmod o-w {} \;

# Private keys belong to root, mode 600
sudo find /etc/letsencrypt -name 'privkey*.pem' -exec chmod 600 {} \;
sudo find /etc/nginx -name '*.key' -exec chmod 600 {} \;

Nginx and Apache read their keys as the master process, which runs as root, so tightening the key to 600 does not break the server. If a key has to be readable by something else, a monitoring agent or a load balancer sync job, give it a group and use 640 rather than opening it to everyone.

This finding usually dates from a permissions error somebody fixed at speed. A chmod -R 777 on a config directory during a deployment at midnight is still in effect today, and nothing since has had any reason to complain. It is worth checking even on a host you believe nobody has touched.

Check 2: Who owns and can write to the web root?

High severityNeeds sudo

The web root is the one directory on the server whose contents are handed to the internet on request. If the worker user owns it, then a file upload bug or a code execution flaw in the application lets an attacker write a file that the same server will then serve back, which is how a webshell gets its foothold.

The audit resolves the worker user from the running process table, falling back to the user directive and then to www-data. It then looks for world-writable files inside the document roots, and for directories within them owned by that worker user.

How to check manually

# Who are the workers running as
ps -eo user=,comm= | awk '$2=="nginx"||$2=="apache2"||$2=="httpd"' | sort -u

# World-writable content
sudo find /var/www -maxdepth 3 -type f -perm -0002 2>/dev/null

# Directories owned by the worker user
sudo find /var/www -maxdepth 2 -user www-data -type d 2>/dev/null
Result thresholds this check applies
ResultWhenWhat it means
PASSNo world-writable files and no worker-owned directories in the document rootsThe worker user it checked against is named in the finding.
FAILWorld-writable files exist in the web rootUp to three are named. Any local process can plant a file the server will serve.
WARNWeb root directories are owned by the worker userUp to three are named. Prefer root-owned content with the worker user holding read-only access.
SKIPNo document root was identified in the configNothing to evaluate.

The warning is the branch most sites land on, because a great many deployment guides tell you to chown -R www-data:www-data /var/www. That is convenient, and it means the process handling untrusted input owns the content it serves.

How to fix it

# Content owned by a deploy user, readable by the worker group
sudo chown -R deploy:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 750 {} \;
sudo find /var/www/html -type f -exec chmod 640 {} \;

# Only the paths that genuinely need writes
sudo chown -R www-data:www-data /var/www/html/storage
sudo chmod 770 /var/www/html/storage

The principle is that the account which deploys owns the files, and the account which serves them can read but not write, except in the handful of directories that hold uploads, cache or sessions.

Check what those writable directories can execute. An uploads directory that the worker user can write to is fine until the server is also willing to run a PHP file from it. Pair the ownership fix with a location block that disables script execution under the upload path, or the webshell still works.

Check 3: Is the request body size capped?

Medium severityNo sudo needed

Without a cap, a single client can POST until your disk or memory runs out. Nginx has a sane default of 1 MB. Apache has no default at all, which means unlimited until you say otherwise.

The audit checks for the presence of client_max_body_size in the Nginx config or LimitRequestBody in the Apache config. It does not evaluate the value.

How to check manually

grep -rn 'client_max_body_size' /etc/nginx/ | grep -v '#'
grep -rni 'LimitRequestBody' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSEither directive is present anywhere in the effective configRequest body size is capped.
WARNNeither is presentTwo distinct warnings, by server. On Nginx it notes the 1MB default is safe but asks you to set the limit explicitly per endpoint. On Apache it notes the default is unlimited, where one large POST can exhaust disk or memory.
SKIPNo web server detected on the hostNothing to evaluate.

Because the check reads presence rather than value, client_max_body_size 0;, which disables the limit entirely, passes. That is worth knowing if you inherited a config where somebody set it to zero to make an upload work.

How to fix it

# Global default
client_max_body_size 10m;

# Raise it only where uploads actually happen
location /api/upload {
    client_max_body_size 100m;
}
LimitRequestBody 10485760

<Directory "/var/www/html/uploads">
    LimitRequestBody 104857600
</Directory>
Bottom line: set the global limit as low as the application tolerates, then raise it per route. A 10 MB default refuses the traffic that should never be large, and the endpoints that legitimately accept a video file say so in their own block, where the next person reading the config can see it.

Check 4: Are connection timeouts configured?

Low severityNo sudo needed

A slow-request attack does not flood you. It opens connections and sends a byte at a time, holding a worker for each one, until the pool is exhausted and real visitors get nothing. Timeouts are what close those connections.

The audit looks for client_body_timeout or client_header_timeout in Nginx, and Timeout or RequestReadTimeout in Apache.

How to check manually

grep -rnE 'client_body_timeout|client_header_timeout' /etc/nginx/ | grep -v '#'
grep -rniE '^[[:space:]]*(Timeout|RequestReadTimeout)' /etc/apache2/ /etc/httpd/ 2>/dev/null | grep -v '#'
Result thresholds this check applies
ResultWhenWhat it means
PASSAny of those directives is present in the effective configRequest timeouts are configured.
WARNNone of them is presentSlow-request attacks can hold workers open. Set client_body_timeout or RequestReadTimeout.
SKIPNo web server detected on the hostNothing to evaluate.

Note which directives count. send_timeout and keepalive_timeout are useful and are not what this check reads, because they govern the response side rather than how long a client may take to finish sending a request.

How to fix it

client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 10s;
keepalive_timeout 15s;
Timeout 60
RequestReadTimeout header=20-40,MinRate=500 body=20,MinRate=500

Apache's RequestReadTimeout needs mod_reqtimeout, which is enabled by default on Debian family packages. The MinRate form is the useful one: it allows a slow but genuine client on a bad connection while cutting off one that is deliberately trickling.

Bottom line: this is LOW severity because modern defaults already cut most of it off, and it is still worth setting explicitly. Defaults differ between distributions and between major versions, and an explicit value is the one you can reason about during an incident.

Check 5: Is rate limiting in place on login and API paths?

Medium severityNo sudo needed

Without rate limiting, a credential-stuffing run against your login form proceeds at whatever speed the attacker's connection allows, and a scraper takes your whole catalogue as fast as your server can render it. Rate limiting does not stop either outright, it makes both slow enough to be noticed and expensive enough to be abandoned.

The audit looks for limit_req or limit_conn anywhere in the Nginx config, or for an Apache module matching evasive, ratelimit or qos in the enabled module list.

How to check manually

grep -rnE 'limit_req|limit_conn' /etc/nginx/ | grep -v '#'
apache2ctl -M 2>/dev/null | grep -iE 'evasive|ratelimit|qos'
Result thresholds this check applies
ResultWhenWhat it means
PASSNginx has a limit_req or limit_conn directive, or Apache has a rate limiting module loadedRate or connection limiting is in place. The audit does not grade the rate.
WARNNeither is presentCredential stuffing and scraping run at full speed. Add limit_req on login and API paths.
SKIPNo web server detected on the hostNothing to evaluate.

For Apache the check reads modules, not configuration, so mod_evasive present but unconfigured still passes. For Nginx it reads directives, so a zone defined and never applied to a location also passes. In both cases a pass means the mechanism exists rather than that it covers your login route.

How to fix it

# In the http block: two zones, one for auth, one for the API
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

location /login {
    limit_req zone=login burst=5 nodelay;
    proxy_pass http://app;
}

location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://app;
}

Five requests per minute on a login form is generous for a human and hostile to a script. Keep the API limit separate, because the right number for a login page and the right number for a paginated API are nowhere near each other.

Rate limit by something more stable than the client IP where you can. $binary_remote_addr behind a CDN or load balancer is the proxy's address unless you configure real_ip correctly, in which case one zone entry covers all your traffic and the limit either blocks everybody or nobody.

Check 6: Are risky Apache modules still enabled?

Low severityNo sudo needed

Apache loads modules at runtime, and distribution packages enable a set that suits a general-purpose install rather than your site. Every loaded module is code in the request path, and several of the defaults expose things a production host has no reason to expose.

The audit reads the enabled module list from mods-enabled or from LoadModule lines, and flags eight specific names: autoindex, status, info, userdir, cgi, include, dav and dav_fs.

How to check manually

apache2ctl -M 2>/dev/null || httpd -M 2>/dev/null

apache2ctl -M 2>/dev/null | grep -E 'autoindex|status|info|userdir|cgi|include|dav'
Result thresholds this check applies
ResultWhenWhat it means
PASSNone of the eight flagged modules is enabledNo high-risk optional modules are loaded.
WARNOne or more of them is enabledThe module names are listed. Disable any you do not use with a2dismod.
SKIPApache is not installed, or the module list could not be readTwo distinct skips. The first is the common one on an Nginx host, and it says so: Nginx compiles in only what it needs, so there is no equivalent list.

The check warns rather than fails because several of these modules have legitimate uses. mod_dav is the point of a WebDAV server, mod_cgi is the point of a legacy application. The finding is a prompt to confirm each name belongs, not an instruction to remove them all.

How to fix it

sudo a2dismod autoindex status info userdir
sudo systemctl reload apache2

If you need mod_status for monitoring, keep it and scope it:

<Location "/server-status">
    SetHandler server-status
    Require ip 127.0.0.1 ::1
</Location>

That combination, module enabled and access restricted, is also what turns the status endpoints check from a warning into a pass.

Bottom line: disabling a module you do not use is the rare hardening change with no trade-off. Work down the list one at a time with a reload and a smoke test between each, because the one you were sure nothing used is occasionally the one a legacy path depends on.

What this audit does not cover

This audit reads server configuration, file permissions and the enabled module list. It does not check:

  • HTTP response headers. HSTS, CSP, frame protection and cookie flags are the Security Headers audit.
  • TLS. Protocol versions, ciphers, certificate expiry and the HTTPS redirect are the TLS Configuration audit.
  • What the server exposes. Directory listing, dotfiles, backup files and status endpoints are the Content Exposure audit.
  • Versions and patching. Inventory, version disclosure, the worker user and pending updates are the Identification and Patching audit.
  • Logging. Access and error logs, permissions, rotation and WAF presence are the Logging and Monitoring audit.
  • Your application's own configuration. Framework settings, ORM behaviour and upload validation live inside the app, not in the web server config this audit reads.

The manual audit problem at scale

Six checks by hand is roughly fifteen minutes per host, and the slow part is not the commands. It is resolving which document roots are actually in use, which worker user the server really runs as, and which of three possible Apache config layouts this distribution uses.

On ten servers with a mix of Nginx and Apache, and a mix of Debian and RHEL layouts, that is most of an afternoon and a high chance of checking the wrong path on at least one host.

TaskBy hand, 8 hostsCtrlOps, 8 hosts
Run all 6 checksAbout 2 hoursAbout 90 seconds
Config layout handlingRemember which distro puts what whereResolved from the running server
Document root discoveryGrep and readExtracted from the effective config
Get a scored reportNot availableAutomatic
Re-audit after fixingAnother 2 hoursAnother 90 seconds

How CtrlOps runs all 6 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Configuration Hardening audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH 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 Configuration Hardening

Choose it from the Web Server category. All 6 checks are listed with their descriptions, severity levels and the estimated run time, about 11 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 files, directories and modules

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 most on this audit: a chmod or chown across a web root is the fastest way to take a working site offline, and the command you approve should name the path you expect.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the score move. Checks 1 and 2 are the ones to re-run after any deployment, because a release process that adjusts permissions is exactly what reintroduces them.

Conclusion

Hardening is the layer nobody revisits, because none of it announces itself. The world-writable config from a midnight fix, the web root owned by the worker user because a guide said so, the missing request cap that has never mattered because nobody has tried: each is invisible until it is the thing that made an incident worse than it needed to be.

Two of these six are HIGH severity and both describe write access that should not exist. The other four are refusals the server is not currently making. The CtrlOps Security Audit runs all 6 in about 11 seconds per server, resolving the config layout and the worker user from the running server rather than guessing.

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


Frequently asked questions

Configuration files should be owned by root and mode 644 at most, never world-writable. Private keys should be mode 600, owned by root. Both servers read their configuration and keys as the master process, which runs as root, so tightening them does not affect the worker processes that handle requests.

A deploy user, with the worker user holding read-only access through the group. If the worker owns the content, then any code execution bug in the application can write new files into a directory the same server will serve back. Give write access only to the specific paths that need it: uploads, cache and session directories.

Whatever your largest legitimate request needs, set globally as low as that allows, then raised per location for upload routes. 10 MB is a common global default. Avoid client_max_body_size 0, which removes the limit entirely, and note that this audit checks whether the directive exists rather than what value it holds, so a zero passes.

Set client_body_timeout and client_header_timeout on Nginx, or RequestReadTimeout header=20-40,MinRate=500 body=20,MinRate=500 on Apache with mod_reqtimeout. The MinRate form is better than a flat timeout, because it tolerates a genuinely slow connection while cutting off a client that is trickling bytes deliberately.

This audit flags eight: autoindex, status, info, userdir, cgi, include, dav and dav_fs. Disable the ones your site does not use with a2dismod, one at a time with a reload and a smoke test between each. If you need mod_status for monitoring, keep it and restrict it to localhost rather than removing it.

Because Nginx has no runtime module list to read in the same way. Modules are compiled in or loaded by explicit load_module directives, and the set is decided when the package is built rather than by what is enabled in a directory. The skip message says exactly that, and it is a correct result rather than a gap.

Because world-writable configs and world-readable keys are two branches of one check, evaluated in that order. If both are present, you see the writable configs first, and the readable keys appear on the next run once those are fixed. Check both by hand if you want the full picture in one pass.

No. It caps the rate of requests reaching your application from one source, which blunts credential stuffing and scraping. It does not know whether a request is a valid login attempt, and it cannot count failed attempts per account. Use both: limit_req at the edge, and account lockout or progressive delays in the application.

No. Request limits and timeouts reject work the server should never have accepted, which frees capacity. Disabling unused modules reduces the code in each request path. Rate limiting adds a small in-memory lookup per request. None of them affect a legitimate visitor on a normal request.

After every deployment that touches permissions or the web server config, and monthly on production regardless. Checks 1 and 2 are the ones tied to change, because release tooling is what rewrites ownership. The other four only change when somebody edits the config, which makes them a good post-change verification rather than a routine scan.

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