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
| Command | What 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 enabled —
sudo 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 running —
sudo 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 ownership —
philip:www-data, directories 755, files 644, upload dirs 775 only where needed.
- Config file protected —
config.php is either 640 or outside the web root entirely.
- Server version hidden —
ServerTokens Prod / server_tokens off / expose_php = Off all set.
- Directory listing disabled —
Options -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.