How to Set Up a LEMP Stack on FreeBSD (NGINX + MariaDB + PHP)
LEMP stands for Linux/NGINX/MariaDB/PHP. On FreeBSD, replace the L with F and keep the rest. The FEMP stack (FreeBSD, NGINX, MariaDB, PHP) is the standard web application platform for FreeBSD servers. It powers PHP applications from WordPress to Laravel to custom internal tools.
This guide builds a production-ready LEMP stack on FreeBSD from scratch: NGINX as the web server, MariaDB as the database, PHP-FPM as the application processor, SSL with Let's Encrypt, and performance tuning for each component. For a deeper dive into NGINX configuration, see the NGINX Production Setup guide.
Prerequisites
- FreeBSD 14.0 or later
- Root access
- A domain name pointing to the server (required for SSL)
- At least 1 GB RAM (2 GB recommended)
Step 1: Update the System
Start with a current system:
shfreebsd-update fetch install pkg update && pkg upgrade
Step 2: Install NGINX
shpkg install nginx sysrc nginx_enable="YES"
NGINX Base Configuration
Replace the default configuration with a production-ready base:
shcat > /usr/local/etc/nginx/nginx.conf << 'EOF' worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 4096; multi_accept on; use kqueue; } http { include mime.types; default_type application/octet-stream; # Logging log_format main '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' '$request_time'; access_log /var/log/nginx/access.log main; error_log /var/log/nginx/error.log warn; # Performance sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; keepalive_requests 1000; types_hash_max_size 2048; server_tokens off; # Gzip compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 4; gzip_min_length 1000; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; # Security headers (defaults, override per server block) add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; # Client body limits client_max_body_size 64M; client_body_buffer_size 128k; # Include server blocks include /usr/local/etc/nginx/conf.d/*.conf; } EOF mkdir -p /usr/local/etc/nginx/conf.d mkdir -p /var/log/nginx
Note the use kqueue; directive. FreeBSD's kqueue event notification system is more efficient than epoll (Linux) or poll. NGINX detects this automatically, but specifying it explicitly avoids any ambiguity.
Default Server Block
Create a default server block that rejects requests without a matching Host header:
shcat > /usr/local/etc/nginx/conf.d/00-default.conf << 'EOF' server { listen 80 default_server; listen 443 ssl default_server; server_name _; ssl_certificate /usr/local/etc/nginx/self-signed.crt; ssl_certificate_key /usr/local/etc/nginx/self-signed.key; return 444; } EOF
Generate the self-signed certificate for the default block:
shopenssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ -keyout /usr/local/etc/nginx/self-signed.key \ -out /usr/local/etc/nginx/self-signed.crt \ -subj "/CN=localhost"
Test and start NGINX:
shnginx -t && service nginx start
Step 3: Install and Configure MariaDB
shpkg install mariadb1011-server mariadb1011-client sysrc mysql_enable="YES" service mysql-server start
Secure the Installation
shmysql_secure_installation
Answer yes to all security prompts: set a root password, remove anonymous users, disable remote root login, and remove the test database.
MariaDB Configuration
Create a tuned configuration:
shcat > /usr/local/etc/mysql/conf.d/server.cnf << 'EOF' [mysqld] # Character set character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci # InnoDB storage engine innodb_buffer_pool_size = 256M innodb_log_file_size = 64M innodb_flush_log_at_trx_commit = 2 innodb_flush_method = O_DIRECT innodb_file_per_table = 1 # Connection handling max_connections = 151 max_allowed_packet = 64M wait_timeout = 300 interactive_timeout = 300 # Temporary tables tmp_table_size = 64M max_heap_table_size = 64M # Thread handling thread_cache_size = 16 # Query cache query_cache_type = 1 query_cache_size = 32M query_cache_limit = 1M # Binary logging (enable for replication/point-in-time recovery) # log-bin = /var/db/mysql/mysql-bin # binlog_format = ROW # expire_logs_days = 14 # Slow query log slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 2 # Security local_infile = 0 symbolic-links = 0 EOF mkdir -p /var/log/mysql chown mysql:mysql /var/log/mysql service mysql-server restart
Scale innodb_buffer_pool_size to your server: use 50-70% of total RAM on a dedicated database server, or 20-30% on a shared LEMP server.
Create an Application Database
shmysql -u root -p << 'SQL' CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'SecurePassword123'; GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost'; FLUSH PRIVILEGES; SQL
Verify MariaDB
shmysql -u root -p -e "SELECT VERSION(); SHOW DATABASES;"
Step 4: Install and Configure PHP-FPM
shpkg install php83 php83-extensions php83-curl php83-gd php83-mbstring \ php83-mysqli php83-pdo_mysql php83-xml php83-zip php83-zlib \ php83-opcache php83-json php83-session php83-ctype php83-dom \ php83-fileinfo php83-intl php83-bcmath php83-tokenizer \ php83-simplexml php83-xmlreader php83-xmlwriter sysrc php_fpm_enable="YES"
PHP Configuration
Copy the production template and customize:
shcp /usr/local/etc/php.ini-production /usr/local/etc/php.ini
Apply production settings:
shcat > /usr/local/etc/php/ext-30-production.ini << 'EOF' ; Production PHP settings for LEMP stack upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M max_execution_time = 300 max_input_vars = 3000 date.timezone = UTC expose_php = Off ; OPcache opcache.enable = 1 opcache.memory_consumption = 128 opcache.interned_strings_buffer = 16 opcache.max_accelerated_files = 10000 opcache.revalidate_freq = 60 opcache.fast_shutdown = 1 opcache.enable_cli = 0 ; Session session.save_handler = files session.save_path = /var/lib/php/sessions session.gc_maxlifetime = 1440 EOF mkdir -p /var/lib/php/sessions chown www:www /var/lib/php/sessions chmod 700 /var/lib/php/sessions
PHP-FPM Pool Configuration
Edit /usr/local/etc/php-fpm.d/www.conf:
shcat > /usr/local/etc/php-fpm.d/www.conf << 'EOF' [www] user = www group = www ; Use Unix socket for performance listen = /var/run/php-fpm.sock listen.owner = www listen.group = www listen.mode = 0660 ; Process manager: dynamic for variable load pm = dynamic pm.max_children = 25 pm.start_servers = 5 pm.min_spare_servers = 3 pm.max_spare_servers = 10 pm.max_requests = 500 ; Status page (restrict in NGINX) pm.status_path = /fpm-status ping.path = /fpm-ping ping.response = pong ; Logging access.log = /var/log/php-fpm/access.log access.format = "%R - %u %t \"%m %r%Q%q\" %s %f %{mili}d %{kilo}M %C%%" slowlog = /var/log/php-fpm/slow.log request_slowlog_timeout = 5s request_terminate_timeout = 300s ; Environment catch_workers_output = yes decorate_workers_output = no EOF mkdir -p /var/log/php-fpm chown www:www /var/log/php-fpm
Tune pm.max_children based on available memory. Each PHP-FPM worker uses roughly 30-50 MB. For 2 GB RAM with MariaDB also running, 20-25 workers is appropriate.
Start PHP-FPM:
shservice php-fpm start
Test PHP Processing
Create a test page:
shmkdir -p /usr/local/www/html cat > /usr/local/www/html/info.php << 'EOF' <?php phpinfo(); ?> EOF chown www:www /usr/local/www/html/info.php
Create an NGINX server block for testing:
shcat > /usr/local/etc/nginx/conf.d/test.conf << 'EOF' server { listen 80; server_name your-server-ip; root /usr/local/www/html; index index.php index.html; location / { try_files $uri $uri/ =404; } location ~ \.php$ { fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_intercept_errors on; } } EOF nginx -t && service nginx reload
Visit http://your-server-ip/info.php in a browser. You should see the PHP info page. Remove it after testing:
shrm /usr/local/www/html/info.php
Step 5: SSL with Let's Encrypt
Install certbot:
shpkg install py311-certbot py311-certbot-nginx
Create a site server block first:
shcat > /usr/local/etc/nginx/conf.d/example.conf << 'EOF' server { listen 80; server_name example.com www.example.com; root /usr/local/www/example.com; index index.php index.html; location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_intercept_errors on; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 30d; access_log off; } } EOF mkdir -p /usr/local/www/example.com nginx -t && service nginx reload
Obtain the certificate:
shcertbot --nginx -d example.com -d www.example.com
Certbot modifies the NGINX config to add SSL directives and a redirect from HTTP to HTTPS.
Automatic Renewal
sh# Test renewal certbot renew --dry-run # Add cron job for automatic renewal cat >> /etc/crontab << 'CRON' 0 3 * * * root certbot renew --quiet --deploy-hook "service nginx reload" CRON
SSL Hardening
After certbot runs, verify and harden the SSL configuration in your server block:
sh# Add these directives in the SSL server block ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5:!3DES; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; # HSTS (enable only after confirming SSL works) add_header Strict-Transport-Security "max-age=63072000" always;
Step 6: Production Application Server Block
Here is a complete server block template for a PHP application:
shcat > /usr/local/etc/nginx/conf.d/app.conf << 'EOF' server { listen 80; server_name app.example.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name app.example.com; root /usr/local/www/app.example.com/public; index index.php index.html; # SSL (managed by certbot) ssl_certificate /usr/local/etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /usr/local/etc/letsencrypt/live/app.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Strict-Transport-Security "max-age=63072000" always; # Request limits client_max_body_size 64M; # Application routing location / { try_files $uri $uri/ /index.php?$query_string; } # PHP-FPM location ~ \.php$ { try_files $uri =404; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; fastcgi_intercept_errors on; fastcgi_buffer_size 16k; fastcgi_buffers 4 16k; fastcgi_read_timeout 300; } # Static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, immutable"; access_log off; } # Deny dotfiles location ~ /\. { deny all; access_log off; log_not_found off; } # PHP-FPM status (restrict to localhost) location ~ ^/(fpm-status|fpm-ping)$ { allow 127.0.0.1; deny all; fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } EOF nginx -t && service nginx reload
Step 7: Log Rotation
Configure log rotation for all LEMP components:
shcat > /usr/local/etc/newsyslog.conf.d/lemp.conf << 'EOF' # NGINX logs /var/log/nginx/access.log www:www 644 7 * @T00 JB /var/run/nginx.pid 30 /var/log/nginx/error.log www:www 644 7 * @T00 JB /var/run/nginx.pid 30 # PHP-FPM logs /var/log/php-fpm/access.log www:www 644 7 * @T00 JB /var/log/php-fpm/slow.log www:www 644 7 * @T00 JB # MariaDB slow query log /var/log/mysql/slow.log mysql:mysql 644 7 * @T00 JB EOF
Step 8: Monitoring and Health Checks
NGINX Status
Enable the stub status module for monitoring:
shcat > /usr/local/etc/nginx/conf.d/status.conf << 'EOF' server { listen 127.0.0.1:8080; location /nginx_status { stub_status; } location /fpm-status { fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } } EOF nginx -t && service nginx reload
Check status:
sh# NGINX connections fetch -qo - http://127.0.0.1:8080/nginx_status # PHP-FPM status fetch -qo - http://127.0.0.1:8080/fpm-status # MariaDB status mysqladmin -u root -p status
Simple Health Check Script
shcat > /usr/local/bin/lemp-health.sh << 'HEALTH' #!/bin/sh # LEMP stack health check echo "=== NGINX ===" service nginx status fetch -qo /dev/null http://127.0.0.1:8080/nginx_status && echo "NGINX: OK" || echo "NGINX: FAIL" echo "" echo "=== PHP-FPM ===" service php-fpm status fetch -qo /dev/null http://127.0.0.1:8080/fpm-status && echo "PHP-FPM: OK" || echo "PHP-FPM: FAIL" echo "" echo "=== MariaDB ===" service mysql-server status mysqladmin -u root ping 2>/dev/null && echo "MariaDB: OK" || echo "MariaDB: FAIL" echo "" echo "=== Disk ===" df -h / /var /usr echo "" echo "=== Memory ===" sysctl hw.physmem hw.usermem HEALTH chmod +x /usr/local/bin/lemp-health.sh
Performance Tuning Summary
| Component | Setting | Recommended Value |
|-----------|---------|-------------------|
| NGINX | worker_connections | 4096 |
| NGINX | keepalive_timeout | 65 |
| PHP-FPM | pm.max_children | RAM_MB / 50 |
| PHP-FPM | opcache.memory_consumption | 128 MB |
| MariaDB | innodb_buffer_pool_size | 50-70% of RAM (dedicated) |
| MariaDB | query_cache_size | 32-64 MB |
| FreeBSD | kern.ipc.somaxconn | 4096 |
| FreeBSD | net.inet.tcp.msl | 5000 |
Apply FreeBSD kernel tuning:
shcat >> /etc/sysctl.conf << 'EOF' # LEMP stack tuning kern.ipc.somaxconn=4096 net.inet.tcp.msl=5000 net.inet.tcp.fast_finwait2_recycle=1 kern.ipc.maxsockbuf=16777216 net.inet.tcp.sendspace=262144 net.inet.tcp.recvspace=262144 EOF sysctl -f /etc/sysctl.conf
For more comprehensive FreeBSD tuning, including NUMA awareness and ZFS optimization, see the database comparison with PostgreSQL on FreeBSD.
FAQ
What is the difference between LEMP and LAMP?
LAMP uses Apache with mod_php. LEMP uses NGINX with PHP-FPM. NGINX handles static files more efficiently, uses less memory per connection, and provides better concurrency. PHP-FPM runs as a separate process pool, allowing independent scaling and restarts. For new deployments, LEMP is the better choice.
Can I use PostgreSQL instead of MariaDB?
Yes. Replace the MariaDB packages with postgresql16-server and php83-pdo_pgsql. The NGINX and PHP-FPM configuration remains identical. PostgreSQL is better for complex queries, JSON data, and write-heavy workloads. MariaDB is better for WordPress, simple CRUD applications, and read-heavy workloads with query caching.
How do I add another PHP application to this stack?
Create a new server block in /usr/local/etc/nginx/conf.d/, set the root to the application directory, obtain an SSL certificate with certbot, and reload NGINX. If the application needs different PHP settings, create a separate PHP-FPM pool in /usr/local/etc/php-fpm.d/.
How many concurrent users can this handle?
With the default configuration in this guide on a 2-core, 4 GB RAM server: approximately 200-500 concurrent users for a typical PHP application. Bottlenecks usually appear at PHP-FPM (too few workers) or MariaDB (slow queries). Monitor with fpm-status and the slow query log.
Should I run the LEMP stack in a jail?
For production: yes. A jail provides security isolation, independent package management, and easy migration (snapshot the jail's ZFS dataset). For development or single-purpose servers: the overhead of managing a jail may not be justified.
How do I upgrade PHP from 8.3 to 8.4?
When PHP 8.4 appears in the FreeBSD ports tree: pkg install php84 php84-extensions ... (all the same extensions with the new version prefix), update the PHP-FPM pool config path, test your application, then remove the old PHP packages with pkg remove php83-*. Test in a staging environment first.
Why use a Unix socket instead of TCP for PHP-FPM?
Unix sockets avoid TCP overhead (no three-way handshake, no port allocation, no loopback network processing). On the same machine, Unix sockets deliver lower latency and higher throughput. Use TCP only when PHP-FPM runs on a separate server from NGINX.