The default Apache and Nginx configurations on Ubuntu are built to work out of the box, not to expose as little as possible. A few changes reduce what an attacker can learn about your server and limit the damage of common attacks: hiding version numbers, turning off directory listings, rejecting unknown host names, enforcing modern TLS, sending security headers and banning clients that brute force your login pages. This tutorial applies these changes on Ubuntu 24.04, with the Apache and Nginx variant of each step side by side, so you can follow only the one you use.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Apache (
apache2) or Nginx installed from the Ubuntu repositories and serving at least one site. - A domain name (
your_domainin the examples) pointing to the server, and a TLS certificate from Let's Encrypt. If you don't have one yet, install Certbot withsudo apt install certbot python3-certbot-nginx(orpython3-certbot-apache) and runsudo certbot --nginx(or--apache). - UFW allowing only the ports you need, for example
sudo ufw allow OpenSSHandsudo ufw allow "Nginx Full"orsudo ufw allow "Apache Full".
Before each reload, always run the configuration test for your server (sudo nginx -t or sudo apache2ctl configtest). A reload with a broken configuration is refused, but a restart takes the site down.
Step 1 - Keeping the web server updated
Most real-world compromises exploit known, already fixed vulnerabilities. Ubuntu 24.04 installs unattended-upgrades by default, which applies security updates daily. Confirm it is active:
systemctl status unattended-upgrades --no-pager
cat /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
If the file is missing, enable automatic security updates with sudo dpkg-reconfigure -plow unattended-upgrades. Security fixes for nginx and apache2 are then installed and the services restarted automatically.
Step 2 - Hiding version information
By default both servers advertise their version in the Server header and on error pages, which tells attackers exactly which vulnerabilities to try.
Nginx
Open the main configuration:
sudo nano /etc/nginx/nginx.conf
In the http block, find the commented line # server_tokens off; and uncomment it:
http {
...
server_tokens off;
...
}
Apache
Ubuntu keeps these settings in a separate file:
sudo nano /etc/apache2/conf-available/security.conf
Set the following values (the file already contains them with less strict defaults):
ServerTokens Prod
ServerSignature Off
TraceEnable Off
ServerTokens Prod reduces the header to Server: Apache, ServerSignature Off removes the version footer from error pages and TraceEnable Off disables the HTTP TRACE method.
Test and reload the server you use:
sudo nginx -t && sudo systemctl reload nginx
sudo apache2ctl configtest && sudo systemctl reload apache2
Verify the header:
curl -sI https://your_domain/ | grep -i '^server'
Server: nginx
Step 3 - Disabling directory listing and unused modules
Apache
The default <Directory /var/www/> block enables Indexes, so any directory without an index.html shows its contents. Edit /etc/apache2/apache2.conf:
sudo nano /etc/apache2/apache2.conf
Remove Indexes from the Options line:
<Directory /var/www/>
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
Then disable modules you don't use. autoindex generates directory listings and status exposes server statistics; a2dismod needs -f for autoindex because Debian marks it as essential:
sudo a2dismod -f autoindex status
sudo apache2ctl configtest && sudo systemctl restart apache2
List what remains loaded with sudo apache2ctl -M and disable anything else your sites don't need (for example cgi or userdir if you enabled them earlier).
Nginx
Nginx does not list directories unless autoindex on; is set. Check that no site enables it:
grep -rn autoindex /etc/nginx/
Nginx modules on Ubuntu are either compiled in or loaded from /etc/nginx/modules-enabled/. Remove the symlinks there for dynamic modules you don't use, such as 50-mod-http-image-filter.conf or 50-mod-mail.conf if they are present.
Verify from the outside that a directory without an index file is not listed:
sudo mkdir /var/www/html/listing-test
curl -I https://your_domain/listing-test/
sudo rmdir /var/www/html/listing-test
HTTP/1.1 403 Forbidden
Step 4 - Rejecting requests for unknown host names
Bots scan IP addresses rather than domains, so requests with an unknown or missing Host header should never reach your application.
Nginx
Create a catch-all server that closes the connection without a response (status 444 is Nginx specific and means exactly that):
sudo nano /etc/nginx/sites-available/000-default-deny
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
Enable it and remove the stock default site, which also uses default_server:
sudo rm /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/000-default-deny /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
For HTTPS you can add listen 443 ssl default_server; with ssl_reject_handshake on; (Nginx 1.19.4 and later) to the same block, which rejects TLS handshakes for unknown names without needing a certificate.
Apache
Apache uses the first virtual host loaded as the default. Ubuntu's 000-default.conf serves /var/www/html to anyone who connects by IP. Edit it so it answers with 403 instead:
sudo nano /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
ServerName default.invalid
<Location />
Require all denied
</Location>
</VirtualHost>
sudo apache2ctl configtest && sudo systemctl reload apache2
Test by requesting the server by IP address. Replace your_server_ip with the public IP:
curl -I http://your_server_ip/
With Nginx, curl reports Empty reply from server; with Apache you get 403 Forbidden. Requests using your_domain still work.
Step 5 - Limiting request size and timeouts
Small limits make it harder to exhaust memory or keep connections open with slow requests.
Nginx
Add the limits to the http block of /etc/nginx/nginx.conf, or to a single server block if only one site needs different values:
client_max_body_size 10m;
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 15s;
keepalive_timeout 30s;
client_max_body_size caps uploads (the default is 1 MB, so raise it only as far as your application needs). Requests over the limit get 413 Request Entity Too Large.
Apache
Create a small configuration file:
sudo nano /etc/apache2/conf-available/limits.conf
Timeout 30
LimitRequestBody 10485760
Timeout defaults to 300 seconds on Ubuntu, which is generous for slow clients. LimitRequestBody is in bytes (10 MB here). Enable the file:
sudo a2enconf limits
sudo apache2ctl configtest && sudo systemctl reload apache2
Apache also ships mod_reqtimeout, enabled by default, which already protects against slow header attacks such as Slowloris.
Step 6 - Enforcing modern TLS
Allow only TLS 1.2 and 1.3 and redirect all HTTP traffic to HTTPS. If you used Certbot, the redirect and a good cipher list are already in place (/etc/letsencrypt/options-ssl-nginx.conf or options-ssl-apache.conf); you only need to check the protocol list.
Nginx
The ssl_protocols directive in the http block of /etc/nginx/nginx.conf applies to every site:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
Then add HTTP Strict Transport Security to the HTTPS server block of your site, so browsers refuse to connect over plain HTTP for a year:
add_header Strict-Transport-Security "max-age=31536000" always;
Apache
Edit /etc/apache2/mods-available/ssl.conf and set:
SSLProtocol -all +TLSv1.2 +TLSv1.3
Add HSTS inside the <VirtualHost *:443> block of your site (requires sudo a2enmod headers):
Header always set Strict-Transport-Security "max-age=31536000"
ImportantOnly enable HSTS when every subdomain you care about works over HTTPS. Browsers cache the policy for the whole
max-ageand you cannot undo it from the server side.
Reload the server and verify that TLS 1.2 and TLS 1.3 handshakes succeed:
openssl s_client -connect your_domain:443 -servername your_domain -tls1_2 </dev/null 2>/dev/null | grep -E '^New,'
openssl s_client -connect your_domain:443 -servername your_domain -tls1_3 </dev/null 2>/dev/null | grep -E '^New,'
New, TLSv1.2, Cipher is ECDHE-RSA-AES256-GCM-SHA384
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
The OpenSSL client on Ubuntu 24.04 refuses TLS 1.0 and 1.1 on its own, so it cannot prove that the server rejects them. For that, and for a full report on ciphers and certificate chain, run your domain through the Qualys SSL Labs server test.
Step 7 - Adding security headers
These headers tell browsers to block MIME sniffing, refuse to render your pages inside frames on other sites and limit the referrer information sent to third parties.
Nginx
Add them to the server block of your site:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
Note
add_headerdirectives are inherited from the enclosing block only if the current block has none of its own. If alocationblock contains anyadd_header, repeat the security headers there too.
Apache
Enable mod_headers and add the headers to /etc/apache2/conf-available/security.conf, which is enabled by default and applies to every site:
sudo a2enmod headers
sudo nano /etc/apache2/conf-available/security.conf
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
A Content-Security-Policy header gives the strongest protection against cross-site scripting, but it has to be written for each application. Start with Content-Security-Policy-Report-Only to see what would break before enforcing it.
Reload and check:
curl -sI https://your_domain/ | grep -iE 'strict-transport|x-content|x-frame|referrer'
strict-transport-security: max-age=31536000
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
referrer-policy: strict-origin-when-cross-origin
Step 8 - Checking the process user and file permissions
On Ubuntu both servers start as root only to bind ports 80 and 443, then run their worker processes as the unprivileged www-data user. Confirm it:
ps -eo user,comm | grep -E 'nginx|apache2' | sort | uniq -c
1 root nginx
2 www-data nginx
The web server only needs to read your site files, not write them. Keep the files owned by your deploy user and readable by everyone, and give www-data write access only to the directories that truly need it, such as an upload folder:
sudo chown -R your_user:your_user /var/www/your_domain
sudo find /var/www/your_domain -type d -exec chmod 755 {} +
sudo find /var/www/your_domain -type f -exec chmod 644 {} +
sudo chown -R www-data:www-data /var/www/your_domain/uploads
With this layout, a vulnerability in your application cannot modify its own code. Never use chmod 777 to fix a permission problem.
Step 9 - Banning abusive clients with Fail2Ban
Fail2Ban reads log files and temporarily bans IP addresses that fail authentication repeatedly. It ships ready-made filters for both servers.
sudo apt install fail2ban
Create a local configuration file so package updates don't overwrite your changes:
sudo nano /etc/fail2ban/jail.d/web.local
For Nginx:
[nginx-http-auth]
enabled = true
backend = auto
maxretry = 5
findtime = 10m
bantime = 1h
For Apache, use the apache-auth jail with the same options instead:
[apache-auth]
enabled = true
backend = auto
maxretry = 5
findtime = 10m
bantime = 1h
backend = auto makes the jail read the log files in /var/log/nginx/ or /var/log/apache2/, which is where these filters look for failed Basic Auth attempts. Restart Fail2Ban and check the jail:
sudo systemctl restart fail2ban
sudo fail2ban-client status nginx-http-auth
Status for the jail: nginx-http-auth
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
| `- File list: /var/log/nginx/error.log
`- Actions
|- Currently banned: 0
|- Total banned: 0
`- Banned IP list:
To also slow down floods of requests, configure rate limiting in Nginx with limit_req (see the CubePath guide on rate limiting in Nginx) and enable the nginx-limit-req jail in the same way.
Troubleshooting
The site returns 403 Forbidden after changing permissions. www-data must be able to traverse every parent directory. Check the path with namei -l /var/www/your_domain/index.html; each directory needs the x bit for others (755).
nginx -t reports duplicate default server. Another enabled site still uses default_server on the same port. Search with grep -rn default_server /etc/nginx/sites-enabled/ and remove the extra flag.
Fail2Ban jail does not start. Run sudo fail2ban-client -d to print the parsed configuration, and sudo journalctl -u fail2ban -n 50 to see the error. A jail whose log file does not exist yet will fail; make sure the web server has written to it at least once.
Security headers missing on some pages. In Nginx this is almost always the add_header inheritance rule described in Step 7. In Apache, check that the application does not send the headers itself with a different value.
Conclusion
Your Apache or Nginx server now hides its version, refuses directory listings and unknown host names, limits request sizes, accepts only TLS 1.2 and 1.3, sends security headers and bans clients that brute force protected areas. Next, consider adding a web application firewall such as ModSecurity with the OWASP Core Rule Set, writing a Content-Security-Policy for your application, and centralising your web server logs so suspicious activity is easy to spot.
