Copy linkThemeUnmute sounds
Blog

Complete LAMP (Linux, Apache, MySQL/MariaDB, PHP) Stack Installation Guide

A production-ready handbook for deploying a highly secure and optimized LAMP stack on Rocky Linux 9. This guide covers system swap optimizations, Multi-PHP configuration, secure database setups,…

By Joshua Sarmiento//12 min read/Web stack

1. System Preparation

Initial Updates and Essential Tools

# Update package database
sudo dnf update -y

# Install utility packages
sudo dnf install -y htop nano wget epel-release

Create and Configure Swap File

Allocate an 8GB swap file to prevent out-of-memory errors on smaller hosting instances:

# Create 8GB swap file
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
ls -lh /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
sudo swapon --show

# Make swap permanent
sudo cp /etc/fstab /etc/fstab.bak
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# Optimize swap settings
cat /proc/sys/vm/swappiness
sudo sysctl vm.swappiness=10
sudo sysctl vm.vfs_cache_pressure=50

# Persist sysctl parameters
sudo nano /etc/sysctl.conf
# Append:
vm.swappiness=10
vm.vfs_cache_pressure=50

Note

fallocate gives a hole-free file on ext4 and XFS, which is what swapon needs. On btrfs and ZFS it can produce a file the kernel refuses to swap on — use dd if=/dev/zero of=/swapfile bs=1M count=8192 (or btrfs filesystem mkswapfile --size 8g /swapfile) instead.


2. Install PHP

Remi Repository Integration

Install the Remi repository to manage multiple modern and legacy PHP packages:

sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-9.rpm
sudo dnf module list php
sudo dnf module reset php -y

# Install the current PHP version (choose one)
sudo dnf module install php:remi-8.4 -y
# OR the previous line, for an app that isn't ready for 8.4 yet
# sudo dnf module install php:remi-8.3 -y

# Verify running version
php -v

# Install required PHP extensions
sudo dnf install -y php-cli php-fpm php-mysqlnd php-opcache php-zip php-devel php-gd php-mbstring php-curl php-xml php-pear php-bcmath php-intl php-tokenizer php-sodium php-process

Warning

Two things moved on since the classic extension list: PHP 8 removed mcrypt entirely — the php-mcrypt package no longer exists, and php-sodium (or php-openssl) is its replacement — and json is compiled in, so there is no php-json to install. Asking for either one makes dnf abort the whole transaction.

Run Multiple PHP Versions Concurrently

If your server hosts apps requiring different versions, install both:

# Install PHP 8.3 & PHP 8.4 with FPM
sudo dnf install -y php83-php-fpm php83-php-mysqlnd php83-php-gd php83-php-mbstring php83-php-xml
sudo dnf install -y php84-php-fpm php84-php-mysqlnd php84-php-gd php84-php-mbstring php84-php-xml

# Start and enable both FPM services
sudo systemctl enable --now php83-php-fpm
sudo systemctl enable --now php84-php-fpm

# Ensure web server starts
sudo systemctl enable --now httpd
sudo systemctl enable --now mariadb

Tip

Only keep a second version for code you actually still run. PHP 7.4 has been end-of-life since November 2022 and receives no security fixes, so php74-* packages belong on a migration list, not on a production host.


3. Install MariaDB

Add the Official MariaDB Repository

AppStream ships MariaDB 10.5, which is old. For a current release use MariaDB's own repository — their mariadb_repo_setup script writes a correctly signed /etc/yum.repos.d/mariadb.repo for you, so there is no hand-rolled baseurl to keep pointed at a working mirror:

# Turn off the AppStream mariadb module first, so the two package sets
# don't fight over mariadb-server
sudo dnf module reset mariadb -y
sudo dnf module disable mariadb -y

# Add the official repo, pinned to the 11.8 LTS line
curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | sudo bash -s -- --mariadb-server-version=11.8

Tip

Swap 11.8 for 11.4 if you need the older LTS line. Both are long-term-support releases; <mariadb-server-version> also accepts minor pins such as 11.8.2.

Install and Start Service

# Install server and client packages
sudo dnf install -y mariadb-server

# Start database engine
sudo systemctl enable mariadb
sudo systemctl restart mariadb
sudo systemctl status mariadb

Secure Database Engine

Execute the security script to lock down external access and set root credentials:

sudo mariadb-secure-installation

Respond to the configuration prompts:

  • Enter current password for root (enter for none): [Press Enter]
  • Switch to unix_socket authentication [Y/n]: n
  • Change the root password? [Y/n]: Y (Set a strong master password)
  • Remove anonymous users? [Y/n]: Y
  • Disallow root login remotely? [Y/n]: Y
  • Remove test database and access to it? [Y/n]: Y
  • Reload privilege tables now? [Y/n]: Y

Alternatively, manually reset local passwords inside the SQL shell:

mysql -u root -p
ALTER USER 'root'@'localhost' IDENTIFIED BY 'YOUR_STRONG_PASSWORD';

4. Database Management

Create Databases & User Accounts

-- Access command shell
-- mysql -u root -p

-- Create web application database
CREATE DATABASE sample_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Authorize dedicated application user
CREATE USER 'sample_user'@'localhost' IDENTIFIED BY 'YOUR_APP_USER_PASSWORD';
GRANT ALL PRIVILEGES ON sample_db.* TO 'sample_user'@'localhost';
FLUSH PRIVILEGES;

Administration Utilities

-- Check database user list
SELECT User, Host FROM mysql.user;

-- Check existing databases
SHOW DATABASES;

-- Show rights for target user
SHOW GRANTS FOR 'sample_user'@'localhost';

-- Delete Database & Revoke Privileges
DROP DATABASE IF EXISTS sample_db;
REVOKE ALL PRIVILEGES ON sample_db.* FROM 'sample_user'@'localhost';
FLUSH PRIVILEGES;

Backup & Restore

# Export single database (specifying custom port if configured)
mysqldump -u [username] -p sample_db > db_backup.sql

# Export all databases
mysqldump -u [username] -p --all-databases > all_db_backup.sql

# Restore dump
mysql -u [username] -p sample_db < db_backup.sql

Configure Alternative Port (e.g. 6303)

To obscure the database port, configure custom listening bindings:

# Backup configuration
sudo cp /etc/my.cnf.d/mariadb-server.cnf /etc/my.cnf.d/mariadb-server.cnf.orig

# Edit Server settings
sudo nano /etc/my.cnf.d/mariadb-server.cnf

Add your custom port under the [mysqld] block:

[mysqld]
port = 6303

SELinux & Firewalld Rules for Custom Port:

If SELinux is active, you must configure policies to authorize MariaDB to bind to the custom port:

# Enable policy core utilities
sudo dnf install -y policycoreutils-python-utils

# Register custom port in SELinux policy
sudo semanage port -a -t mysqld_port_t -p tcp 6303

# Open custom port in the host firewall
sudo firewall-cmd --permanent --add-port=6303/tcp
sudo firewall-cmd --reload

# Apply configuration and reload MariaDB
sudo systemctl daemon-reload
sudo systemctl restart mariadb

# Verify custom port state (`ss` ships with iproute, unlike the retired net-tools)
sudo ss -tulpen | grep mariadb

5. Install and Harden Apache (HTTPD)

Base Setup

sudo dnf install -y httpd mod_ssl mod_security mod_security_crs
sudo systemctl enable httpd
sudo systemctl restart httpd

# Verify module registrations
sudo httpd -M | grep -E 'headers|rewrite|ssl|security'

Configuration Hardening

To restrict Apache from leaking version banners, disable server headers and trace methods:

# Edit main config
sudo nano /etc/httpd/conf/httpd.conf

Append these directives to the configuration file:

ServerSignature Off
ServerTokens Prod
TraceEnable Off

Ensure Directory indexing is disabled by verifying -Indexes is present within DocumentRoot folders:

<Directory "/var/www/html">
    Options -Indexes +FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

Verify configurations and restart the service:

sudo apachectl configtest
sudo systemctl restart httpd

Fail2ban Intrusion Prevention Setup

Deploy Fail2ban to block IP addresses showing brute force behaviors:

sudo dnf install -y fail2ban

Create a global override configuration /etc/fail2ban/jail.local:

[DEFAULT]
# Global default ban metrics
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.multipliers = 1 2 4 8 16 32 64

# Shorten check times
findtime = 600
maxretry = 3
backend = systemd

# Notifications (Postfix needs setup)
destemail = admin@sample.com
sender = fail2ban@sample.com
action = %(action_mwl)s

# Whitelist local/trusted ranges
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 <YOUR_TRUSTED_IP>

Note

Two defaults worth knowing here. backend = systemd reads the journal, so the logpath in the jail below is only a fallback — leave it out entirely if you are happy with journal-only matching. And banaction = iptables-multiport writes iptables rules on a host whose firewall is firewalld; that works, but the bans are invisible to firewall-cmd. Use banaction = firewallcmd-ipset (after sudo dnf install -y ipset) if you want to see and manage every ban from firewalld itself.

Add an Apache authorization monitor configuration inside /etc/fail2ban/jail.d/httpd.conf:

[httpd-auth]
enabled = true
port = http,https
logpath = /var/log/httpd/*access_log
filter = apache-auth
maxretry = 3
bantime = 7200
findtime = 600
action = %(action_mwl)s
banaction = iptables-multiport

Enable Postfix to handle mail delivery notifications:

sudo dnf install -y postfix
sudo systemctl enable --now postfix
sudo systemctl restart fail2ban

# Monitor fail2ban logs
sudo tail -f /var/log/fail2ban.log

6. Firewall Configuration

# Install and initialize firewall
sudo dnf install -y firewalld
sudo systemctl enable --now firewalld

# Open standard web protocols
sudo firewall-cmd --permanent --zone=public --add-service=http
sudo firewall-cmd --permanent --zone=public --add-service=https

# Loopback permissions
sudo firewall-cmd --permanent --zone=trusted --add-interface=lo

# Remove unnecessary service ranges
sudo firewall-cmd --remove-port=[PORT]/tcp --permanent
sudo firewall-cmd --reload

# Show active rules
sudo firewall-cmd --list-all

7. SSH Configuration Hardening

Obscure standard management ports to decrease automated SSH dictionary attacks:

sudo nano /etc/ssh/sshd_config

Change default bindings:

Port 8282

Configure SELinux and firewall bounds for the updated port:

# Authorize SSH to bind on the new port
sudo semanage port -a -t ssh_port_t -p tcp 8282

# Update firewalld rules
sudo firewall-cmd --permanent --add-port=8282/tcp
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload

# Apply Changes
sudo systemctl restart sshd

SSH Key Setup

Secure SSH profiles by generating authorized keys:

mkdir -p ~/.ssh
chmod 700 ~/.ssh

# Import user public keys
nano ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

To convert PuTTY public key exports into standard OpenSSH format:

ssh-keygen -i -f /home/temp/dev1.pub >> ~/.ssh/authorized_keys

8. Directory & Host Configurations

Create Project Roots

sudo mkdir -p /var/www/sample-webapps/logs
sudo mkdir -p /var/www/sample-webapps/prod/public_html
sudo chown -R apache:apache /var/www/sample-webapps

Set secure directory traversal ownership and permissions:

cd /var/www/sample-webapps/prod/public_html
sudo find . -type d -exec chmod 0755 {} \;
sudo find . -type f -exec chmod 0644 {} \;

# Restrict sensitive system credentials files
sudo chmod 400 .env
sudo chmod 400 wp-config.php

SELinux Web Directory Permissions

# Standard read-only content access
sudo chcon -R -t httpd_sys_content_t /var/www/sample-webapps

# Apply persistent database context permissions for uploads/temp write areas
sudo chcon -R -t httpd_sys_rw_content_t /var/www/sample-webapps/prod/public_html/wp-content
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/sample-webapps/prod/public_html/wp-content(/.*)?"
sudo restorecon -Rv /var/www/sample-webapps/

9. WordPress Deployments

# Fetch and extract package files
wget https://wordpress.org/latest.tar.gz
tar -xzvf latest.tar.gz
rm latest.tar.gz

# Align to project structure
mv wordpress/* /var/www/sample-webapps/prod/public_html/
rmdir wordpress

Advanced WordPress Hardening Permissions

# Secure directory configuration
sudo chown -R apache:apache /var/www/sample-webapps/prod/public_html/
sudo find /var/www/sample-webapps/prod/public_html/ -type d -exec chmod 750 {} \;
sudo find /var/www/sample-webapps/prod/public_html/ -type f -exec chmod 640 {} \;

# Enable writable sections for uploads and plugin config
sudo chmod 770 /var/www/sample-webapps/prod/public_html/wp-content/
sudo chmod 770 /var/www/sample-webapps/prod/public_html/wp-content/themes/
sudo chmod 770 /var/www/sample-webapps/prod/public_html/wp-content/plugins/
sudo chmod 770 /var/www/sample-webapps/prod/public_html/wp-content/uploads/

Exclude XML-RPC from execution (prevents brute-force pingback loops) by appending this to your .htaccess:

<Files xmlrpc.php>
    Require all denied
</Files>

10. Let's Encrypt SSL (Certbot + Cloudflare API Challenge)

Generate certificates using Snapd Certbot packages and Cloudflare DNS validation plugins:

# Setup Snapd dependencies
sudo dnf install -y snapd
sudo systemctl enable --now snapd.socket
sudo ln -s /var/lib/snapd/snap /snap

# Install certbot via snap
sudo snap install --classic certbot
sudo snap set certbot trust-plugin-with-root=ok
sudo ln -s /snap/bin/certbot /usr/bin/certbot

# Install DNS plugins
sudo snap install certbot-dns-cloudflare

Create a Cloudflare credentials file ~/.secrets/certbot/cloudflare.ini:

dns_cloudflare_api_token = <YOUR_CLOUDFLARE_API_TOKEN>
chmod 600 ~/.secrets/certbot/cloudflare.ini

# Generate SSL certificate using DNS validation
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 60 \
  --deploy-hook "systemctl reload httpd" \
  -d sample.com \
  -d www.sample.com

Tip

Prefer the distribution package (sudo dnf install -y certbot python3-certbot-dns-cloudflare from EPEL) unless you specifically need the snap build — it installs the same plugins without pulling snapd and its /snap mount into a server image.

Auto-Renewal Automation

Certbot ships a certbot.timer systemd unit that already attempts renewal twice a day, and the --deploy-hook above reloads Apache whenever a certificate is actually renewed. The explicit cron job below is the manual equivalent — keep it only if you want renewal on a fixed weekly schedule you control:

Create a script /scripts/certbot/renew_ssl.sh:

#!/bin/bash
certbot renew --quiet
systemctl restart httpd
sudo chmod +x /scripts/certbot/renew_ssl.sh

Configure /etc/crontab to execute weekly:

0 23 * * 0 root /scripts/certbot/renew_ssl.sh

11. VirtualHost Configurations

Create VirtualHost profiles under /etc/httpd/conf.d/sample.conf:

<VirtualHost *:80>
    ServerName sample.com
    ServerAlias www.sample.com
    
    # Enforce SSL redirects
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
</VirtualHost>

<VirtualHost *:443>
    ServerName sample.com
    ServerAlias www.sample.com
    DocumentRoot /var/www/sample-webapps/prod/public_html
    
    ErrorLog /var/www/sample-webapps/logs/error.log
    CustomLog /var/www/sample-webapps/logs/access.log combined

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/sample.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/sample.com/privkey.pem

    <Directory "/var/www/sample-webapps/prod/public_html">
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted

        # Security Headers Configuration
        <IfModule mod_headers.c>
            Header set X-Frame-Options "sameorigin"
            Header set X-Content-Type-Options "nosniff"
            Header set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
            Header set Content-Security-Policy "default-src * data: blob:; script-src https: blob: 'unsafe-inline' 'unsafe-eval'; style-src https: 'unsafe-inline'; frame-src https: blob: self"
            Header set Referrer-Policy "no-referrer-when-downgrade"
            Header set Permissions-Policy "camera=(), microphone=(), geolocation=()"
            Header edit Set-Cookie ^(.*)$ $1;HttpOnly;Secure
        </IfModule>

        # Fix HTTP Trace Vulnerability
        RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)
        RewriteRule .* - [F]
    </Directory>

    # PHP-FPM Proxy Settings
    # The socket path follows whichever Remi module you enabled above
    # (php82 / php83 / php84 ...)
    <FilesMatch \.php$>
        SetHandler "proxy:unix:/var/opt/remi/php84/run/php-fpm/www.sock|fcgi://localhost"
    </FilesMatch>

    # Protect WordPress configuration parameters
    <FilesMatch "^(wp-config\.php|readme\.html|license\.txt|xmlrpc\.php)">
        Require all denied
    </FilesMatch>
</VirtualHost>

12. ClamAV Antivirus Deployments

Install ClamAV to verify and scan web project roots:

sudo dnf install -y clamav clamav-update

# Adjust SELinux settings for virus scanning
sudo setsebool -P antivirus_can_scan_system 1
sudo setsebool -P clamd_use_jit 1

# Configure signatures
sudo sed -i -e "s/^Example/#Example/" /etc/clamd.d/scan.conf
sudo sed -i -e "s/#LocalSocket /LocalSocket /" /etc/clamd.d/scan.conf
sudo sed -i -e "s/^Example/#Example/" /etc/freshclam.conf

# Fetch database definitions
sudo freshclam

# Start the scanner and the signature updater
sudo systemctl enable --now clamd@scan clamav-freshclam

Note

On Enterprise Linux 9 the daemon and its updater come from the clamav and clamav-update packages (clamd@scan.service, clamav-freshclam.service). The clamav-server-systemd / clamav-scanner-systemd names you'll find in older write-ups are not in EPEL 9, and asking for them fails the whole install.

Full-Scan Automation Script

Create /scripts/clamav/clamav_fullscan.sh:

#!/bin/bash
sudo systemctl stop clamav-freshclam
sudo freshclam
sudo systemctl start clamav-freshclam
# Scan directories and move quarantined objects
clamscan -ir --move=/scripts/clamav/quarantine /var/www/ -l /scripts/clamav/logs/av.log
sudo chmod +x /scripts/clamav/clamav_fullscan.sh

Configure daily cron task in /etc/crontab:

0 1 * * * root /scripts/clamav/clamav_fullscan.sh

13. System Node.js & Composer Installations

Node.js (via AppStream or NVM)

# Option 1: System Node.js module streams
sudo dnf module enable nodejs:22 -y
sudo dnf install -y nodejs

# Option 2: NVM (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 22

Note

Node.js 20 went end-of-life in April 2026 and no longer receives security patches, so both the AppStream module and the NVM install above target Node.js 22 (supported into 2027).

PHP Composer Setup

curl -sS https://getcomposer.org/installer -o composer-setup.php
curl -sS https://composer.github.io/installer.sig -o composer-setup.sig

# Verify the installer against the published signature before running it
php -r "exit(hash_file('sha384', 'composer-setup.php') === trim(file_get_contents('composer-setup.sig')) ? 0 : 1);" \
  && echo "Installer verified" \
  || { echo "Installer corrupt — do not run it"; exit 1; }

sudo php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php composer-setup.sig
composer --version

PM2 Process Manager Deployments

npm install pm2 -g
pm2 start app.js --name "node-service"
pm2 save
pm2 startup

14. ModSecurity Rules Tuning

If ModSecurity filters cause issues with contact forms or admin tools, whitelist specific rules inside /etc/httpd/modsecurity.d/activated_rules/whitelist.conf:

# Increase match constraints globally to prevent limit exceptions
SecPcreMatchLimit 150000
SecPcreMatchLimitRecursion 150000

# Remove restrictive rules for trusted admin backend locations
<LocationMatch "/(wp-admin|wp-json)">
    SecRuleRemoveById 933160 941160 942190 200003 949110 980130
</LocationMatch>

# Bypass rule checking for specific trusted management IPs
<LocationMatch "/">
    <If "%{REMOTE_ADDR} == '192.168.1.100' || %{REMOTE_ADDR} == '192.168.1.101'">
        SecRuleEngine Off
    </If>
</LocationMatch>

Need Help with Your Infrastructure?

If you're looking to implement a robust, secure, and production-ready server architecture, or if you need help migrating your applications to modern infrastructure, I can help!

Contact Me:

Tools in this guide

Each tool links to where it sits in my full stack.