WEB SERVER HARDENING (APACHE AND NGINX)
Baseline settings for Apache httpd 2.4 and nginx in front of a web site: hide versions, refuse listings and dotfiles, cut unused methods, send security headers, use current TLS, cap request size and slow clients, and keep status pages private. Checked against the Apache 2.4 and nginx.org module documentation, the Mozilla Server Side TLS guidelines 6.0 and the OWASP Secure Headers Project (October 2026). Apache does NOT accept a comment on the same line as a directive, so its comments below sit on their own lines; nginx accepts both. Test every change before you reload.
TEST, THEN RELOAD#
apachectl configtest # Apache syntax check
apachectl graceful # reload without dropping requests
nginx -t # nginx syntax check
nginx -T # check and print the full config,
# including every included file
nginx -s reload # reload after a clean -t
curl -sI https://example.com/ # look at the response headers
HIDE VERSION DETAILS#
# Apache, server config only (not per virtual host). # Prod sends "Server: Apache"; the default Full adds version and OS. ServerTokens Prod # No version footer on error pages (Off is already the default). ServerSignature Off # nginx (http, server or location; default on) server_tokens off; # no version in "Server" or error pages # PHP, in php.ini: no "X-Powered-By: PHP/x.y" header expose_php = Off # If a backend still sends it, drop it at the web server: # Apache, mod_headers. Use both lines: PHP-FPM (mod_proxy_fcgi) and # CGI headers sit in the "always" table, others in the default one. Header always unset X-Powered-By Header unset X-Powered-By # nginx proxy_hide_header X-Powered-By; # in front of a proxied app fastcgi_hide_header X-Powered-By; # in front of PHP-FPM
NO LISTINGS, NO DOTFILES#
# Apache: deny the whole filesystem, then open only the docroot.
<Directory "/">
AllowOverride None
Require all denied
</Directory>
# Options -Indexes = no automatic directory listing (mod_autoindex).
# AllowOverride None = ignore .htaccess files unless you need them.
<Directory "/var/www/example">
Options -Indexes
AllowOverride None
Require all granted
</Directory>
# Refuse dotfiles (.env, .htpasswd). FilesMatch only sees the file
# name, so .git/config (name "config") needs the DirectoryMatch too;
# the lookahead keeps /.well-known/ (ACME, security.txt) reachable.
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.(?!well-known/)">
Require all denied
</DirectoryMatch>
# nginx
autoindex off; # the default; never turn it on for a site
location ~ /\.(?!well-known/) { deny all; } # keeps /.well-known/ (ACME)
ONLY THE METHODS YOU USE#
# Apache: TRACE then gets 405 (the default is TraceEnable on)
TraceEnable Off
# Inside the site's <Directory> or <Location> block:
<LimitExcept GET POST HEAD>
Require all denied
</LimitExcept>
# nginx, inside a location (allowing GET also allows HEAD)
limit_except GET POST { deny all; }
SECURITY HEADERS#
# Values from the OWASP Secure Headers Project. Start strict, then # loosen per application (a CSP usually needs adjusting). # Apache: "always" also covers error and redirect responses. Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "deny" Header always set Referrer-Policy "no-referrer" Header always set Content-Security-Policy "default-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests" Header always set Cross-Origin-Opener-Policy "same-origin" # nginx: "always" adds the header whatever the response code (1.7.5+) add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "deny" always; add_header Referrer-Policy "no-referrer" always; # (add the CSP and COOP lines the same way) # nginx trap: a location with ANY add_header of its own inherits NONE # of the server-level ones. Repeat them there, or on nginx 1.29.3+ # put this in that location: add_header_inherit merge; # also keep the parent level's headers # HSTS: browsers ignore it over plain HTTP (RFC 6797). Use # includeSubDomains only when every subdomain serves HTTPS.
TLS (MOZILLA "INTERMEDIATE", GUIDELINES 6.0)#
# TLS 1.2 + 1.3. "Modern" = TLS 1.3 only, if all clients support it. # Apache, mod_ssl. Let the client choose the cipher (Mozilla sets # no server preference; Off is also the Apache default). SSLProtocol -all +TLSv1.2 +TLSv1.3 SSLHonorCipherOrder off SSLCipherSuite <TLS 1.2 list below, joined with ":"> # nginx ssl_protocols TLSv1.2 TLSv1.3; # also the default since nginx 1.23.4 ssl_prefer_server_ciphers off; # the default ssl_ciphers <TLS 1.2 list below, joined with ":">; # TLS 1.2 cipher list (TLS 1.3 suites need no setting): # 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 # Key exchange groups in Mozilla's order: X25519MLKEM768 (hybrid # post-quantum, only if your TLS library supports it), X25519, # prime256v1, secp384r1. nginx (default auto): ssl_ecdh_curve X25519:prime256v1:secp384r1; # Then test from outside: see the OpenSSL TLS Checks sheet.
REQUEST SIZE AND SLOW CLIENTS#
# Apache: 10 MiB body cap (default 1073741824 bytes = 1 GiB) LimitRequestBody 10485760 # mod_reqtimeout, stricter than its default of # handshake=0 header=20-40,MinRate=500 body=20,MinRate=500 RequestReadTimeout handshake=5 header=10 body=30 # 5 seconds is the default; keep keep-alive short KeepAliveTimeout 5 # nginx (defaults in brackets) client_max_body_size 10m; # [1m] larger bodies get 413 client_header_timeout 10s; # [60s] client_body_timeout 10s; # [60s] keepalive_timeout 15s; # [75s] # (nginx -T shows if the distro's nginx.conf already sets one) # nginx rate limit per client IP limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; # http limit_req zone=perip burst=20 nodelay; # server or location limit_req_status 429; # [503]
STATUS PAGES STAY PRIVATE#
# Apache mod_status (with ExtendedStatus On it also shows the
# requests being served and the clients making them)
<Location "/server-status">
SetHandler server-status
Require ip 127.0.0.1 ::1
</Location>
# nginx
location = /basic_status {
stub_status;
allow 127.0.0.1;
deny all;
}
CHECK FROM OUTSIDE#
curl -sI https://example.com/ | grep -iE '^(server|x-powered-by):'
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.git/config
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.env
# expect 403 or 404 for both, never 200
curl -s -o /dev/null -w '%{http_code}\n' -X TRACE https://example.com/
# expect 405 on Apache with TraceEnable Off
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/server-status
# expect 403 or 404 from outside
WEB SERVER HARDENING (APACHE AND NGINX)
=======================================
Baseline settings for Apache httpd 2.4 and nginx in front of a web
site: hide versions, refuse listings and dotfiles, cut unused methods,
send security headers, use current TLS, cap request size and slow
clients, and keep status pages private. Checked against the Apache 2.4
and nginx.org module documentation, the Mozilla Server Side TLS
guidelines 6.0 and the OWASP Secure Headers Project (October 2026).
Apache does NOT accept a comment on the same line as a directive, so
its comments below sit on their own lines; nginx accepts both.
Test every change before you reload.
TEST, THEN RELOAD
-----------------
apachectl configtest # Apache syntax check
apachectl graceful # reload without dropping requests
nginx -t # nginx syntax check
nginx -T # check and print the full config,
# including every included file
nginx -s reload # reload after a clean -t
curl -sI https://example.com/ # look at the response headers
HIDE VERSION DETAILS
--------------------
# Apache, server config only (not per virtual host).
# Prod sends "Server: Apache"; the default Full adds version and OS.
ServerTokens Prod
# No version footer on error pages (Off is already the default).
ServerSignature Off
# nginx (http, server or location; default on)
server_tokens off; # no version in "Server" or error pages
# PHP, in php.ini: no "X-Powered-By: PHP/x.y" header
expose_php = Off
# If a backend still sends it, drop it at the web server:
# Apache, mod_headers. Use both lines: PHP-FPM (mod_proxy_fcgi) and
# CGI headers sit in the "always" table, others in the default one.
Header always unset X-Powered-By
Header unset X-Powered-By
# nginx
proxy_hide_header X-Powered-By; # in front of a proxied app
fastcgi_hide_header X-Powered-By; # in front of PHP-FPM
NO LISTINGS, NO DOTFILES
------------------------
# Apache: deny the whole filesystem, then open only the docroot.
<Directory "/">
AllowOverride None
Require all denied
</Directory>
# Options -Indexes = no automatic directory listing (mod_autoindex).
# AllowOverride None = ignore .htaccess files unless you need them.
<Directory "/var/www/example">
Options -Indexes
AllowOverride None
Require all granted
</Directory>
# Refuse dotfiles (.env, .htpasswd). FilesMatch only sees the file
# name, so .git/config (name "config") needs the DirectoryMatch too;
# the lookahead keeps /.well-known/ (ACME, security.txt) reachable.
<FilesMatch "^\.">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.(?!well-known/)">
Require all denied
</DirectoryMatch>
# nginx
autoindex off; # the default; never turn it on for a site
location ~ /\.(?!well-known/) { deny all; } # keeps /.well-known/ (ACME)
ONLY THE METHODS YOU USE
------------------------
# Apache: TRACE then gets 405 (the default is TraceEnable on)
TraceEnable Off
# Inside the site's <Directory> or <Location> block:
<LimitExcept GET POST HEAD>
Require all denied
</LimitExcept>
# nginx, inside a location (allowing GET also allows HEAD)
limit_except GET POST { deny all; }
SECURITY HEADERS
----------------
# Values from the OWASP Secure Headers Project. Start strict, then
# loosen per application (a CSP usually needs adjusting).
# Apache: "always" also covers error and redirect responses.
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "deny"
Header always set Referrer-Policy "no-referrer"
Header always set Content-Security-Policy "default-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
Header always set Cross-Origin-Opener-Policy "same-origin"
# nginx: "always" adds the header whatever the response code (1.7.5+)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "deny" always;
add_header Referrer-Policy "no-referrer" always;
# (add the CSP and COOP lines the same way)
# nginx trap: a location with ANY add_header of its own inherits NONE
# of the server-level ones. Repeat them there, or on nginx 1.29.3+
# put this in that location:
add_header_inherit merge; # also keep the parent level's headers
# HSTS: browsers ignore it over plain HTTP (RFC 6797). Use
# includeSubDomains only when every subdomain serves HTTPS.
TLS (MOZILLA "INTERMEDIATE", GUIDELINES 6.0)
--------------------------------------------
# TLS 1.2 + 1.3. "Modern" = TLS 1.3 only, if all clients support it.
# Apache, mod_ssl. Let the client choose the cipher (Mozilla sets
# no server preference; Off is also the Apache default).
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder off
SSLCipherSuite <TLS 1.2 list below, joined with ":">
# nginx
ssl_protocols TLSv1.2 TLSv1.3; # also the default since nginx 1.23.4
ssl_prefer_server_ciphers off; # the default
ssl_ciphers <TLS 1.2 list below, joined with ":">;
# TLS 1.2 cipher list (TLS 1.3 suites need no setting):
# 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
# Key exchange groups in Mozilla's order: X25519MLKEM768 (hybrid
# post-quantum, only if your TLS library supports it), X25519,
# prime256v1, secp384r1. nginx (default auto):
ssl_ecdh_curve X25519:prime256v1:secp384r1;
# Then test from outside: see the OpenSSL TLS Checks sheet.
REQUEST SIZE AND SLOW CLIENTS
-----------------------------
# Apache: 10 MiB body cap (default 1073741824 bytes = 1 GiB)
LimitRequestBody 10485760
# mod_reqtimeout, stricter than its default of
# handshake=0 header=20-40,MinRate=500 body=20,MinRate=500
RequestReadTimeout handshake=5 header=10 body=30
# 5 seconds is the default; keep keep-alive short
KeepAliveTimeout 5
# nginx (defaults in brackets)
client_max_body_size 10m; # [1m] larger bodies get 413
client_header_timeout 10s; # [60s]
client_body_timeout 10s; # [60s]
keepalive_timeout 15s; # [75s]
# (nginx -T shows if the distro's nginx.conf already sets one)
# nginx rate limit per client IP
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s; # http
limit_req zone=perip burst=20 nodelay; # server or location
limit_req_status 429; # [503]
STATUS PAGES STAY PRIVATE
-------------------------
# Apache mod_status (with ExtendedStatus On it also shows the
# requests being served and the clients making them)
<Location "/server-status">
SetHandler server-status
Require ip 127.0.0.1 ::1
</Location>
# nginx
location = /basic_status {
stub_status;
allow 127.0.0.1;
deny all;
}
CHECK FROM OUTSIDE
------------------
curl -sI https://example.com/ | grep -iE '^(server|x-powered-by):'
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.git/config
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.env
# expect 403 or 404 for both, never 200
curl -s -o /dev/null -w '%{http_code}\n' -X TRACE https://example.com/
# expect 405 on Apache with TraceEnable Off
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/server-status
# expect 403 or 404 from outside
Defensive reference on CyberRamen. Offensive / red-team sheets live on OffensiveRamen.com.