Table of Contents

Most people install WordPress the easy way. We’re going to install it the right way. secure, repeatable and for what it’s worth, it’s still easy.

WordPress doesn’t get hacked because it’s bad. It gets hacked because people cut corners.

WordPress powers more than a third of the web and while it is famous for its five-minute install the truth is that most people set it up in a way that leaves cracks open for attackers and headaches down the road. Installing it the right way is not about making things harder, it is about laying a solid foundation for performance, security, and stability. A rushed or copy-pasted configuration often hides dangerous flaws that bots and scanners know how to exploit. When you take a few extra minutes to configure the database properly, lock down PHP, and use a hardened Nginx setup you are protecting yourself from problems that can take entire sites offline. Think of it like building a house: anyone can put up four walls, but only a careful build will survive a storm. This guide walks through the complete process step by step so you get the benefits of WordPress without the common mistakes.
Alright, Let’s skip the chit-chat

What we’ll be using?

  • System: Ubuntu 22.04 or 24.04 is recommended
  • Database: MariaDB for performance and stability
  • Webserver: NGINX for performance and stability
  • Caching: Redis- high-performance, in-memory data store
  • SSL: CertBot
  • Extras: Nginx Auto-Reload, Brotli Compression, Secure Logging, Email Obfuscation

Ubuntu has become my go-to server operating system ever since Red Hat “Killed” CentOS. The ease of use, extensive documentation, strong community support, and wide availability of up-to-date software packages make it a natural fit for both beginners and professionals. It strikes the right balance between stability and modern features, which is exactly what you want when you’re running something as widely targeted as WordPress.

Prepare Your Server

Let’s get the stack ready. We’ll start by adding Ondrej’s trusted PPA, he maintains up-to-date PHP and Nginx builds for Ubuntu. Then we’ll install Nginx, MariaDB, Redis, acl, and all the PHP 8.4 packages you’ll actually need for WordPress apps.

Bash
sudo apt install python-software-properties
sudo add-apt-repository ppa:ondrej/nginx
sudo add-apt-repository ppa:ondrej/php
sudo apt update && sudo apt upgrade -y
sudo apt install -y nginx nginx-extras mariadb-server unzip curl redis-server imagemagick acl less file
sudo apt install -y php8.4-fpm php8.4-mysql \
 php8.4-curl php8.4-xml php8.4-zip php8.4-gd php8.4-mbstring php8.4-bcmath \
 php8.4-intl php8.4-soap php8.4-imagick php8.4-cli
Bash
# Allow NGINX in the Firewall (For this instance UFW)
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

As always, I do recommend disabling ssh root login via ssh_config and disabling password authentication and resorting to SSH Keys for safety reasons

Setup the Database

When you first install MariaDB, it’s like moving into a new apartment with the doors unlocked, spare keys on the table, and a “Welcome Hacker” mat at the door. The mysql_secure_installation script exists to close those doors before anyone walks in.

Bash
sudo mysql_secure_installation
Switch to unix_socket authentication [Y/n] N
Change the root password? [Y/n] As you wish
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

Create a dedicated database + user for WordPress, This is where most if not all your WordPres data will be stored. So, make sure to choose a strong password.

SQL
# Change the DB/User Names, Password as you wish
CREATE DATABASE wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'VERY_STRONGPASS!';
GRANT ALL PRIVILEGES ON wp.* TO 'wpuser'@'localhost';
FLUSH PRIVILEGES;

Recommended tuning for enhancing Database performance and security, Edit /etc/mysql/mariadb.conf.d/50-server.cnf

/etc/mysql/mariadb.conf.d/50-server.cnf
[mysqld]
bind-address            = 127.0.0.1
# Adjust as you like.
# ~50-70% of RAM on DB-only servers
innodb_buffer_pool_size = 1G 
innodb_log_file_size    = 256M
innodb_flush_method     = O_DIRECT
max_connections         = 200
query_cache_size        = 0
query_cache_type        = 0
Bash
sudo systemctl restart mariadb

Optimize PHP for WordPress

Next, we’ll tweak a few key PHP settings. These small changes make a big difference in both stability and security, especially for high-traffic WordPress sites.
Open /etc/php/8.4/fpm/php.ini or 8.4 and uncomment/append the following

/etc/php/8.4/fpm/php.ini
memory_limit = 256M
; These values are kinda low. So adjust as you like
upload_max_filesize = 64M
; Post_Max_size always needs to be the same as file max upload or higher. Never lower
post_max_size = 64M
max_execution_time = 120

