Even More Security

Chapter 6 — Firewall & Security Basics

A working web stack is also an attack surface. This chapter covers the essential hardening steps for a Debian 12 Bookworm server: controlling inbound traffic with UFW, banning brute-force attackers automatically with fail2ban, setting correct file system permissions on your web root, and stripping the information leaks that tell scanners exactly what software you're running.

Scope: This chapter covers server-level hardening — firewall rules, process-level banning, filesystem permissions, and HTTP header hygiene. SSL/HTTPS (encrypting traffic) is covered in Chapter 5. Application-level security (SQL injection, XSS, CSRF, etc.) is a separate topic covered in the Securing Your Web Server course.

1. UFW — Uncomplicated Firewall

UFW is a frontend for iptables that makes managing firewall rules readable. It's not installed by default on Debian 12 but is in the main repository. The principle is simple: deny everything by default, then explicitly allow only the ports you need.

Allow SSH before enabling UFW. If you enable UFW without allowing SSH first you will lock yourself out of a remote machine immediately. This must be the first rule you add.

Install and configure UFW

philip@debian — UFW setup
philip@debian:~$ sudo apt install -y ufw philip@debian:~$ sudo ufw default deny incoming Default incoming policy changed to 'deny' philip@debian:~$ sudo ufw default allow outgoing Default outgoing policy changed to 'allow' # Allow SSH first — do this before enabling the firewall philip@debian:~$ sudo ufw allow ssh Rules updated Rules updated (v6) philip@debian:~$ sudo ufw allow http philip@debian:~$ sudo ufw allow https # Enable the firewall philip@debian:~$ sudo ufw enable Command may disrupt existing ssh connections. Proceed with operation (y|n)? y Firewall is active and enabled on system startup philip@debian:~$ sudo ufw status verbose Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) To Action From -- ------ ---- 22/tcp ALLOW IN Anywhere 80/tcp ALLOW IN Anywhere 443/tcp ALLOW IN Anywhere

Rule reference

Command Effect Action
sudo ufw allow ssh Opens port 22 (SSH) ALLOW
sudo ufw allow http Opens port 80 (HTTP) ALLOW
sudo ufw allow https Opens port 443 (HTTPS) ALLOW
sudo ufw allow 3306 Opens MySQL port — only if remote DB access needed ALLOW
sudo ufw deny 3306 Blocks MySQL from the internet (correct default) DENY
sudo ufw allow from 192.168.1.0/24 Allow all traffic from local LAN only ALLOW
sudo ufw allow from 192.168.1.50 to any port 22 Allow SSH from one specific IP only ALLOW
sudo ufw delete allow http Remove a previously added rule REMOVE
sudo ufw disable Turn off firewall (all traffic passes through) OFF
sudo ufw reload Reload rules without disabling RELOAD
MySQL firewall rule: MariaDB and MySQL bind to 127.0.0.1 by default, so they're unreachable from outside even without a UFW rule. Only add a rule for port 3306 if you specifically need remote database access — and if you do, restrict it to a known IP address, not Anywhere.

2. fail2ban — Automatic Brute-Force Protection

fail2ban watches log files for repeated failed authentication attempts and temporarily bans the offending IP address using iptables. Without it, a server exposed to the internet will receive thousands of SSH and HTTP brute-force attempts per day from automated scanners.

Install fail2ban

philip@debian — install fail2ban
philip@debian:~$ sudo apt install -y fail2ban Setting up fail2ban (1.0.2-2) ... philip@debian:~$ sudo systemctl enable --now fail2ban philip@debian:~$ sudo systemctl status fail2ban ● fail2ban.service - Fail2Ban Service Loaded: loaded (/lib/systemd/system/fail2ban.service; enabled) Active: active (running)

Configure a local jail

fail2ban reads its main config from /etc/fail2ban/jail.conf, but you must never edit that file directly — it gets overwritten on package updates. Instead, create a local override file:

philip@debian — create jail.local
philip@debian:~$ sudo vi /etc/fail2ban/jail.local
# /etc/fail2ban/jail.local [DEFAULT] # Ban for 1 hour after 5 failed attempts within 10 minutes bantime = 3600 findtime = 600 maxretry = 5 # Your own IP — never ban yourself ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 # ── SSH protection ──────────────────────────────────────── [sshd] enabled = true port = ssh logpath = %(sshd_log)s backend = %(sshd_backend)s maxretry = 3 # ── Apache: block rapid 404 scanning ───────────────────── [apache-noscript] enabled = true [apache-overflows] enabled = true # ── Apache: auth failures (htpasswd protected areas) ───── [apache-auth] enabled = true # ── Nginx equivalents (uncomment if using Nginx) ────────── # [nginx-http-auth] # enabled = true # # [nginx-noscript] # enabled = true
philip@debian — reload and verify
philip@debian:~$ sudo systemctl restart fail2ban philip@debian:~$ sudo fail2ban-client status Status |- Number of jail: 4 `- Jail list: apache-auth, apache-noscript, apache-overflows, sshd philip@debian:~$ sudo fail2ban-client status sshd Status for the jail: sshd |- Filter | |- Currently failed: 0 | `- Total failed: 0 `- Actions |- Currently banned: 0 `- Total banned: 0

Useful fail2ban commands

CommandWhat it does
sudo fail2ban-client status List all active jails
sudo fail2ban-client status sshd Show failed attempts and banned IPs for the SSH jail
sudo fail2ban-client set sshd unbanip 1.2.3.4 Manually unban an IP address
sudo fail2ban-client banned List all currently banned IPs across all jails
sudo tail -f /var/log/fail2ban.log Watch banning activity in real time

3. File System Permissions

Incorrect permissions on your web root are one of the most common security mistakes on self-hosted servers. The goal is: Apache/Nginx can read your files, PHP-FPM can write to specific upload directories, but the web server process can never write to your PHP source files.

The ownership model

Web root owner
philip:www-data
You own the files; the web server group can read them.
Directories
755
Owner: rwx · Group: r-x · Other: r-x
PHP / HTML files
644
Owner: rw- · Group: r-- · Other: r--
Upload / cache directories
775 (www-data writable)
Only directories PHP needs to write to. Not the whole web root.
Never use 777. World-writable files let any process on the system modify your PHP source code. If your shared host or tutorial suggests 777, it's wrong. The correct permission is 775 on specific writable directories, and only when the web server group is www-data.

Applying correct permissions

philip@debian — set web root permissions
# Set ownership: you own everything, www-data is the group philip@debian:~$ sudo chown -R philip:www-data /var/www/osztromok.com/public_html # Directories: 755 — traversable by the web server, not writable philip@debian:~$ sudo find /var/www/osztromok.com/public_html -type d -exec chmod 755 {} \; # Files: 644 — readable by web server, writable only by you philip@debian:~$ sudo find /var/www/osztromok.com/public_html -type f -exec chmod 644 {} \; # Upload directory (if it exists): writable by www-data philip@debian:~$ sudo chmod 775 /var/www/osztromok.com/public_html/uploads # Verify philip@debian:~$ ls -la /var/www/osztromok.com/public_html/ drwxr-xr-x philip www-data admin/ drwxrwxr-x philip www-data uploads/ -rw-r--r-- philip www-data index.php

Configuration file protection

Your database credentials live in a PHP config file. That file should never be readable by anyone except you and should never be accessible via the browser.

philip@debian — protect config file
# Lock the config file so only you can read it philip@debian:~$ chmod 600 /var/www/osztromok.com/public_html/includes/config.php # Better: move config outside the web root entirely philip@debian:~$ sudo mkdir -p /etc/osztromok philip@debian:~$ sudo mv /var/www/osztromok.com/public_html/includes/config.php /etc/osztromok/config.php philip@debian:~$ sudo chmod 640 /etc/osztromok/config.php philip@debian:~$ sudo chown philip:www-data /etc/osztromok/config.php
Moving config outside the web root means even if a misconfiguration causes Apache to serve raw PHP files as text, your database password is never exposed — it's in /etc/osztromok/, not inside public_html. Update the require path in your PHP files to require '/etc/osztromok/config.php';.

4. Hiding Server Information

By default, Apache and Nginx announce their version numbers in HTTP response headers and error pages. This tells automated scanners exactly which software to target. Removing this information is one of the fastest wins in server hardening.

Apache — hide version

philip@debian — Apache security.conf
philip@debian:~$ sudo vi /etc/apache2/conf-available/security.conf

Find and set (or add) these two directives:

# /etc/apache2/conf-available/security.conf # Show only "Apache" — no version or OS info ServerTokens Prod # Suppress the "Apache/2.4.57 (Debian)" line in error pages ServerSignature Off
philip@debian — enable config and reload
philip@debian:~$ sudo a2enconf security philip@debian:~$ sudo systemctl reload apache2 # Verify: Server header should now show only "Apache" philip@debian:~$ curl -I http://localhost | grep -i server Server: Apache

Nginx — hide version

Add one directive to the http {} block in nginx.conf:

philip@debian — nginx.conf
philip@debian:~$ sudo vi /etc/nginx/nginx.conf
# Inside the http { } block in nginx.conf http { server_tokens off; # Hides "nginx/1.22.1" from Server header # ... rest of your config }
philip@debian — reload nginx
philip@debian:~$ sudo nginx -t && sudo systemctl reload nginx nginx: configuration file syntax is ok nginx: configuration file test is successful philip@debian:~$ curl -I http://localhost | grep -i server Server: nginx

PHP — hide version from HTTP headers

PHP adds an X-Powered-By: PHP/8.2.x header to every response unless you turn it off. Edit php.ini for your web server SAPI:

# /etc/php/8.2/apache2/php.ini (or /etc/php/8.2/fpm/php.ini for Nginx) expose_php = Off
philip@debian — restart to apply
philip@debian:~$ sudo systemctl restart apache2 # or for Nginx: philip@debian:~$ sudo systemctl restart php8.2-fpm # Verify — X-Powered-By should be gone philip@debian:~$ curl -I http://localhost | grep -i powered (no output — header removed)

5. Additional Quick Wins

Directory Listing
Apache: add Options -Indexes to your virtual host. Nginx: remove autoindex on (off by default). Prevents browsing directories with no index file.
Hidden Files Exposed
Block .git, .env, and .htaccess files from being served. Apache: Options FollowSymLinks + Require all denied in a <FilesMatch> block.
Unused Apache Modules
Disable modules you don't need: sudo a2dismod status autoindex. Each loaded module is potential attack surface and adds memory overhead.
SSH Key Auth Only
Disable password-based SSH login. Set PasswordAuthentication no in /etc/ssh/sshd_config after confirming key login works.

Block directory listing and dot-files (Apache)

# Add inside your <VirtualHost> block or <Directory> directive Options -Indexes FollowSymLinks # Block access to hidden files (.git, .env, .htaccess, etc.) <FilesMatch "(^\.|\.(env|git|sql|log|bak|swp)$)"> Require all denied </FilesMatch>

SSH hardening (sshd_config)

philip@debian — sshd_config
philip@debian:~$ sudo vi /etc/ssh/sshd_config
# /etc/ssh/sshd_config — key settings to review # Only allow your user to log in via SSH AllowUsers philip # Disable root login entirely PermitRootLogin no # Disable password auth once SSH key is set up PasswordAuthentication no # Timeout idle sessions after 10 minutes ClientAliveInterval 300 ClientAliveCountMax 2
philip@debian — reload SSH daemon
philip@debian:~$ sudo sshd -t (no output = config is valid) philip@debian:~$ sudo systemctl reload sshd
Confirm key-based login works before disabling password auth. Open a second SSH session and confirm you can log in with your key while the first session is still open. Only then reload sshd. If you lock yourself out of a remote machine you'll need physical console access to fix it.

Security Checklist

  • UFW enabledsudo ufw status verbose shows SSH, HTTP, HTTPS allowed, everything else denied.
  • MySQL/MariaDB port blocked — port 3306 not open to the internet unless remote access is required.
  • fail2ban runningsudo fail2ban-client status lists SSH and Apache/Nginx jails as active.
  • jail.local in place — never edited jail.conf directly; custom settings survive package updates.
  • Web root ownershipphilip:www-data, directories 755, files 644, upload dirs 775 only where needed.
  • Config file protectedconfig.php is either 640 or outside the web root entirely.
  • Server version hiddenServerTokens Prod / server_tokens off / expose_php = Off all set.
  • Directory listing disabledOptions -Indexes prevents browsing empty directories.
  • Dot-files blocked.env, .git, .sql files cannot be fetched via the browser.
  • SSH hardened — root login disabled, ideally password auth off and key auth confirmed working.
Next — Chapter 7: XAMPP. The final chapter covers XAMPP — the all-in-one Apache + MariaDB + PHP installer. It explains when XAMPP makes sense (local development, quick testing), how to install it on Debian, its directory structure, and the honest trade-offs versus the manual stack built in Chapters 1–6.