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-releaseCreate 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=50Note
fallocategives a hole-free file on ext4 and XFS, which is whatswaponneeds. On btrfs and ZFS it can produce a file the kernel refuses to swap on — usedd if=/dev/zero of=/swapfile bs=1M count=8192(orbtrfs 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-processWarning
Two things moved on since the classic extension list: PHP 8 removed
mcryptentirely — thephp-mcryptpackage no longer exists, andphp-sodium(orphp-openssl) is its replacement — andjsonis compiled in, so there is nophp-jsonto install. Asking for either one makesdnfabort 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 mariadbTip
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.8Tip
Swap
11.8for11.4if you need the older LTS line. Both are long-term-support releases;<mariadb-server-version>also accepts minor pins such as11.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 mariadbSecure Database Engine
Execute the security script to lock down external access and set root credentials:
sudo mariadb-secure-installationRespond 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 -pALTER 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.sqlConfigure 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.cnfAdd your custom port under the [mysqld] block:
[mysqld]
port = 6303SELinux & 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 mariadb5. 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.confAppend these directives to the configuration file:
ServerSignature Off
ServerTokens Prod
TraceEnable OffEnsure 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 httpdFail2ban Intrusion Prevention Setup
Deploy Fail2ban to block IP addresses showing brute force behaviors:
sudo dnf install -y fail2banCreate 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 = systemdreads the journal, so thelogpathin the jail below is only a fallback — leave it out entirely if you are happy with journal-only matching. Andbanaction = iptables-multiportwrites iptables rules on a host whose firewall is firewalld; that works, but the bans are invisible tofirewall-cmd. Usebanaction = firewallcmd-ipset(aftersudo 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-multiportEnable 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.log6. 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-all7. SSH Configuration Hardening
Obscure standard management ports to decrease automated SSH dictionary attacks:
sudo nano /etc/ssh/sshd_configChange default bindings:
Port 8282Configure 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 sshdSSH 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_keysTo convert PuTTY public key exports into standard OpenSSH format:
ssh-keygen -i -f /home/temp/dev1.pub >> ~/.ssh/authorized_keys8. 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-webappsSet 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.phpSELinux 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 wordpressAdvanced 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-cloudflareCreate 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.comTip
Prefer the distribution package (
sudo dnf install -y certbot python3-certbot-dns-cloudflarefrom EPEL) unless you specifically need the snap build — it installs the same plugins without pulling snapd and its/snapmount 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 httpdsudo chmod +x /scripts/certbot/renew_ssl.shConfigure /etc/crontab to execute weekly:
0 23 * * 0 root /scripts/certbot/renew_ssl.sh11. 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-freshclamNote
On Enterprise Linux 9 the daemon and its updater come from the
clamavandclamav-updatepackages (clamd@scan.service,clamav-freshclam.service). Theclamav-server-systemd/clamav-scanner-systemdnames 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.logsudo chmod +x /scripts/clamav/clamav_fullscan.shConfigure daily cron task in /etc/crontab:
0 1 * * * root /scripts/clamav/clamav_fullscan.sh13. 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 22Note
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 --versionPM2 Process Manager Deployments
npm install pm2 -g
pm2 start app.js --name "node-service"
pm2 save
pm2 startup14. 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: