Setting Up a Nginx Reverse Proxy on a VPS for WordPress: A Production-Ready Tutorial

Written by:

·

Last Updated on:

·

HostingGuider uses affiliate links. We may earn a commission if you purchase through them, at no extra cost to you.

This tutorial sets up a real production WordPress server.

The architecture uses Nginx as a reverse proxy in front of Apache. Nginx handles incoming connections, terminates SSL, serves static files directly, and forwards dynamic requests to Apache. Apache handles WordPress through mod_php and benefits from WordPress’s native .htaccess support.

This combination delivers the speed of Nginx for static content with the WordPress compatibility of Apache. It is the setup that powers many high-traffic WordPress sites in production.

Every command in this tutorial is verified. Every configuration file is shown in full. Every step includes a verification check. If you follow this guide in order, you will end with a fast, secure, production-ready WordPress server.

This tutorial assumes Ubuntu 22.04 LTS. If you have not set up a VPS yet, start with the VPS setup tutorial first.

What You Will Build

By the end of this guide, your server will run this architecture:

Internet
   ↓
Nginx (ports 80/443) — handles SSL, static files, caching
   ↓
Apache (port 8080, localhost only) — handles PHP via mod_php
   ↓
PHP (mod_php) — executes WordPress
   ↓
MariaDB (port 3306, localhost only) — stores WordPress data
Nginx reverse proxy architecture diagram showing request flow to Apache and MariaDB
Request flow from Nginx reverse proxy to Apache and MariaDB

What You Will Need

  • A VPS running Ubuntu 22.04 LTS (other versions work with minor adjustments)
  • Root or sudo access via SSH
  • A domain name pointing to your VPS IP address
  • At least 2GB RAM (4GB recommended for production)
  • At least 20GB storage

This setup runs well on most VPS plans. Hostinger VPS, Contabo, and ScalaHosting all provide infrastructure suitable for this setup at reasonable prices.

Key Takeaways

  • This architecture combines Nginx speed with Apache WordPress compatibility
  • Nginx handles all incoming traffic on ports 80 and 443
  • Apache runs on a different port (8080) accessible only from localhost
  • Static files (images, CSS, JS) are served directly by Nginx without touching Apache
  • Dynamic requests pass through to Apache for PHP processing
  • Real visitor IPs are correctly forwarded through the proxy chain
  • The full setup takes about two hours to complete properly

Phase 1: Prerequisites and Server Preparation

Step 1.1: Connect to Your VPS

Open a terminal on your computer. Connect via SSH:

ssh root@YOUR.VPS.IP.ADDRESS

Replace YOUR.VPS.IP.ADDRESS with your actual server IP.

Step 1.2: Update the System

Always start with a fully updated system:

sudo apt update && sudo apt upgrade -y

This downloads the latest package information and upgrades all installed packages. Wait for it to complete.

Step 1.3: Set the Hostname

Set a meaningful hostname for your server:

sudo hostnamectl set-hostname wordpress-prod

Replace wordpress-prod with whatever name you prefer.

Verify:

hostname

Step 1.4: Set the Timezone

Setting the correct timezone matters for log timestamps and scheduled tasks:

sudo timedatectl set-timezone UTC

Use UTC unless you have a specific reason for a different timezone. UTC simplifies log analysis across servers.

Verify:

timedatectl

Step 1.5: Configure the Firewall

Install and enable UFW (Uncomplicated Firewall):

sudo apt install ufw -y

Allow SSH so you do not lock yourself out:

sudo ufw allow OpenSSH

Allow HTTP and HTTPS for web traffic:

sudo ufw allow 80
sudo ufw allow 443

Enable the firewall:

sudo ufw enable

Type y and press Enter when prompted.

Verify rules:

sudo ufw status

Expected output:

Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80                         ALLOW       Anywhere
443                        ALLOW       Anywhere

Apache port 8080 and MariaDB port 3306 are intentionally NOT opened. They will only be accessible from localhost.

Step 1.6: Create a Non-Root User

Working as root all the time is risky. Create a regular user:

sudo adduser webadmin

Enter a strong password when prompted. Press Enter to skip the optional fields.

Add the user to the sudo group:

sudo usermod -aG sudo webadmin

Now switch to this user for the rest of the tutorial:

su - webadmin

You will use sudo for commands that need root privileges.

Phase 2: Install Apache (The Backend)

Apache will run on port 8080, listening only on localhost. Nginx will forward dynamic requests to it.

Step 2.1: Install Apache

sudo apt install apache2 -y

Verify it is running:

sudo systemctl status apache2

Look for Active: active (running). Press q to exit.

Step 2.2: Reconfigure Apache for Port 8080

Apache normally listens on port 80. We need it on 8080 because Nginx will use port 80.

Edit the ports configuration:

sudo nano /etc/apache2/ports.conf

Replace the entire content with:

Listen 127.0.0.1:8080

<IfModule ssl_module>
    Listen 127.0.0.1:8443
</IfModule>

<IfModule mod_gnutls.c>
    Listen 127.0.0.1:8443
</IfModule>

The 127.0.0.1: prefix is critical. It binds Apache to localhost only. External traffic cannot reach Apache directly. This is a security best practice.

Save with Ctrl+X, then Y, then Enter.

Step 2.3: Update the Default Site

Edit the default virtual host:

sudo nano /etc/apache2/sites-available/000-default.conf

Change the first line from <VirtualHost *:80> to:

<VirtualHost 127.0.0.1:8080>

Save and exit.

Step 2.4: Install mod_rpaf for Real IP Forwarding

Apache needs to know the real visitor IP, not the Nginx server IP. The mod_rpaf module handles this.

sudo apt install libapache2-mod-rpaf -y

Configure it:

sudo nano /etc/apache2/mods-available/rpaf.conf

Replace the entire content with:

<IfModule rpaf_module>
    RPAF_Enable             On
    RPAF_ProxyIPs           127.0.0.1
    RPAF_SetHostName        On
    RPAF_SetHTTPS           On
    RPAF_SetPort            On
    RPAF_ForbidIfNotProxy   Off
    RPAF_Header             X-Forwarded-For
</IfModule>

Save and exit.

Step 2.5: Enable Required Apache Modules

sudo a2enmod rpaf
sudo a2enmod rewrite
sudo a2enmod headers
sudo a2enmod expires

Step 2.6: Restart Apache

sudo systemctl restart apache2

Verify Apache is now listening only on localhost:

sudo ss -tlnp | grep apache2

Expected output:

LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* users:(("apache2",pid=...))

The 127.0.0.1:8080 confirms Apache is correctly bound to localhost only.

Phase 3: Install MariaDB (The Database)

Step 3.1: Install MariaDB

sudo apt install mariadb-server -y

Step 3.2: Secure the Installation

Run the security setup wizard:

sudo mysql_secure_installation

Answer the prompts:

PromptAnswer
Enter current password for rootPress Enter (no password set yet)
Switch to unix_socket authenticationn
Change the root passwordY
New passwordEnter a strong password and write it down
Re-enter new passwordRe-enter the password
Remove anonymous usersY
Disallow root login remotelyY
Remove test databaseY
Reload privilege tablesY

Step 3.3: Create the WordPress Database

Log into MariaDB:

sudo mariadb

Create the database:

CREATE DATABASE wordpress_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Create a database user. Replace your_strong_password with a real strong password:

CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'your_strong_password';

Grant permissions:

GRANT ALL PRIVILEGES ON wordpress_db.* TO 'wp_user'@'localhost';

Apply changes:

FLUSH PRIVILEGES;

Exit:

EXIT;

Write down these three values. You will need them for WordPress installation:

  • Database name: wordpress_db
  • Database user: wp_user
  • Database password: the password you just set

Phase 4: Install PHP and Required Modules

Step 4.1: Install PHP

sudo apt install php php-mysql libapache2-mod-php php-curl php-gd php-mbstring php-xml php-xmlrpc php-soap php-intl php-zip php-imagick php-bcmath -y

This installs PHP plus every module WordPress and most plugins require.

Step 4.2: Configure PHP for Production

Find your PHP version:

php -v

The first line shows your version (likely PHP 8.1 or 8.2). Use this version number in the next command.

Edit php.ini:

sudo nano /etc/php/8.1/apache2/php.ini

Find and update these settings (use Ctrl+W to search for each):

SettingProduction Value
upload_max_filesize64M
post_max_size64M
memory_limit256M
max_execution_time300
max_input_time300
max_input_vars3000
display_errorsOff
log_errorsOn
error_log/var/log/php_errors.log

Save and exit.

Create the error log file:

sudo touch /var/log/php_errors.log
sudo chown www-data:www-data /var/log/php_errors.log

Step 4.3: Enable PHP OPcache

OPcache stores compiled PHP code in memory. It significantly improves WordPress performance.

sudo nano /etc/php/8.1/apache2/conf.d/10-opcache.ini

Add or update these lines:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.enable_cli=0

Save and exit.

Step 4.4: Restart Apache

sudo systemctl restart apache2

Step 4.5: Verify PHP

Create a test file:

sudo nano /var/www/html/info.php

Add:

<?php
phpinfo();
?>

Save and exit.

Test from the server itself (because Apache only listens on localhost):

curl http://127.0.0.1:8080/info.php | head -50

You should see PHP information output. If you do, PHP is working correctly.

Delete the test file immediately (it exposes server information):

sudo rm /var/www/html/info.php

Phase 5: Install Nginx (The Reverse Proxy)

Step 5.1: Install Nginx

sudo apt install nginx -y

Nginx starts automatically. Verify:

sudo systemctl status nginx

Look for Active: active (running). Press q to exit.

Step 5.2: Remove the Default Configuration

sudo rm /etc/nginx/sites-enabled/default

Step 5.3: Create the WordPress Site Configuration

Create a new configuration file. Replace yourdomain.com with your actual domain throughout:

sudo nano /etc/nginx/sites-available/yourdomain.com.conf

Paste this complete configuration:

# Upstream Apache backend
upstream apache_backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

# Redirect HTTP to HTTPS (will be enabled after SSL setup)
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    # Allow Let's Encrypt verification
    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# Main HTTPS server (will be activated after SSL setup)
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    # SSL certificates (Certbot will fill these in)
    # ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    # ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    # SSL settings (modern profile)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Document root for static file serving
    root /var/www/yourdomain.com;
    index index.php index.html;

    # Logging
    access_log /var/log/nginx/yourdomain.com-access.log;
    error_log /var/log/nginx/yourdomain.com-error.log warn;

    # 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=31536000; includeSubDomains" always;

    # Block access to sensitive files
    location ~ /\.(?!well-known) {
        deny all;
        access_log off;
        log_not_found off;
    }

    location = /wp-config.php {
        deny all;
    }

    location ~* \.(log|sql|conf|bak)$ {
        deny all;
    }

    # Serve static files directly from Nginx (bypass Apache)
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|ttf|svg|webp|avif)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        try_files $uri @apache;
    }

    # Limit upload size
    client_max_body_size 64M;

    # Proxy all dynamic requests to Apache
    location / {
        try_files $uri $uri/ @apache;
    }

    location @apache {
        proxy_pass http://apache_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        proxy_read_timeout 300s;
        proxy_connect_timeout 75s;
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 8 16k;
    }

    # Handle PHP files that should be processed by Apache
    location ~ \.php$ {
        proxy_pass http://apache_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
    }
}

Save and exit.

Step 5.4: Create the WordPress Directory

sudo mkdir -p /var/www/yourdomain.com
sudo chown -R www-data:www-data /var/www/yourdomain.com

Step 5.5: Temporarily Comment Out the HTTPS Server

Until you have an SSL certificate, the HTTPS server block will fail. Comment it out for now.

Edit the configuration:

sudo nano /etc/nginx/sites-available/yourdomain.com.conf

Add a # at the start of every line in the HTTPS server block (the one starting with server { listen 443 ssl http2;). You can also temporarily delete it and add it back after SSL setup.

A faster way using sed:

sudo sed -i 's/^server {$/#server {/g' /etc/nginx/sites-available/yourdomain.com.conf

Actually, the cleaner approach is to comment out just the HTTPS block. Use nano and manually add # at the start of each line in the HTTPS server block.

Also temporarily change the HTTP block to serve content directly:

In the HTTP server block, replace:

location / {
    return 301 https://$host$request_uri;
}

With:

location / {
    try_files $uri $uri/ @apache;
}

location @apache {
    proxy_pass http://apache_backend;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

root /var/www/yourdomain.com;
index index.php index.html;

After SSL is set up later, you will revert this to the HTTPS redirect.

Step 5.6: Enable the Site

Create the symlink:

sudo ln -s /etc/nginx/sites-available/yourdomain.com.conf /etc/nginx/sites-enabled/

Step 5.7: Test and Reload Nginx

Always test before reloading:

sudo nginx -t

Expected output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If there are errors, fix them before continuing. The error message tells you the file and line number.

Reload Nginx:

sudo systemctl reload nginx

Phase 6: Install WordPress

Step 6.1: Download WordPress

Move to a temporary directory:

cd /tmp

Download the latest WordPress:

wget https://wordpress.org/latest.tar.gz

Extract:

tar -xzf latest.tar.gz

Step 6.2: Move WordPress Files

sudo cp -r wordpress/. /var/www/yourdomain.com/

Set correct ownership:

sudo chown -R www-data:www-data /var/www/yourdomain.com

Set correct permissions:

sudo find /var/www/yourdomain.com -type d -exec chmod 755 {} \;
sudo find /var/www/yourdomain.com -type f -exec chmod 644 {} \;

Step 6.3: Create wp-config.php

cd /var/www/yourdomain.com
sudo cp wp-config-sample.php wp-config.php
sudo nano wp-config.php

Find these three lines and update with your database details from Phase 3:

define('DB_NAME', 'wordpress_db');
define('DB_USER', 'wp_user');
define('DB_PASSWORD', 'your_strong_password');

Step 6.4: Generate Secure Salts

Find the section that says:

define('AUTH_KEY',         'put your unique phrase here');

Visit the WordPress salt generator from a browser:

https://api.wordpress.org/secret-key/1.1/salt/

Copy the generated keys. Replace the entire put your unique phrase here block in wp-config.php with the copied content.

Step 6.5: Add Reverse Proxy Configuration to WordPress

WordPress needs to know it is behind a reverse proxy. Add these lines to wp-config.php just before the line that says /* That's all, stop editing! Happy publishing. */:

/* Reverse Proxy Configuration */
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_FORWARDED_FOR'];
}

/* Force SSL for admin */
define('FORCE_SSL_ADMIN', true);

/* Disable file editing in admin */
define('DISALLOW_FILE_EDIT', true);

/* WordPress automatic updates */
define('WP_AUTO_UPDATE_CORE', 'minor');

Save and exit.

Step 6.6: Set Final Permissions

sudo chown -R www-data:www-data /var/www/yourdomain.com
sudo chmod 640 /var/www/yourdomain.com/wp-config.php

The 640 permission for wp-config.php is stricter than other files because it contains database credentials.

Step 6.7: Complete WordPress Installation in Browser

Open your domain in a browser:

http://yourdomain.com

You should see the WordPress installation screen. If you do not:

  • Check Nginx error log: sudo tail -50 /var/log/nginx/yourdomain.com-error.log
  • Check Apache error log: sudo tail -50 /var/log/apache2/error.log
  • Verify DNS is pointing to your VPS: dig +short yourdomain.com

Fill in the WordPress setup form. Create your admin user with a strong password. Click Install WordPress.

You can now log in at http://yourdomain.com/wp-admin.

Phase 7: SSL/TLS Certificate with Let’s Encrypt

Step 7.1: Install Certbot

sudo apt install certbot python3-certbot-nginx -y

Step 7.2: Generate the Certificate

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot asks for an email address (for renewal notifications) and asks you to agree to the terms of service.

When asked about redirecting HTTP to HTTPS, choose option 2 (redirect). This automatically configures the redirect.

Certbot updates your Nginx configuration with the SSL certificate paths and reloads Nginx.

Step 7.3: Update Nginx Configuration

Certbot’s automatic modifications may not include all the optimizations from the earlier configuration. Verify the configuration still includes everything:

sudo nano /etc/nginx/sites-available/yourdomain.com.conf

Ensure both the HTTP-to-HTTPS redirect and the full HTTPS server block from Step 5.3 are present. Restore any commented sections from Step 5.5.

The final HTTP block should look like this:

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.com www.yourdomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/html;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

The HTTPS block needs the SSL certificate lines added by Certbot (or you can add them manually):

ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Step 7.4: Update WordPress Site URLs to HTTPS

Log into WordPress admin. Go to Settings then General. Change:

  • WordPress Address (URL) from http://yourdomain.com to https://yourdomain.com
  • Site Address (URL) from http://yourdomain.com to https://yourdomain.com

Save changes. WordPress logs you out. Log back in at the new HTTPS URL.

Step 7.5: Test SSL Auto-Renewal

Let’s Encrypt certificates expire every 90 days. Certbot installs a systemd timer that renews them automatically. Test the renewal process:

sudo certbot renew --dry-run

You should see Congratulations, all simulated renewals succeeded.

Step 7.6: Verify SSL Quality

Run an external SSL test against your domain at SSL Labs. You should achieve at least an A grade. The configuration in this tutorial typically scores A+.

Phase 8: Performance Optimization

Step 8.1: Configure Nginx Worker Processes

Edit the main Nginx configuration:

sudo nano /etc/nginx/nginx.conf

In the events block, set:

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

Set worker_processes to match your CPU cores. Find them:

nproc

In the top-level config (outside any block), update:

worker_processes auto;
worker_rlimit_nofile 65535;

In the http block, add or update:

http {
    # Existing settings...

    keepalive_timeout 65;
    keepalive_requests 100;

    client_body_timeout 12;
    client_header_timeout 12;
    send_timeout 10;

    # Buffer sizes
    client_body_buffer_size 16K;
    client_header_buffer_size 1k;
    large_client_header_buffers 4 8k;

    # Compression
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;

    # File cache
    open_file_cache max=10000 inactive=30s;
    open_file_cache_valid 60s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;
}

Save and exit.

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Step 8.2: Configure Apache MPM

Apache’s prefork MPM is inefficient. Switch to event MPM:

sudo a2dismod mpm_prefork
sudo a2dismod php8.1
sudo a2enmod mpm_event
sudo apt install libapache2-mod-fcgid -y

Configure event MPM:

sudo nano /etc/apache2/mods-available/mpm_event.conf

Replace the content with values suited for production:

<IfModule mpm_event_module>
    StartServers             2
    MinSpareThreads          25
    MaxSpareThreads          75
    ThreadLimit              64
    ThreadsPerChild          25
    MaxRequestWorkers        150
    MaxConnectionsPerChild   1000
</IfModule>

Step 8.3: Configure PHP-FPM for Apache

Switch Apache from mod_php to PHP-FPM for better performance:

sudo apt install php8.1-fpm -y
sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.1-fpm

Configure PHP-FPM pool:

sudo nano /etc/php/8.1/fpm/pool.d/www.conf

Update these values:

pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500

Restart services:

sudo systemctl restart php8.1-fpm
sudo systemctl restart apache2

Step 8.4: Install and Configure Redis Object Cache

Redis caches WordPress objects in memory. This dramatically reduces database queries.

sudo apt install redis-server -y

Edit Redis configuration:

sudo nano /etc/redis/redis.conf

Find and update:

maxmemory 256mb
maxmemory-policy allkeys-lru

Restart Redis:

sudo systemctl restart redis-server
sudo systemctl enable redis-server

Install the WordPress Redis plugin from the admin dashboard: Plugins, Add New, search for Redis Object Cache, install and activate.

In the plugin settings, click Enable Object Cache.

Verify Redis is working:

redis-cli ping

Expected response: PONG

Step 8.5: Tune the Kernel for Web Traffic

Several kernel parameters affect web server performance significantly. The kernel parameters that actually move the needle cover this in detail.

The key changes for this setup:

sudo nano /etc/sysctl.conf

Add at the end:

# Web server tuning
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
fs.file-max = 2097152
net.ipv4.tcp_fastopen = 3
net.ipv4.tcp_tw_reuse = 1
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

Apply:

sudo sysctl -p

Phase 9: Security Hardening

Step 9.1: Hide Server Version Information

Stop Nginx from showing its version:

sudo nano /etc/nginx/nginx.conf

In the http block, add:

server_tokens off;

Stop Apache from showing version:

sudo nano /etc/apache2/conf-available/security.conf

Find and update:

ServerTokens Prod
ServerSignature Off

Enable:

sudo a2enconf security
sudo systemctl restart apache2
sudo systemctl reload nginx

Step 9.2: Install Fail2Ban

Fail2Ban blocks IPs that repeatedly fail login attempts.

sudo apt install fail2ban -y

Create a configuration for SSH and Nginx:

sudo nano /etc/fail2ban/jail.local

Add:

[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5

[sshd]

enabled = true port = ssh logpath = %(sshd_log)s

[nginx-http-auth]

enabled = true logpath = /var/log/nginx/yourdomain.com-error.log

[nginx-noscript]

enabled = true port = http,https logpath = /var/log/nginx/yourdomain.com-access.log maxretry = 6

[nginx-badbots]

enabled = true port = http,https logpath = /var/log/nginx/yourdomain.com-access.log maxretry = 2

Start and enable:

sudo systemctl start fail2ban
sudo systemctl enable fail2ban

Verify:

sudo fail2ban-client status

Step 9.3: Configure Automatic Security Updates

sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Select Yes when prompted.

Edit the configuration:

sudo nano /etc/apt/apt.conf.d/50unattended-upgrades

Ensure these lines are enabled (not commented):

"${distro_id}:${distro_codename}-security";
Unattended-Upgrade::Mail "your@email.com";
Unattended-Upgrade::Automatic-Reboot "false";

Step 9.4: Protect WordPress Login

Add basic authentication to wp-login.php at the Nginx level:

sudo apt install apache2-utils -y
sudo htpasswd -c /etc/nginx/.htpasswd admin

Enter a password when prompted.

Edit your Nginx config:

sudo nano /etc/nginx/sites-available/yourdomain.com.conf

Add this location block inside the HTTPS server block:

location ~ /wp-login\.php$ {
    auth_basic "Restricted Access";
    auth_basic_user_file /etc/nginx/.htpasswd;
    try_files $uri @apache;
}

This adds a second password prompt before users can reach the WordPress login page. Brute force attacks now need to crack two passwords.

Reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Phase 10: Testing and Verification

Step 10.1: Test the Full Stack

Visit your site in a browser:

https://yourdomain.com

Verify:

  • Site loads over HTTPS
  • Padlock icon appears
  • No mixed content warnings
  • Pages load quickly

Step 10.2: Verify Real IPs Are Forwarded

Check Nginx access log:

sudo tail -20 /var/log/nginx/yourdomain.com-access.log

The first column shows the visitor IP. If it shows your real IP and not 127.0.0.1, the proxy chain is forwarding correctly.

Step 10.3: Run a Performance Test

Install testing tools:

sudo apt install apache2-utils wrk -y

Test with Apache Bench:

ab -n 1000 -c 50 https://yourdomain.com/

Test with wrk for more accurate results:

wrk -t4 -c50 -d30s --latency https://yourdomain.com/

A well-configured server on a 4GB VPS should handle:

  • 500+ requests per second for cached pages
  • 100+ requests per second for uncached dynamic pages
  • p99 latency under 1 second

Step 10.4: Verify SSL Configuration

Test SSL externally at SSL Labs. You should score A or A+.

Step 10.5: Check All Services Are Running

sudo systemctl status nginx apache2 mariadb php8.1-fpm redis-server fail2ban

All should show active (running).

Linux service status verification infographic
Verify all critical services are running

Phase 11: Backups and Monitoring

Step 11.1: Set Up Automated Backups

Create a backup script:

sudo nano /usr/local/bin/wordpress-backup.sh

Paste:

#!/bin/bash
DATE=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="/var/backups/wordpress"
SITE_DIR="/var/www/yourdomain.com"
DB_NAME="wordpress_db"
DB_USER="wp_user"
DB_PASS="your_strong_password"

mkdir -p $BACKUP_DIR

# Backup files
tar -czf $BACKUP_DIR/files-$DATE.tar.gz -C $SITE_DIR .

# Backup database
mysqldump -u $DB_USER -p$DB_PASS $DB_NAME | gzip > $BACKUP_DIR/db-$DATE.sql.gz

# Keep only last 14 days
find $BACKUP_DIR -name "*.gz" -mtime +14 -delete

echo "Backup completed: $DATE"

Make it executable:

sudo chmod +x /usr/local/bin/wordpress-backup.sh

Add to crontab:

sudo crontab -e

Add this line at the bottom:

0 3 * * * /usr/local/bin/wordpress-backup.sh > /var/log/wordpress-backup.log 2>&1

This runs the backup every day at 3am.

Step 11.2: Set Up Log Rotation

WordPress and Nginx logs grow over time. Configure log rotation:

sudo nano /etc/logrotate.d/yourdomain.com

Add:

/var/log/nginx/yourdomain.com-*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 644 www-data adm
    sharedscripts
    postrotate
        nginx -s reload
    endscript
}

Step 11.3: External Monitoring

Set up free external monitoring at UptimeRobot. Monitor your domain every 5 minutes. Set up email alerts for downtime.

Phase 12: Maintenance Checklist

Production servers need regular attention. Run through this checklist weekly.

TaskCommand
Check disk spacedf -h
Check memoryfree -m
Check service statussudo systemctl status nginx apache2 mariadb php8.1-fpm
Review error logssudo tail -100 /var/log/nginx/yourdomain.com-error.log
Check Fail2Ban banssudo fail2ban-client status sshd
Update packagessudo apt update && sudo apt upgrade -y
Test backups existls -lh /var/backups/wordpress/
Verify SSL is validsudo certbot certificates
Check WordPress updatesVisit /wp-admin/update-core.php

For deeper troubleshooting when problems occur, the VPS troubleshooting guide covers the most common production issues with exact fix commands.

Common Issues and Solutions

WordPress shows wrong URLs

If WordPress shows http:// instead of https:// after the SSL setup, the database may contain http URLs.

Install WP-CLI:

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

Run a search-replace:

cd /var/www/yourdomain.com
sudo -u www-data wp search-replace 'http://yourdomain.com' 'https://yourdomain.com'

502 Bad Gateway error

This means Nginx cannot reach Apache. Check that Apache is running and listening on 127.0.0.1:8080:

sudo systemctl status apache2
sudo ss -tlnp | grep apache2

Check Nginx error log:

sudo tail -50 /var/log/nginx/yourdomain.com-error.log

Real IP not appearing in WordPress

Verify mod_rpaf is loaded:

sudo a2enmod rpaf
sudo systemctl restart apache2

Check the rpaf config has 127.0.0.1 listed in RPAF_ProxyIPs.

Mixed content warnings

If your site shows mixed content warnings after SSL setup, the WordPress database may still reference http:// URLs in posts. Use WP-CLI search-replace as shown above.

