Apache tuning starts with how the server handles requests, runs PHP and serves static files. I check those boundaries before raising worker limits or adding modules. The steps below cover the settings I review, how to check them and where the site’s own configuration needs to take precedence.
Swap mod_php for PHP-FPM and the event MPM
The old prefork model starts a heavy process for every connection, and mod_php loads PHP into all of them, even the ones serving images. The event MPM with PHP-FPM handles far more visitors on the same memory:
sudo apt install php8.3-fpm
sudo a2dismod php8.3 mpm_prefork
sudo a2enmod mpm_event proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
sudo apachectl configtest && sudo systemctl restart apache2Use your installed PHP version in place of 8.3. Check with apachectl -V | grep MPM afterwards.
Turn on HTTP/2 and Brotli
sudo a2enmod http2 brotli headers expires
sudo systemctl reload apache2Protocols h2 http/1.1
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/css text/plain \
application/javascript application/json image/svg+xml
</IfModule>HTTP/2 needs the event or worker MPM, which is one more reason to make the switch above.
Cache static files properly
If your CSS and JavaScript URLs change when the files change, such as style.css?v=42 or a hashed filename, browsers can keep them for a year:
<IfModule mod_headers.c>
<FilesMatch "\.(css|js|svg|woff2|png|jpe?g|webp|avif)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>Only do this for versioned assets. If you edit a file and the URL stays the same, visitors will keep the old one for a year.
See real visitor addresses behind Cloudflare
Behind a proxy, Apache logs the proxy’s address for every visitor. mod_remoteip fixes that, but trust the header only from the proxy itself, or anyone can fake their address:
RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ...one line for each range at https://www.cloudflare.com/ips/
# Log the real address
LogFormat "%a %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combinedsudo a2enmod remoteip && sudo apachectl configtest && sudo apachectl gracefulSay less about yourself
# /etc/apache2/conf-available/security.conf
ServerTokens Prod
ServerSignature Off
TraceEnable OffNever serve what should stay private
Deploying from Git can leave scripts, notes, dumps and the .git folder sitting in the web root. Refuse them outright:
<FilesMatch "\.(sql|bak|env|log|sh|yml|yaml|md|dist)$|^(composer|package)\.(json|lock)$">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.git">
Require all denied
</DirectoryMatch>Skip .htaccess when you own the config
With AllowOverride All, Apache checks for an .htaccess file in every folder on every request. If you control the virtual host, move the rules there and switch lookups off:
<Directory /var/www/example>
AllowOverride None
Require all granted
# WordPress permalinks, moved from .htaccess
RewriteEngine On
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</Directory>Test before you reload, every time
sudo apachectl configtest && sudo apachectl graceful
# What is loaded, and which site answers which name
apachectl -M
apachectl -SA graceful reload lets current visitors finish while new requests get the new config. A failed config test means nothing changes, which is exactly what you want.