; Security: prevent path-info exploits
cgi.fix_pathinfo = 0
expose_php = Off

; Enable OPcache, dramatically improves performance
zend_extension=opcache
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=1
Bash
# Reload PHP-fpm
sudo systemctl reload php8.4-fpm # or 8.4

Download WordPress via WP-CLI

Instead of managing WordPress through the browser every time, you can use WP-CLI, a shell that lets you install, update, and manage WordPress with simple terminal commands. It’s a must-have for developers, automation scripts, or anyone running multiple sites, Let’s Install WP-CLI

Bash
# Login as a user other than the root, for better security
sudo -u ubuntu -i
curl -sS -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x /usr/local/bin/wp
# You shoulod see WP-Cli Version now
wp --info

If you get a permission error or the version is not showing, use the following command. Otherwise, Skip it.

Bash
curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
| sudo tee /usr/local/bin/wp >/dev/null
sudo chmod +x /usr/local/bin/wp

Next, create the directory that will hold your WordPress files. This is your web root, the place Nginx will serve content from. You can name it anything or place it anywhere on your system, but make sure it’s writable by the web server www-data.

Bash
mkdir -p /var/www/blog
sudo chown -R www-data:www-data /var/www/blog
cd /var/www/blog

With the setup ready and the database created, it’s time to bring WordPress to life. Instead of downloading it manually, we’ll use WP-CLI, faster, cleaner, and perfect for automated setups.

Download & Configure: You can continue Installation using your browser after wp core download --locale=en_US or CLI (Below).

Bash
sudo -u www-data wp core download --locale=en_US
# The DB/User/Password you created earlier
sudo -u www-data wp config create --dbname=wp --dbuser=wpuser --dbpass=VERY_STRONGPASS! --dbhost=localhost --dbprefix=wp_
# Do not use default username: admin, Choose something more secure
sudo -u www-data wp core install --url="https://example.com" --title="My Site" --admin_user="myusername" --admin_password="ANOTHERSTRONGPASS" --admin_email="me@example.com"

Once WordPress is installed, it’s time to lock down its files. The goal is simple: the web server (www-data) should be able to read and write only what it absolutely needs, and nothing more. This drastically reduces the chances of an attacker planting or editing files through a plugin exploit or misconfigured upload.

Bash
find /var/www/blog -type d -exec chmod 755 {} \;
find /var/www/blog -type f -exec chmod 644 {} \;
rm /var/www/blog/wp-config-sample.php
rm /var/www/blog/readme.html

Now for the most sensitive file of all, wp-config.php. It holds your database credentials and keys, so treat it like a root password. then clear session history cause we used plain text password in the shell.

we’re making wp-config.php readable only by www-data, and verifying with getfacl that the Access Control List (ACL) is correct. This ensures the web server can read the file to load WordPress but can’t modify or overwrite it.

Bash
chown ubuntu:www-data /var/www/blog/wp-config.php
chown ubuntu:www-data /var/www/blog/wp-admin
chown ubuntu:www-data /var/www/blog/wp-includes
# WP-CONFIG should always have 600 (-rw-------) or 440 (-r--r-----+) permission
chmod 440 /var/www/blog/wp-config.php
sudo setfacl -m u:www-data:r /var/www/blog/wp-config.php
# ensure mask allows read
sudo setfacl -m m::r /var/www/blog/wp-config.php
getfacl /var/www/blog/wp-config.php
# https://developer.wordpress.org/advanced-administration/security/hardening/#file-permissions
# Clear Session Command History
history -c

Secure and Optimize WordPress

WordPress uses a set of cryptographic keys and salts to secure user sessions and cookies. They make it nearly impossible for anyone to hijack a login or reuse authentication tokens.

Bash
# Set unique salts & keys
sudo -u ubuntu wp config shuffle-salts

Before wrapping up, let’s add a few extra lines to wp-config.php to make WordPress behave more predictably and securely.
Editwp-config.php and append the following just above “That’s all, stop editing!”

PHP
# Disable file editing inside WP-Admin
define('DISALLOW_FILE_EDIT', true);
# Limit Revisions to 5
define('WP_POST_REVISIONS', 5);
# Disable WP cron (use system cron)
define('DISABLE_WP_CRON', true);
# Save/Exit the file