Plugin or theme upload fails with permission error

Check the wp-content folder is writable by www-data:

sudo chown -R www-data:www-data /var/www/yourdomain.com/wp-content
sudo find /var/www/yourdomain.com/wp-content -type d -exec chmod 755 {} \;
sudo find /var/www/yourdomain.com/wp-content -type f -exec chmod 644 {} \;

Frequently Asked Questions

Why use Nginx as a reverse proxy instead of just running Nginx alone?

Nginx alone can serve WordPress through PHP-FPM, and many production sites do exactly that. The reverse proxy setup is preferred when you want WordPress’s full .htaccess support (which only works in Apache), need to host applications that require Apache-specific modules, or want to migrate gradually from a pure Apache setup. The hybrid approach gives you Nginx’s static file performance with Apache’s WordPress compatibility.

Will this setup work for multiple WordPress sites?

Yes, with adjustments. Each site needs its own Nginx server block, its own Apache virtual host on a unique port (or using ServerName), its own database, and its own document root. The general architecture stays the same but the configuration files multiply. For more than two or three sites, consider managed multi-site hosting from Cloudways or similar managed providers where this orchestration is handled for you.

How much traffic can this setup handle?

A 4GB RAM VPS configured according to this tutorial typically handles 500 to 1000 concurrent users for cached WordPress pages, and 100 to 200 concurrent users for uncached dynamic pages. WooCommerce stores or sites with heavy database queries reduce these numbers. Adding a CDN like Cloudflare in front of this setup typically doubles or triples the effective capacity by offloading static asset delivery to edge servers.

Do I really need both Nginx and Apache?

For most production WordPress sites, the answer depends on your needs. If you need .htaccess support for specific plugins, hosting integrations, or migration compatibility, the dual setup makes sense. If you are starting fresh and have control over all WordPress configuration, a pure Nginx with PHP-FPM setup is simpler and equally fast for most workloads. The pure Nginx setup uses about 30% less memory than the dual setup but requires translating any .htaccess rules into Nginx configuration manually.

Should I use Apache or PHP-FPM for the backend?

This tutorial uses Apache with mod_php for compatibility reasons. For higher performance, switch Apache to use PHP-FPM as shown in Phase 8 Step 8.3. PHP-FPM gives you better process control, more efficient memory usage, and the ability to tune workers independently from web server connections. The downside is slightly more complex configuration. For sites above 50,000 monthly visitors, PHP-FPM is worth the setup effort.

How do I add more WordPress sites to this server?

For each new site: create a new Nginx server block in /etc/nginx/sites-available/, create a new Apache virtual host in /etc/apache2/sites-available/ on a different port (8081, 8082, etc.) or using ServerName directives on port 8080, create a new database and user in MariaDB, create a new document root, install WordPress in that root, and configure SSL through Certbot for the new domain. Symlink the configurations to sites-enabled and reload both web servers.

How do I move an existing WordPress site to this server?

Export the existing site as a complete backup (files plus database). Set up this server completely first using a placeholder site or a test domain. Then copy the WordPress files to /var/www/yourdomain.com/ and import the database. Update wp-config.php with the new database credentials. Run WP-CLI search-replace to update URLs if the domain changes. Test thoroughly before switching DNS. Allow 24 hours for DNS propagation before considering the migration complete.

What if my hosting provider does not allow installing all these packages?

This typically means you are on managed hosting rather than unmanaged VPS. Managed providers handle this kind of stack configuration for you and do not allow customer-level installation of system packages. If you need the customization this tutorial provides, you need unmanaged VPS access. VPS hosting providers like Hostinger, Contabo, and ScalaHosting all provide unmanaged VPS plans where this tutorial works completely.

About The Author

Hostinger

4.7/5 (62k)
Claim 88% OFF Now

Liquid Web

4.3/5 (2.6k)
Claim 50% OFF Now

WP Engine

4.3/5 (1.6k)
Claim 33% OFF Now