WordPress Subdomain Hardening Guide

Comprehensive NGINX & PHP-FPM Security Configuration

🎯 Overview

This guide provides a comprehensive approach to hardening a WordPress subdomain installation by implementing dedicated PHP-FPM pools, proper file permissions, SSL certificates, and various security measures. The process isolates each subdomain with its own user account, PHP pool, and security configurations.

Security Architecture Diagram

User Account
(store)
PHP-FPM Pool
(Dedicated)
NGINX
(Server Block)
WordPress
(Subdomain)
📌 Key Benefits:
  • Enhanced security through user isolation
  • Improved resource management
  • Better logging and debugging capabilities
  • Simplified SSL management with wildcard certificates

👤 User Setup & Group Configuration

The first critical step in hardening your WordPress subdomain is creating a dedicated system user that will own all WordPress files and directories. This isolation prevents potential security breaches from affecting other sites on the same server.

Step 1: Create Dedicated User

Create a new system user specifically for this subdomain. In this example, we'll use "store" as the username:

sudo useradd store

Step 2: Configure Group Memberships

Proper group configuration ensures the web server can read files while maintaining security boundaries:

sudo usermod -a -G www-data store

Explanation: Adds the "store" user to the "www-data" group, allowing NGINX to read files owned by this user.

sudo usermod -a -G store www-data

Explanation: Adds the "www-data" user to the "store" group, enabling two-way communication.

sudo usermod -a -G store yourusername

Explanation: Adds your non-root server user to the "store" group for administrative access. Replace "yourusername" with your actual username.

⚠️ Important: After modifying group memberships, you must log out and log back in for changes to take effect.

Step 3: Apply Changes

exit

Log out and then log back in to enable the group modifications.

Step 4: System Updates

Before proceeding, ensure your system is up to date:

sudo apt update && sudo apt upgrade -y

User & Group Relationship

store (user)
File Owner
www-data (group)
Web Server
yourusername
Admin Access

🔧 PHP-FPM Pool Configuration

Creating a dedicated PHP-FPM pool for each subdomain provides resource isolation, improved security, and better performance monitoring. Each pool runs under its own user account with specific resource limits.

Step 1: Navigate to PHP Pool Directory

cd /etc/php/8.3/fpm/pool.d/

Step 2: Create Pool Configuration File

sudo cp www.conf store.expert-wp.conf

Copy the default pool configuration as a template for your new subdomain pool.

Step 3: Edit Pool Configuration

sudo nano store.expert-wp.conf

Configuration Changes Required:

[store.expert-wp] ; Pool name - identifies this specific PHP-FPM pool ; Change from [www] to your subdomain identifier user = store ; User under which the pool runs - must match your created user group = store ; Group under which the pool runs - must match your created user listen = /run/php/php8.3-fpm-store.sock ; Unix socket file path - must be unique for each pool ; Format: /run/php/php8.3-fpm-SUBDOMAIN.sock ; Resource Limits rlimit_files = 15000 ; Maximum number of open file descriptors rlimit_core = 100 ; Maximum core file size ; Error Logging Configuration php_flag[display_errors] = off ; Never display errors to users (security risk) php_admin_value[error_log] = /var/log/fpm-php.store.log ; Custom error log location for this pool php_admin_flag[log_errors] = on ; Enable error logging ; Disabled PHP Functions (Security) php_admin_value[disable_functions] = shell_exec,opcache_get_configuration,opcache_get_status,disk_total_space,diskfreespace,dl,exec,passthru,pclose,pcntl_alarm,pcntl_exec,pcntl_fork,pcntl_get_last_error,pcntl_getpriority,pcntl_setpriority,pcntl_signal,pcntl_signal_dispatch,pcntl_sigprocmask,pcntl_sigtimedwait,pcntl_sigwaitinfo,pcntl_strerror,pcntl_waitpid,pcntl_wait,pcntl_wexitstatus,pcntl_wifcontinued,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_wstopsig,pcntl_wtermsig,popen,posix_getpwuid,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,posix_uname,proc_close,proc_get_status,proc_nice,proc_open,proc_terminate,show_source,system ; Open Base Directory Restriction php_admin_value[upload_tmp_dir] = /var/www/store.example.com/tmp/ php_admin_value[sys_temp_dir] = /var/www/store.example.com/tmp/ php_admin_value[open_basedir] = /var/www/store.example.com/public_html/:/var/www/store.example.com/tmp/ ; Memory Configuration php_admin_value[memory_limit] = 256M

Step 4: Create and Configure Log File

sudo touch /var/log/fpm-php.store.log
sudo chown store:www-data /var/log/fpm-php.store.log
sudo chmod 660 /var/log/fpm-php.store.log
💡 Permissions Explanation:
  • 660 = Owner (read/write) + Group (read/write) + Others (none)
  • Owner: store user can write logs
  • Group: www-data can read logs for monitoring

Step 5: Verify Socket Configuration

sudo grep "listen = /" store.expert-wp.conf

This command confirms the socket file path. Example output:

listen = /run/php/php8.3-fpm-store.sock

Step 6: Create Temporary Directory

cd /var/www/store.example.com/
sudo mkdir tmp/
sudo chown store:store tmp/
sudo chmod 770 tmp/
📊 Pool Resource Limits Explained:
Directive Value Purpose
rlimit_files 15000 Maximum open file descriptors
rlimit_core 100 Core dump size limit
memory_limit 256M Maximum memory per PHP process

⚙️ NGINX Server Block Configuration

The NGINX server block acts as the entry point for HTTP/HTTPS requests. Proper configuration ensures secure communication, correct PHP processing, and optimal performance.

Step 1: Navigate to Sites Directory

cd /etc/nginx/sites-available/

Step 2: Edit Server Block

sudo nano store.example.com.conf

Step 3: Update PHP-FPM Socket Reference

Locate the PHP processing location block and update the fastcgi_pass directive:

location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_param HTTP_HOST $host; fastcgi_pass unix:/run/php/php8.3-fpm-store.sock; include /etc/nginx/includes/fastcgi_optimize.conf; }
⚠️ Critical: The socket path must exactly match the path defined in your PHP-FPM pool configuration.

Step 4: Test and Reload NGINX

sudo nginx -t

Expected output: "syntax is ok" and "test is successful"

sudo systemctl reload nginx

Step 5: Reload PHP-FPM

sudo systemctl reload php8.3-fpm

Request Processing Flow

Client Request
(Browser)
NGINX
(Port 80/443)
PHP-FPM Pool
(Unix Socket)
WordPress
(PHP Processing)

🔐 Ownership & Permissions Management

Proper file permissions are crucial for security. We implement a two-tier approach: standard permissions for development and hardened permissions for production environments.

Navigate to Site Directory

cd /var/www/store.example.com/

Standard Permissions (Development)

Use these permissions during active development and content updates:

Set Ownership

sudo chown -R store:store public_html/

Directory Permissions (770)

sudo find /var/www/store.example.com/public_html/ -type d -exec chmod 770 {} \;

770 = Owner: rwx, Group: rwx, Others: ---

File Permissions (660)

sudo find /var/www/store.example.com/public_html/ -type f -exec chmod 660 {} \;

660 = Owner: rw-, Group: rw-, Others: ---

Hardened Permissions (Production)

Apply these restrictive permissions once your site is fully configured and ready for production:

Base Directory Permissions (550/440)

sudo find /var/www/store.example.com/public_html/ -type d -exec chmod 550 {} \;
sudo find /var/www/store.example.com/public_html/ -type f -exec chmod 440 {} \;

WP-Content Directory (770/660)

WordPress needs write access to wp-content for uploads, plugins, and themes:

sudo find /var/www/store.example.com/public_html/wp-content/ -type d -exec chmod 770 {} \;
sudo find /var/www/store.example.com/public_html/wp-content/ -type f -exec chmod 660 {} \;

WP-Config Security

sudo chmod 400 public_html/wp-config.php

400 = Owner: r--, Group: ---, Others: --- (Read-only for owner)

📊 Permission Levels Comparison:
Location Development Production Purpose
Directories (base) 770 550 Execute/traverse directories
Files (base) 660 440 Read-only for web server
wp-content (dirs) 770 770 Allow uploads/modifications
wp-content (files) 660 660 Allow file modifications
wp-config.php 400 400 Maximum security
⚠️ Important: When applying hardened permissions, you may need to temporarily switch back to standard permissions (770/660) when:
  • Installing or updating plugins
  • Installing or updating themes
  • Performing WordPress core updates

🔒 Wildcard SSL Certificate Installation

A wildcard SSL certificate allows you to secure unlimited subdomains under a single certificate. This simplifies certificate management and reduces renewal overhead.

What is a Wildcard SSL Certificate?

A wildcard certificate uses an asterisk (*) as a wildcard character in the domain name field, allowing it to secure any subdomain. For example, a certificate for *.example.com will secure:

  • store.example.com
  • blog.example.com
  • shop.example.com
  • Any other subdomain

Step 1: Cloudflare API Configuration

Log in to your Cloudflare account and navigate to: My Profile → API Tokens → Global API Key

💡 Security Note: The Global API Key provides full account access. Store it securely and never share it publicly.

Step 2: Create Credentials Directory

sudo mkdir /root/.credentials_cloudflare/

Step 3: Create Credentials File

sudo nano /root/.credentials_cloudflare/cf

Add the following content (replace with your actual credentials):

dns_cloudflare_email = "[email protected]" dns_cloudflare_api_key = "your-global-api-key-here"

Step 4: Secure Credentials

sudo chmod 400 /root/.credentials_cloudflare/cf
sudo chmod 700 /root/.credentials_cloudflare/

Step 5: Test Certificate Installation (Dry Run)

sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.credentials_cloudflare/cf -d *.example.com --preferred-challenges dns-01 --dry-run
📌 Command Breakdown:
  • certonly - Only obtain certificate, no automated configuration
  • --dns-cloudflare - Use Cloudflare DNS validation method
  • --dns-cloudflare-credentials - Path to API credentials file
  • -d *.example.com - Domain to secure (wildcard)
  • --preferred-challenges dns-01 - Use DNS validation method
  • --dry-run - Test without actually obtaining certificate

Step 6: Install Certificate (Remove --dry-run)

sudo certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.credentials_cloudflare/cf -d *.example.com --preferred-challenges dns-01
⚠️ Troubleshooting: If you receive a "DNS problem: NXDOMAIN" error, wait 2-5 minutes and try again. DNS propagation can take time.

Step 7: Verify Certificate Installation

sudo certbot certificates

Expected output will show certificate paths:

Certificate Path: /etc/letsencrypt/live/example.com-0001/fullchain.pem Private Key Path: /etc/letsencrypt/live/example.com-0001/privkey.pem

Step 8: Create SSL Include File

cd /etc/nginx/ssl/
sudo nano ssl_wildcard_example.com.conf
# Wildcard SSL Certificate Configuration # This file can be used for ALL subdomains under example.com ssl_certificate /etc/letsencrypt/live/example.com-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com-0001/privkey.pem; # SSL STAPLING ssl_trusted_certificate /etc/letsencrypt/live/example.com-0001/chain.pem;

SSL Certificate Architecture

*.example.com
Wildcard Cert
store.example.com
Subdomain 1
blog.example.com
Subdomain 2
shop.example.com
Subdomain 3

🛡️ Security Hardening Implementation

This section covers comprehensive security measures including HTTPS redirection, HTTP/2, HTTP/3, security headers, and rate limiting.

HTTPS Configuration

Step 1: Update Server Block for SSL

cd /etc/nginx/sites-available/
sudo nano store.example.com.conf

Step 2: Add HTTP to HTTPS Redirect

# HTTP to HTTPS Redirect (Port 80) server { listen 80; server_name store.example.com; # 301 Permanent Redirect to HTTPS return 301 https://store.example.com$request_uri; }

Step 3: Configure HTTPS Server Block

# HTTPS Server Block (Port 443) server { # HTTP/2 Configuration listen 443 ssl; http2 on; # HTTP/3 Configuration listen 443 quic; http3 on; server_name store.example.com; root /var/www/store.example.com/public_html; index index.php; # SSL Certificate Include Files include /etc/nginx/ssl/ssl_wildcard_example.com.conf; include /etc/nginx/ssl/ssl_all_sites.conf; # HTTP Headers Security include /etc/nginx/includes/http_headers.conf; # WordPress Security Directives include /etc/nginx/includes/wp_security.conf; # Rate Limiting include /etc/nginx/includes/rate_limiting_store.example.com.conf; location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_param HTTP_HOST $host; fastcgi_pass unix:/run/php/php8.3-fpm-store.sock; include /etc/nginx/includes/fastcgi_optimize.conf; } include /etc/nginx/includes/browser_caching.conf; access_log /var/log/nginx/access_store.example.com.log combined buffer=256k flush=60m; error_log /var/log/nginx/error_store.example.com.log; }
💡 HTTP/3 Testing: To test HTTP/3 without browser caching, temporarily add this directive to the location / context:
add_header Cache-Control 'no-cache,no-store';
Remove this directive after confirming HTTP/3 is working!

Security Headers Configuration

Security headers protect against common web vulnerabilities. Create or verify the http_headers.conf file:

cd /etc/nginx/includes/
sudo nano http_headers.conf
# Security Headers Configuration # Prevent clickjacking attacks add_header X-Frame-Options "SAMEORIGIN" always; # Prevent MIME type sniffing add_header X-Content-Type-Options "nosniff" always; # Enable XSS protection add_header X-XSS-Protection "1; mode=block" always; # Referrer policy add_header Referrer-Policy "strict-origin-when-cross-origin" always; # Content Security Policy (adjust as needed) add_header Content-Security-Policy "default-src 'self' https: data: 'unsafe-inline' 'unsafe-eval';" always; # HTTP Strict Transport Security (HSTS) add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

Rate Limiting Configuration

Rate limiting prevents brute force attacks on wp-login.php and xmlrpc.php:

Step 1: Copy Rate Limiting Template

cd /etc/nginx/includes/
sudo cp rate_limiting_example.com.conf rate_limiting_store.example.com.conf

Step 2: Configure Rate Limiting

sudo nano rate_limiting_store.example.com.conf
# Rate Limiting for WordPress Login location = /wp-login.php { limit_req zone=wp burst=20 nodelay; limit_req_status 444; include snippets/fastcgi-php.conf; fastcgi_param HTTP_HOST $host; fastcgi_pass unix:/run/php/php8.3-fpm-store.sock; include /etc/nginx/includes/fastcgi_optimize.conf; } # Rate Limiting for XML-RPC location = /xmlrpc.php { limit_req zone=wp burst=20 nodelay; limit_req_status 444; include snippets/fastcgi-php.conf; fastcgi_param HTTP_HOST $host; fastcgi_pass unix:/run/php/php8.3-fpm-store.sock; include /etc/nginx/includes/fastcgi_optimize.conf; }
📊 Rate Limiting Explained:
  • zone=wp - Uses the "wp" rate limiting zone defined in nginx.conf
  • burst=20 - Allows up to 20 requests in a burst
  • nodelay - Processes burst requests immediately
  • limit_req_status 444 - Returns error 444 (connection closed) when limit exceeded

Test and Apply Configuration

sudo nginx -t
sudo systemctl reload nginx

Verify HTTPS Redirect

curl -I http://store.example.com

Expected output: 301 Moved Permanently

curl -I https://store.example.com

Expected output: 200 OK

SSL/TLS Testing

Test your SSL configuration at: https://www.ssllabs.com/ssltest/

Expected rating: A+

HTTP/3 Testing

Test HTTP/3 support at: https://http3check.net/

Also verify in browser DevTools: Network tab → Protocol column → should show h3

✅ Security Checklist:
  • HTTPS redirect configured (301 permanent)
  • HTTP/2 and HTTP/3 enabled
  • Wildcard SSL certificate installed
  • Security headers implemented
  • Rate limiting active on wp-login.php and xmlrpc.php
  • SSL/TLS rating: A+

⚡ WordPress Optimization

Optimize WordPress for performance through database configuration, caching, OPcache, and WP-Cron management.

Database Optimization

Revoke Unnecessary Privileges

For enhanced security, limit database user privileges to only what WordPress requires:

mysql -u root -p
REVOKE ALL PRIVILEGES ON site_db.* FROM 'site_user'@'localhost'; GRANT SELECT, INSERT, UPDATE, DELETE ON site_db.* TO 'site_user'@'localhost'; FLUSH PRIVILEGES; EXIT;
💡 Privilege Explanation:
  • SELECT - Read data from tables
  • INSERT - Add new data
  • UPDATE - Modify existing data
  • DELETE - Remove data

These four privileges are sufficient for normal WordPress operation. Additional privileges (CREATE, DROP, ALTER) are only needed during plugin/theme installation or WordPress updates.

WordPress Constants (wp-config.php)

Disable Post Revisions

/** DISABLE POST REVISIONS */ define('WP_POST_REVISIONS', false);

Increase Memory Limit

/** MEMORY LIMIT */ define('WP_MEMORY_LIMIT', '256M');

Also update PHP pool configuration:

sudo nano /etc/php/8.3/fpm/pool.d/store.expert-wp.conf

Uncomment and modify:

php_admin_value[memory_limit] = 256M

Disable WP-Cron

/** DISABLE WP-CRON */ define('DISABLE_WP_CRON', true);

Setup System Cron Job

crontab -e

Add the following line (runs every 15 minutes):

*/15 * * * * wget -q -O - https://store.example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
📌 Why Disable WP-Cron?

WordPress's built-in cron system runs on page loads, which can:

  • Cause performance issues on high-traffic sites
  • Execute unpredictably during low-traffic periods
  • Consume unnecessary resources

System cron provides reliable, scheduled execution independent of site traffic.

OPcache Configuration

OPcache significantly improves PHP performance by storing precompiled script bytecode in memory.

Edit PHP Pool Configuration

sudo nano /etc/php/8.3/fpm/pool.d/store.expert-wp.conf

Development Server Configuration

; OPCACHE CONFIGURATION - DEVELOPMENT php_admin_value[opcache.memory_consumption] = 256 php_admin_value[opcache.interned_strings_buffer] = 32 php_admin_value[opcache.max_accelerated_files] = 20000 php_admin_flag[opcache.validate_timestamps] = 1 php_admin_value[opcache.revalidate_freq] = 2 php_admin_flag[opcache.validate_permission] = 1

Production Server Configuration

; OPCACHE CONFIGURATION - PRODUCTION php_admin_value[opcache.memory_consumption] = 256 php_admin_value[opcache.interned_strings_buffer] = 32 php_admin_value[opcache.max_accelerated_files] = 20000 php_admin_value[opcache.validate_timestamps] = 0 php_admin_flag[opcache.validate_permission] = 1
📊 OPcache Settings Explained:
Directive Value Purpose
memory_consumption 256 Memory allocated for OPcache (MB)
interned_strings_buffer 32 Memory for storing strings (MB)
max_accelerated_files 20000 Maximum cached PHP files
validate_timestamps 1 (dev) / 0 (prod) Check file modifications (0=disabled for performance)
revalidate_freq 2 Seconds between timestamp checks (dev only)

Caching Plugins

WP Super Cache Configuration

Create exclusion rules for dynamic content:

sudo nano /etc/nginx/includes/wp_super_cache_exclusions.conf
# WP Super Cache Exclusions # Don't cache these pages set $cache_uri $request_uri; # Don't cache POST requests if ($request_method = POST) { set $cache_uri 'null cache'; } # Don't cache query strings if ($query_string != "") { set $cache_uri 'null cache'; } # Don't cache logged-in users or recent commenters if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in") { set $cache_uri 'null cache'; } # Don't cache WooCommerce pages if ($request_uri ~* "(/cart|/checkout|/my-account)") { set $cache_uri 'null cache'; }

Redis Configuration (Advanced)

If using Redis object caching, add to wp-config.php:

/** REDIS CACHE KEY SALT */ define('WP_CACHE_KEY_SALT', 'store.example.com'); /** PREVENT REDIS CACHING WOO SESSION DATA */ define('WP_REDIS_IGNORED_GROUPS', 'wc_session');

Apply Changes

sudo systemctl reload php8.3-fpm
sudo systemctl reload nginx
✅ Optimization Checklist:
  • Database privileges minimized
  • Post revisions disabled
  • Memory limit increased to 256M
  • WP-Cron disabled and system cron configured
  • OPcache enabled and optimized
  • Caching plugin configured with proper exclusions

Performance Optimization Stack

Browser Cache
(Static Assets)
Page Cache
(WP Super Cache)
Object Cache
(Redis)
OPcache
(PHP Bytecode)

📚 Additional Resources

Testing Tools

Documentation Links

Best Practices Summary

🎯 Key Takeaways:
  1. Isolation: Each subdomain should have its own user, PHP pool, and log files
  2. Permissions: Use 770/660 for development, 550/440 for production (except wp-content)
  3. SSL: Wildcard certificates simplify management for multiple subdomains
  4. Security: Implement rate limiting, security headers, and minimal database privileges
  5. Performance: Enable OPcache, configure caching plugins, and use system cron
  6. Monitoring: Regularly check logs and test SSL/security configurations

Troubleshooting Common Issues

Issue Possible Cause Solution
502 Bad Gateway PHP-FPM pool not running Check pool config and restart: sudo systemctl restart php8.3-fpm
Permission Denied Incorrect file ownership Reset ownership: sudo chown -R store:store public_html/
SSL Certificate Error DNS not propagated Wait 5-10 minutes and retry certificate installation
File Upload Fails Hardened permissions on wp-content Ensure wp-content has 770/660 permissions