chunkybastard.com

chunkybastard.com

Building a Hardened LAMP Web Server on FreeBSD 15

A practical guide to setting up Apache, PHP, and MariaDB on a security-hardened FreeBSD 15 server.

Introduction

In this article, I will fully cover setting up a FreeBSD 15 web server, which can operate either facing the public internet directly or behind a reverse proxy (Caddy routing traffic through Tailscale).

1. Base System Setup

We will assume that FreeBSD 15 is already freshly installed on a virtual machine or real hardware.

Start by installing a few essential tools:

pkg install nano bash doas

Enable periodic SSD TRIM — useful on virtual machines or on real SSD disks. If a VM is configured properly, the virtual disk file won't grow in size uncontrollably.

sysrc weekly_trim_enable=YES

Enable NTP time sync:

sysrc ntpd_enable=YES
service ntpd start

Set your timezone with tzsetup. After you can verify time with date.

Set your hostname:

sysrc hostname="yourservername"
service hostname start

By default, syslogd listens on network sockets, which is unnecessary for a server that logs locally. Restrict it by running nano /etc/rc.conf and adding following lines:

syslogd_flags="-ss"
service syslogd restart

2. Users and Privilege Management

Change the root password first, then create your regular user account. Add it to the wheel and operator groups.

passwd
adduser

Instead of sudo, FreeBSD works well with doas — a simpler, more auditable privilege tool:

nano /usr/local/etc/doas.conf
# Add:
permit persist :wheel

Lock down the config file permissions:

chmod 0400 /usr/local/etc/doas.conf

3. SSH Hardening

Edit /etc/ssh/sshd_config and make the following changes:

PermitRootLogin no
MaxAuthTries 3
MaxSessions 3
Banner /etc/ssh/banner

Create a pre-login banner (ASCII art generators like manytools.org work well for this):

nano /etc/ssh/banner
chmod 0644 /etc/ssh/banner

Customize the post-login message of the day:

nano /etc/motd.template
service motd restart

To disable the fortune tip that prints on login, comment it out in both ~/.profile and ~/.login.

Finally, enable and start SSH:

sysrc sshd_enable=YES
service sshd start

5. Kernel Hardening with sysctl

Add the following to /etc/sysctl.conf for meaningful kernel-level hardening:

nano /etc/sysctl.conf
# Randomize PIDs to mitigate exploits
kern.randompid=1

# Protect shared memory
kern.ipc.shm_allow_removed=0

# Restrict socket visibility between users
security.bsd.see_other_uids=0
security.bsd.see_other_gids=0

# Stop ptrace attacks
security.bsd.unprivileged_proc_debug=0

# Disable unprivileged reading of kernel messages
security.bsd.unprivileged_read_msgbuf=0

# Hardlink/symlink restrictions
security.bsd.hardlink_check_uid=1
security.bsd.hardlink_check_gid=1

# Guard page
security.bsd.stack_guard_page=1

# Disable ICMP redirects
net.inet.ip.redirect=0
net.inet.ip.accept_sourceroute=0
net.inet.icmp.drop_redirect=1

# Smurf attack mitigation
net.inet.icmp.bmcastecho=0

# SYN flood prevention
net.inet.tcp.syncookies=1

# IP fingerprinting mitigation
net.inet.ip.random_id=1

# Disable TCP RST on segments to closed ports (blackholing)
net.inet.tcp.blackhole=2
net.inet.udp.blackhole=1

Apply immediately:

sysctl -f /etc/sysctl.conf

6. Disable Unnecessary Services

Review what's running:

service -e

For a headless web server, you can safely disable:

sysrc mixer_enable=NO        # No audio needed
sysrc devmatch_enable=NO     # Laptop driver autoloading — not needed
sysrc growfs_enable=NO       # Leftover from cloud init
sysrc growfs_fstab_enable=NO

7. Firewall with pf

FreeBSD's pf is expressive, fast, and battle-tested. Create /etc/pf.conf:

nano /etc/pf.conf
ext_if = "vtnet0"   # Check your interface with ifconfig
tcp_services = "{ 22, 80, 443 }"

set block-policy drop
set skip on lo0
set skip on tailscale0

scrub in all
block all

pass out quick keep state

pass in on $ext_if proto tcp to ($ext_if) port $tcp_services flags S/SA modulate state
pass inet proto icmp all icmp-type { echoreq, unreach, timex } keep state

Load and enable pf:

nano /boot/loader.conf
# Add:
pf_load="YES"

sysrc pf_enable=YES
service pf start

Note: Check your actual interface name with ifconfig before applying. On cloud VMs it's commonly vtnet0; on physical hardware it might be em0 or igb0.


8. Installing the LAMP Stack

Install All Packages at Once

doas pkg install apache24 mariadb118-server php85 php85-extensions php85-mysqli php85-pdo_mysql php85-gd php85-curl php85-mbstring php85-zip php85-exif php85-intl php85-zlib php85-fileinfo mod-php85

MariaDB Setup

sysrc mysql_enable=YES
service mysql-server start
mysql_secure_installation

Recommended answers during the wizard:

  • Current root password: (press Enter — none set yet)
  • Switch to unix_socket authentication: N
  • Change root password: Y (set a strong password)
  • Remove anonymous users: Y
  • Disallow root login remotely: Y
  • Remove test database: Y
  • Reload privilege tables: Y
service mysql-server restart

PHP Configuration

Copy the production template and edit key settings:

cp /usr/local/etc/php.ini-production /usr/local/etc/php.ini
nano /usr/local/etc/php.ini

Recommended settings:

date.timezone = Europe/Vilnius      ; Set to your timezone
cgi.fix_pathinfo = 0
memory_limit = 256M
upload_max_filesize = 128M
post_max_size = 64M
expose_php = Off
display_errors = Off
allow_url_include = Off
disable_functions = exec,passthru,shell_exec,system,proc_open,popen

Configure PHP-FPM to run as the www user:

nano /usr/local/etc/php-fpm.d/www.conf
listen.owner = www
listen.group = www
listen.mode = 0660
user = www
group = www
sysrc php_fpm_enable=yes
service php_fpm start

Apache Configuration

Create the PHP handler module config:

nano /usr/local/etc/apache24/modules.d/001_mod-php.conf
<IfModule dir_module>
	DirectoryIndex index.php index.html
	<FilesMatch "\.php$">
		SetHandler application/x-httpd-php
	</FilesMatch>
	<FilesMatch "\.phps$">
		SetHandler application/x-httpd-php-source
	</FilesMatch>
</IfModule>

Edit httpd.conf to enable needed modules and harden defaults:

nano /usr/local/etc/apache24/httpd.conf

Enable these modules (uncomment if needed):

LoadModule rewrite_module libexec/apache24/mod_rewrite.so
LoadModule deflate_module libexec/apache24/mod_deflate.so
LoadModule access_compat_module libexec/apache24/mod_access_compat.so

Add at the end of httpd.conf:

# Hide version info
ServerTokens Prod
ServerSignature Off

# Disable directory listings
Options -Indexes

# Disable TRACE method
TraceEnable Off

Start Apache:

sysrc apache24_enable=YES
service apache24 start

Verify PHP is working by creating a test file:

nano /usr/local/www/apache24/data/info.php
<?php phpinfo(); ?>

Visit http://your-server-ip/info.php in a browser — then delete that file once confirmed.


9. phpMyAdmin

pkg install phpMyAdmin5-php85
cd /usr/local/www/phpMyAdmin
cp config.sample.inc.php config.inc.php

Edit config.inc.php and change the host from localhost to the explicit loopback IP (required for TCP socket connections):

$cfg['Servers'][$i]['host'] = '127.0.0.1';

10. Virtual Hosts

Enable vhost includes in httpd.conf:

Include etc/apache24/vhosts/*.conf
mkdir -p /usr/local/etc/apache24/vhosts

Default Catch-All Vhost

It's good practice to have a default vhost that catches requests with no matching ServerName, returning nothing useful to scanners:

mkdir -p /usr/local/www/dummy
echo "" > /usr/local/www/dummy/index.html
nano /usr/local/etc/apache24/vhosts/000-default.conf
<VirtualHost *:80>
	ServerName _
	DocumentRoot "/usr/local/www/dummy"
</VirtualHost>

phpMyAdmin Vhost

nano /usr/local/etc/apache24/vhosts/phpmyadmin.conf
<VirtualHost *:80>
	ServerName db.yourdomain.com
	ServerAlias www.db.yourdomain.com

	DocumentRoot "/usr/local/www/phpMyAdmin"

	<Directory "/usr/local/www/phpMyAdmin">
		AllowOverride All
		Require all granted
	</Directory>
</VirtualHost>

Site Vhost

Create directories for your site's web root and logs:

mkdir -p /home/youruser/www/yourdomain.com/logs
mkdir -p /home/youruser/www/yourdomain.com/public_html
echo "" > /home/youruser/www/yourdomain.com/logs/error.log
echo "" > /home/youruser/www/yourdomain.com/logs/access.log
doas chown -R www:www /home/youruser/www/yourdomain.com/logs
doas chmod -R 755 /home/youruser/www/yourdomain.com/logs

Create the vhost config:

nano /usr/local/etc/apache24/vhosts/yourdomain.com.conf
<VirtualHost *:80>
	ServerName yourdomain.com
	ServerAlias www.yourdomain.com

	DocumentRoot "/home/youruser/www/yourdomain.com/public_html"

	<Directory "/home/youruser/www/yourdomain.com/public_html">
		AllowOverride All
		Require all granted
	</Directory>

	ErrorLog  "/home/youruser/www/yourdomain.com/logs/error.log"
	CustomLog "/home/youruser/www/yourdomain.com/logs/access.log" combined
</VirtualHost>
service apache24 restart

11. Bonus: Reverse Proxy Logging Fix

If your server sits behind a reverse proxy (such as Tailscale Funnel or a CDN), Apache will log the proxy's IP instead of the real client IP. Fix this by enabling the remoteip module.

In httpd.conf, uncomment:

LoadModule remoteip_module libexec/apache24/mod_remoteip.so

Add the header directive:

RemoteIPHeader X-Forwarded-For

Then update the combined log format to use %a (the real client IP via RemoteIPHeader) instead of %h (the connecting IP):

LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined

Summary

Here's the full picture of what we built:

Layer Component
OS FreeBSD 15
Firewall pf
Remote Access SSH (hardened) + Tailscale
Web Server Apache 2.4
PHP PHP 8.5 + PHP-FPM
Database MariaDB 11.8
DB Admin phpMyAdmin 5
Privilege Mgmt doas

This stack gives you a production-capable web server with meaningful security defaults — without sacrificing usability or control. From here, the natural next steps are adding TLS with Let's Encrypt (via certbot or acme.sh), setting up automated backups, and deploying your application.


Built and tested on FreeBSD 15. Commands assume root or doas access unless otherwise noted.