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
4. Tailscale (Optional but Recommended)
If you're running this server behind a reverse proxy or want secure remote access without exposing SSH to the public internet, Tailscale is an excellent choice:
pkg install tailscale sysrc tailscaled_enable=YES service tailscaled start tailscale up tailscale ip -4
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
ifconfigbefore applying. On cloud VMs it's commonlyvtnet0; on physical hardware it might beem0origb0.
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.