Since we disabled WordPress’s internal cron earlier, we’ll now replace it with a proper system cron job. This ensures scheduled tasks, like publishing posts, sending emails, or running updates, trigger on time, even if your site has low traffic.

Bash
sudo -u www-data crontab -e
# Append the following at the end of the file
* * * * * /usr/local/bin/wp --path=/var/www/blog cron event run --due-now --quiet
Bash
# To list upcoming scheduled events
sudo -u www-data wp cron event list

# To run all pending (due) cron events immediately
sudo -u www-data -H wp cron event run --due-now

Setup NGINX Web Server

Now that WordPress is installed and secured, let’s put it behind a proper web server. We’ll use Nginx, a high-performance, lightweight alternative to Apache to serve your site’s traffic, handle SSL, and even act as a load balancer if you plan to scale later.
First, Let’s setup Certbot to Issue a Free SSL Certificate | Cloudflare API KEY, If you are not using Cloudflare, Find your provider here or use HTTP method.
Keep in mind that “blog.conf” can be renamed anything you like. In case that wasn’t clear.

Bash
sudo apt install -y certbot python3-certbot-dns-cloudflare
sudo mkdir -p /etc/letsencrypt
echo "dns_cloudflare_api_token = YOUR_API_TOKEN_HERE" > /etc/.cloudflare.ini
sudo chmod 600 /root/.cloudflare.ini
Bash
# Issue a Certificate
sudo certbot certonly -d example.com -d "*.example.com" \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/.cloudflare.ini \
  --key-type rsa \
  --rsa-key-size 2048 \
  --agree-tos \
  --force-renewal\
  --no-eff-email\
  --email YOUR@EMAIL.COM
# The cert output should be in /etc/letsencrypt/live/example.com/

Now, Let’s setup NGINX. Config Files: In My Github. Don’t forget to change “example.com” inside the files to your domain and SSL Cert path.

Bash
nano /etc/nginx/nginx.conf
# Apend/Modify the following inside http{} block
http2 on;
ssl_protocols TLSv1.2 TLSv1.3; # Drop v1, v1.1 - Keep only 1.2 & 1.3
Bash
cd /etc/nginx/sites-available/
wget -O /etc/nginx/sites-available/blog.conf https://raw.githubusercontent.com/abdessalllam/cloud-setup/refs/heads/main/wordpress/blog.conf
# Disable directory browsing
echo "autoindex off;" > /etc/nginx/snippets/no-autoindex.conf
# Enable the Website and Reload NGINX
sudo ln -s /etc/nginx/sites-available/blog.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Enable Cloudflare Support

If you’ll be using Cloudflare for with Reverse Proxy, we need to add CF Ranges to make sure our Server gets the real IP of the Visitor. Latest Cloudflare’s IPv4IPv6 Ranges
The same can apply to other Load balancers, All you have to do is modify the file below to include their IP Ranges “only”.

Bash
wget -O /etc/nginx/conf.d/realip.conf https://raw.githubusercontent.com/abdessalllam/cloud-setup/refs/heads/main/wordpress/realip.conf
sudo nginx -t && sudo systemctl reload nginx

This is important, especially if you’ll be using analytics and/or WAF plugin for your WordPress.

All .conf files under conf.d will be included automatically by default
(Optional) Create ip.php in /var/www/blog folder and test if Nginx is getting the real IP. (Delete it after you are done)

ip.php
<?php
header('Content-Type: text/plain');
foreach (['REMOTE_ADDR','HTTP_CF_CONNECTING_IP','HTTP_X_FORWARDED_FOR'] as $h) {
    echo "$h: " . ($_SERVER[$h] ?? '') . "\n";
}

Now, Install Cloudflare plugin and apply recommended settings.

Setup Redis Caching

Redis is one of the best ways to speed up WordPress (object caching, sometimes page caching)

Bash
sudo systemctl enable redis-server
sudo systemctl start redis-server

Secure Redis by editing /etc/redis/redis.conf

/etc/redis/redis.conf
# Append/Uncomment/Modify the following
supervised systemd
maxmemory 256mb                # adjust for your RAM
maxmemory-policy allkeys-lru   # evict least-used keys

Install Essential Plugins

Bash
sudo -u www-data wp plugin install redis-cache --activate
sudo -u www-data wp redis enable
sudo -u www-data wp plugin install nginx-helper --activate

Install & Setup Brotli

Brotli makes your website load faster by sending fewer bytes over the internet – like zipping files before emailing them, but automatic and invisible to the user. It’s better than Gzip.

Bash
# Only with ppa:ondrej/nginx Repo
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
Bash
nano /etc/nginx/nginx.conf
/etc/nginx/nginx.conf
# Append the following to inside http{} block, above "gzip on"
brotli on;
brotli_comp_level 5;
brotli_static on;  # serve precompressed .br if present (harmless if not)
brotli_types
  text/plain text/css text/javascript application/javascript
  application/json application/xml application/rss+xml
  application/vnd.ms-fontobject application/font-sfnt
  application/x-font-ttf font/ttf font/otf image/svg+xml;
  
# Append/Uncomment the following
gzip on;
gzip_comp_level 6;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;

Reload NGINX & Test if Brotli is working

Bash
sudo nginx -t && sudo systemctl reload nginx
# Brotli client
curl -sI -H 'Accept-Encoding: br' https://yourdomain | grep -i content-encoding
# Response → Content-Encoding: br

# Gzip client
curl -sI -H 'Accept-Encoding: gzip' https://yourdomain | grep -i content-encoding
# Response → Content-Encoding: gzip (or nothing if already tiny)

NGINX Auto-Reload

if you start using plugins such as Smush, Yoast SEO Redirects, etc… Any plugin that requires include files in “blog.conf” or “nginx.conf”. Everytime the plugin changes that file, we need to reload NGINX. So let’s create a service that does that automatically.

/etc/systemd/system/nginx-auto-reload.service
# Create a /etc/systemd/system/nginx-auto-reload.service
[Unit]
Description=Validate and reload Nginx when watched files change

[Service]
Type=oneshot
# Validate config & Stop if there are errors
ExecStart=/usr/sbin/nginx -t
# Reload ONLY if ExecStart succeeded
ExecStartPost=/bin/systemctl reload nginx
/etc/systemd/system/nginx-auto-reload.path
# Create /etc/systemd/system/nginx-auto-reload.path
[Unit]
Description=Watch specific Nginx-related files

[Path]
# Add one line per file you want to monitor for changes
# Add/Remove Lines as needed
PathChanged=/var/www/blog/file1.conf
PathChanged=/var/www/blog/file2.conf
PathChanged=/var/www/blog/file3.conf

[Install]
WantedBy=multi-user.target

Enable and Start

Bash
sudo systemctl daemon-reload
sudo systemctl enable --now nginx-auto-reload.path
# (optional first run)
sudo systemctl start nginx-auto-reload.service

Test: Edit one of the monitored files, then

Bash
journalctl -u nginx-auto-reload.service -n 20 --no-pager
# You should see nginx: configuration file /etc/nginx/nginx.conf test is successful followed by a reload. If the config is broken, nginx -t will fail and no reload happens.

Secure & Optimize PHP

Now let’s polish PHP for performance and security. The default php.ini values are meant for development, not production. They’re safe, but far from optimal. These tweaks will make your server faster, safer, and better suited for WordPress at scale.
Modify/Append the following to the appropriate files.

/etc/php/8.4/fpm/php.ini
; Core
expose_php = 0
cgi.fix_pathinfo = 0           ; never guess script names
default_charset = UTF-8
max_execution_time = 120
memory_limit = 512M            ; raise if you run heavy builders
post_max_size = 64M
upload_max_filesize = 64M
max_input_vars = 5000
realpath_cache_size = 4096k
realpath_cache_ttl = 600

; Errors (log, don't leak)
display_errors = Off
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
zend.exception_ignore_args = On

; Sessions (secure cookies)
session.use_strict_mode = 1
session.cookie_httponly = 1
session.cookie_secure = 1      ; keep https-only
session.cookie_samesite = Lax
session.sid_length = 48
session.sid_bits_per_character = 6
; per-pool session.save_path is set in pool conf for isolation

; Opcache (prod profile)
opcache.enable = 1
opcache.enable_cli = 1                 ; faster WP-CLI
opcache.jit = off                      ; JIT is useless for WP
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 64
opcache.max_accelerated_files = 130987 ; ~128k hash slots
opcache.max_wasted_percentage = 5
opcache.validate_timestamps = 0        ; immutable code in prod
opcache.revalidate_freq = 0            ; reload FPM on deploy
opcache.fast_shutdown = 1
opcache.save_comments = 1              ; plugins use reflection
; If you deploy often and don't reload, use:
; opcache.validate_timestamps=1
; opcache.revalidate_freq=60

; Optional: APCu (micro-cache inside each FPM worker)
; install: apt-get install php8.4-apcu
apc.enabled = 1
apc.enable_cli = 0
apc.shm_size = 64M
apc.entries_hint = 4096
apc.ttl = 120
apc.gc_ttl = 240

Now let’s tune PHP-FPM, the FastCGI process manager that actually runs your PHP code. This is where most performance bottlenecks hide, especially on high-traffic WordPress sites.

/etc/php/8.4/fpm/pool.d/www.conf
; PROCESS MODEL
pm = dynamic
; I use: max_children  (MemAvailable - reserve_for_DB_OS) / avg_child_MB
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 4
pm.max_spare_servers = 20
pm.max_requests = 800          ; recycle to kill leaks

; LISTENING
listen = /run/php/php8.4-fpm.sock
listen.mode = 0660
listen.owner = www-data
listen.group = www-data

; SECURITY GUARD RAILS
clear_env = yes
security.limit_extensions = .php
; Harden only FPM SAPI; leave CLI free for wp-cli
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,proc_terminate,proc_get_status,proc_nice,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wexitstatus,pcntl_wifexited,pcntl_wifsignaled,pcntl_wifstopped,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_async_signals,popen
; If a plugin genuinely needs one of these, remove it here, not globally.

; OBSERVABILITY
pm.status_path = /fpm-status
ping.path = /fpm-ping
ping.response = pong
request_terminate_timeout = 120
request_slowlog_timeout = 3s
; Make sure the slowlog path exists and is writable by the FPM user
; e.g. sudo mkdir -p /var/log/php8.4-fpm && sudo chown www-data:www-data /var/log/php8.4-fpm
; Otherwise, PHP Reload would fail
slowlog = /var/log/php8.4-fpm/slow.log
catch_workers_output = yes
decorate_workers_output = no
Bash
# Reload PHP
sudo systemctl reload php8.4-fpm
sudo systemctl status php8.4-fpm --no-pager

Safe Logging

Logs are your black box recorder: they tell you what happened, when, and why. They’re essential for debugging and for spotting performance or security issues. But without rotation, logs grow endlessly and can fill your disk. Do it right, enable only what you need, store it safely, and rotate/compress on a schedule.

Bash
mkdir -p /var/log/blog
sudo chown www-data:www-data /var/log/blog
sudo chmod 750 /var/log/blog
sudo tee /etc/logrotate.d/wordpress >/dev/null <<'EOF'
/var/log/blog/debug.log {
weekly
rotate 12
compress
missingok
notifempty
create 640 www-data www-data
}
EOF
wp-config.php
# Enable file logging only in wp-config.php
if ( ! defined( 'WP_DEBUG' ) ) {
        define( 'WP_DEBUG', true );
}
// Set display to false, We don't want errors displayed on our frontend
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', 0 );
// Log to a file (outside web root is best)
define('WP_DEBUG_LOG', '/var/log/blog/debug.log');

Extras

If you’re not using Cloudflare’s “Email Obfuscation”, I recommend this plugin to protect your email address from bots and scrapers. It ensures your email is only shown when JavaScript is enabled, keeping it hidden from the page source and harder for spam bots to harvest.

Bash
# Let's create a Must-Use folder
mkdir -p /var/www/blog/wp-content/mu-plugins 
chown ubuntu:www-data //var/www/blog/wp-content/mu-plugins
wget -O /var/www/blog/wp-content/mu-plugins/protect-my-email.php https://raw.githubusercontent.com/abdessalllam/cloud-setup/refs/heads/main/wordpress/protect-my-email.php

Usage:

Editor
Full Shortcode: [js_email label="Reach out to me via Email:" user="info" domain="example.com" display="inline" subject="Hello" msg="Please enable JS for the Email" class="optional-extra-class"]
Minimal Use: [js_email user="info" domain="example.com"]
Output: info@example.com

What’s next?

Nginx logs are located at /var/log/nginx/

That’s it, You are done. Congratulations 🎉🎉 I’d recommend Installing a Cache Plugin and Security Plugin to help speedup and secure your Setup further.

This isn’t the quickest way to install WordPress, but it’s the right way.
Have suggestions or spotted something I could improve? Share your thoughts in the comments – we’d love to hear from you.

 

 

Categorized in:

Guides,