How to Install WordPress on Ubuntu 24.04 — LEMP Stack, WP-CLI, Redis, FastCGI Cache
WordPress powers more than 43% of every website on the internet, and the overwhelming majority of those sites run on some flavour of a Linux web stack. When you outgrow a shared-hosting plan — because a plugin is banned, a resource cap throttles a traffic spike, a support reply takes three days, or the yearly renewal silently tripled — moving to your own VPS is the permanent fix. You get the full platform, you own the database, and the price stops climbing. This tutorial walks you through a production-hardened WordPress install on a fresh Ubuntu 24.04 VPS using the LEMP stack (Nginx, MariaDB, PHP 8.3-FPM), WP-CLI, Redis object caching, Nginx FastCGI page caching, a free Let's Encrypt TLS certificate, and a UFW firewall baseline.
Just want a VPS? Deploy an Ubuntu 24.04 server in under 60 seconds. Launch a Starter VPS now and follow along — the whole install takes about 45 minutes start to finish.
Table of Contents
What is WordPress?
WordPress is an open-source content management system released in 2003 and now maintained by the WordPress Foundation and thousands of contributors. It began life as blogging software but has matured into a general-purpose CMS that runs everything from single-author blogs to Fortune 500 marketing sites, news publishers, online stores (via WooCommerce), membership sites, learning platforms, and headless back-ends feeding React and Next.js front-ends over the REST API.
The codebase is PHP with a MySQL-compatible database, a templating layer called "themes", and a plugin architecture that lets you extend or replace nearly any behaviour without touching core. The official plugin directory at wordpress.org/plugins lists more than 60,000 free plugins; the theme directory lists another 12,000. Commercial plugins from vendors such as WooCommerce, Gravity Forms, Yoast SEO, and The Events Calendar add another layer on top. For developers, developer.wordpress.org is the canonical reference for the plugin API, REST API, block editor (Gutenberg), theme handbook, coding standards, and WP-CLI.
The ecosystem around self-hosted WordPress is the largest of any CMS by a wide margin. Integrations exist for every analytics tool, email provider, payment gateway, CDN, search engine, and backup destination you can name. When a new technology lands — headless architectures, AI content assistants, Core Web Vitals, full-site editing — the plugin ecosystem catches up within weeks. That breadth is impossible on a closed platform, and it is the reason 43% of the public web runs on WordPress.
Why Self-Host WordPress vs. WP Engine, Kinsta or Bluehost?
Managed WordPress hosts remove some of the sysadmin work but charge heavily for the privilege, ban plugins they dislike, cap your traffic, and leave you nowhere to go when a problem falls outside their playbook. A self-hosted install on a VPS is the same open-source software with none of those restrictions.
Cost comparison at equivalent traffic. The quoted prices below are the visible first-year rates; most managed hosts double the price on renewal.
| Host | Entry plan | Monthly visit cap | Sites | Storage | Price (renewal) |
|---|---|---|---|---|---|
| CloudCore Starter (self-host) | 2 vCPU / 4 GB / 60 GB NVMe | Unmetered | Unlimited | 60 GB | EUR 7.99 / EUR 7.99 |
| WP Engine Startup | Managed WordPress | 25,000 visits | 1 site | 10 GB | USD 25 / USD 30 |
| Kinsta Starter | Managed WordPress | 35,000 visits | 1 site | 10 GB | USD 35 / USD 35 |
| Bluehost Online Store | Shared Apache | "Unmetered"* | 1 site | 40 GB | USD 9.95 / USD 24.95 |
| SiteGround GrowBig | Shared Nginx | ~100,000 visits | Unlimited | 20 GB | USD 4.99 / USD 24.99 |
*Bluehost's "unmetered" shared plans enforce CPU and I/O throttling once sustained usage crosses an undocumented threshold. Every managed host above will charge overage fees or force you to upgrade the instant you cross the visit cap.
Beyond price, self-hosting on your own VPS gives you:
- Unlimited plugins and themes. WP Engine and Kinsta maintain plugin deny-lists that ban popular caching, backup, and security plugins on the grounds that they conflict with the host's proprietary stack. On your own VPS there is no deny-list — install any plugin from any source.
- Full data ownership. Every post, every upload, every user record, every WooCommerce order sits in a MariaDB database and an uploads folder on your server. Dump it, mirror it, move it, delete it on your own schedule.
- No traffic caps and no overage fees. A flash of viral traffic on a managed host triggers either throttling or a surprise invoice. On a VPS, the fixed monthly price stays fixed.
- SSH, WP-CLI, cron and root. Managed hosts sandbox or remove these. Self-hosting gives you full access to debug, automate, and script anything.
- Run other services alongside WordPress. Drop Nginx reverse-proxy routes for a staging site, add a Ghost newsletter, a Redis cache, or a Caddy edge server on the same machine. Managed WordPress hosts allow only WordPress.
- Customizable PHP, MariaDB and Nginx tuning. Raise
max_execution_time, tuneinnodb_buffer_pool_size, enablehttp2and Brotli — impossible on shared hosting.
Prerequisites
Before you start, you will need:
- A VPS running Ubuntu 24.04 LTS with root or sudo access.
- A domain name (for example
example.com) with an A record — and ideally an AAAA record — pointing to your VPS IP. Let's Encrypt requires a real DNS name. - SSH access to the server.
- At least 2 GB of RAM for a single low-traffic site; 4 GB recommended for any site running WooCommerce or a heavy page builder.
- At least 20 GB of free disk space for the OS, MariaDB, WordPress core, and a modest uploads directory; 60 GB+ if you plan to host media-heavy content.
Recommended Plan: CloudCore Starter>
For a single production WordPress site with Redis, FastCGI cache and daily backups, the CloudCore Starter VPS is the right fit:>
- 2 vCPU cores
- 4 GB RAM
- 60 GB NVMe SSD
- Unmetered bandwidth
- EUR 7.99/month>
Step up to the Professional plan (4 vCPU / 8 GB) once you add WooCommerce, membership plugins, or more than one site.
Connect to the server:
ssh root@your-server-ipThroughout this guide replace example.com with your actual domain and your-server-ip with your server's public address.
Step 1: Update System Packages
Always start a fresh server by applying every pending security patch and rebooting if the kernel was upgraded.
apt update && apt upgrade -yExpected output (abbreviated):
Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease
Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB]
Reading package lists... Done
Building dependency tree... Done
Calculating upgrade... Done
42 packages will be upgraded.If the kernel, systemd, or glibc was updated, reboot now:
rebootWait one minute and reconnect over SSH.
Set the hostname and timezone:
hostnamectl set-hostname example.com
timedatectl set-timezone UTCStep 2: Install the LEMP Stack
The LEMP acronym stands for Linux + Engine-X (Nginx) + MariaDB (or MySQL) + PHP. Ubuntu 24.04 ships with packages recent enough for a modern WordPress install: Nginx 1.24, MariaDB 10.11, and PHP 8.3. If you want a deeper dive on the web server itself see the dedicated Nginx install guide; for the database layer see the MariaDB install guide.
Install all three components and the PHP extensions WordPress requires in a single apt command:
apt install -y \
nginx \
mariadb-server \
php8.3-fpm \
php8.3-mysql \
php8.3-curl \
php8.3-gd \
php8.3-mbstring \
php8.3-xml \
php8.3-zip \
php8.3-intl \
php8.3-imagick \
php8.3-bcmath \
php8.3-soap \
php8.3-redis \
php8.3-opcache \
unzip curl wgetWhat each PHP extension does:
php8.3-mysql— communicates with MariaDB.php8.3-curl— powers WordPress HTTP API, plugin update checks and REST calls.php8.3-gd/php8.3-imagick— resize and process uploaded images (Imagick is faster and supports more formats).php8.3-mbstring— multi-byte string handling for non-ASCII content.php8.3-xml— required for oEmbed, RSS, SOAP and XML-RPC.php8.3-zip— needed for plugin and theme installs from the admin dashboard.php8.3-intl— locale-aware sorting and number formatting.php8.3-bcmath— arbitrary-precision math used by WooCommerce and tax plugins.php8.3-redis— PHP client for the Redis object cache.php8.3-opcache— bytecode cache, essential for PHP performance.
systemctl enable --now nginx mariadb php8.3-fpmConfirm each is running:
systemctl is-active nginx mariadb php8.3-fpmExpected output:
active
active
activeTune OPcache and PHP-FPM
Open the PHP-FPM php.ini and raise memory and OPcache limits:
nano /etc/php/8.3/fpm/php.iniSet the following values (search for each key and update):
memory_limit = 256M upload_max_filesize = 64M post_max_size = 64M max_execution_time = 120 max_input_vars = 3000
[opcache] opcache.enable = 1 opcache.memory_consumption = 256 opcache.interned_strings_buffer = 16 opcache.max_accelerated_files = 10000 opcache.validate_timestamps = 1 opcache.revalidate_freq = 2
Restart PHP-FPM:
systemctl restart php8.3-fpmStep 3: Secure MariaDB and Create the Database
Ubuntu 24.04 installs MariaDB with a permissive default configuration. Run the built-in hardener:
mysql_secure_installationAnswer the prompts:
- Enter current password for root: Press Enter (there is none yet).
- Switch to unix_socket authentication:
n(keep password auth so you can script later). - Change the root password:
Y, then set a strong password. - Remove anonymous users:
Y. - Disallow root login remotely:
Y. - Remove test database:
Y. - Reload privilege tables:
Y.
mysql -u root -pRun the following SQL, replacing SET-A-STRONG-PASSWORD-HERE with a long random string:
CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'SET-A-STRONG-PASSWORD-HERE';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wp_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;utf8mb4 is critical — it is the only MySQL/MariaDB charset that correctly stores four-byte characters like emoji and the full CJK range. WordPress has required it since 4.2.
Step 4: Install WP-CLI
WP-CLI is the official WordPress command-line tool. Anything you can do in the wp-admin dashboard you can do faster from the terminal: install and download core, create users, run database search/replace, enable plugins, manage multisite networks, and scaffold themes.
Download the phar archive, verify it, and move it into your PATH:
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
mv wp-cli.phar /usr/local/bin/wpVerify:
wp --infoExpected output (abbreviated):
OS: Linux 6.8.0-45-generic
Shell: /bin/bash
PHP binary: /usr/bin/php8.3
PHP version: 8.3.6
WP-CLI root dir: phar://wp-cli.phar/vendor/wp-cli/wp-cli
WP-CLI version: 2.11.0Step 5: Download WordPress and Generate wp-config.php
Create the web root, set ownership, and switch to it:
mkdir -p /var/www/example.com
chown -R www-data:www-data /var/www/example.com
cd /var/www/example.comDownload the latest WordPress release as the www-data user so every file has the correct ownership from the start:
sudo -u www-data wp core download --locale=en_USExpected output:
Downloading WordPress 6.7.1 (en_US)...
md5 hash verified: [hash]
Success: WordPress downloaded.Generate wp-config.php. WP-CLI fetches fresh cryptographic salt keys from the api.wordpress.org/secret-key endpoint automatically — you never need to copy-paste them from a web form:
sudo -u www-data wp config create \
--dbname=wordpress \
--dbuser=wp_user \
--dbpass='SET-A-STRONG-PASSWORD-HERE' \
--dbhost=localhost \
--dbprefix=wp_Complete the install non-interactively:
sudo -u www-data wp core install \
--url=https://example.com \
--title="My WordPress Site" \
--admin_user=admin \
--admin_password='SET-A-DIFFERENT-STRONG-PASSWORD' \
[email protected]Expected output:
Success: WordPress installed successfully.Harden wp-config.php Further
Open wp-config.php and add the following before the / That's all, stop editing! / line:
// Disable file editing from wp-admin (prevents shell execution if an admin account is compromised) define('DISALLOW_FILE_EDIT', true);// Force SSL on admin and login define('FORCE_SSL_ADMIN', true);
// Automatic updates for minor releases only define('WP_AUTO_UPDATE_CORE', 'minor');
// Limit post revisions define('WP_POST_REVISIONS', 5);
// Empty trash after 7 days define('EMPTY_TRASH_DAYS', 7);
// Move wp-content uploads path check define('WP_MEMORY_LIMIT', '256M'); define('WP_MAX_MEMORY_LIMIT', '512M');
// Redis object cache (plugin installed in Step 7) define('WP_CACHE', true); define('WP_REDIS_HOST', '127.0.0.1'); define('WP_REDIS_PORT', 6379); define('WP_REDIS_DATABASE', 0); define('WP_REDIS_PREFIX', 'example_com_');
Lock the permissions so only the web user can read it:
chmod 640 /var/www/example.com/wp-config.php
chown www-data:www-data /var/www/example.com/wp-config.phpVerify the Salt Keys Look Right
Open wp-config.php and confirm you see eight unique blocks with random 64-character values — not the placeholder text put your unique phrase here:
define('AUTH_KEY', 'J!7{W3#Hc...redacted...x$VD');
define('SECURE_AUTH_KEY', 'Pz9,*d-Y)...redacted...Z&eN2');
define('LOGGED_IN_KEY', ' ...redacted... ');
define('NONCE_KEY', ' ...redacted... ');
define('AUTH_SALT', ' ...redacted... ');
define('SECURE_AUTH_SALT', ' ...redacted... ');
define('LOGGED_IN_SALT', ' ...redacted... ');
define('NONCE_SALT', ' ...redacted... ');</code></pre></div>If they ever look wrong, regenerate them with:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">sudo -u www-data wp config shuffle-salts</code></pre></div>
Step 6: Configure Nginx with FastCGI Cache
FastCGI cache stores fully rendered HTML at the Nginx layer so anonymous visitors never reach PHP. Combined with Redis object cache (Step 7) you have a two-layer strategy that handles thousands of requests per second on modest hardware.
Remove the default server block:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">rm /etc/nginx/sites-enabled/default</code></pre></div>
Create a cache path config file:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">nano /etc/nginx/conf.d/fastcgi-cache.conf</code></pre></div>
Contents:
<div class="code-block" data-lang="nginx"><div class="code-block__header"><span class="code-block__lang">nginx</span></div><pre><code class="language-nginx">fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header updating http_500 http_503;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;</code></pre></div>
Create the WordPress server block:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">nano /etc/nginx/sites-available/example.com.conf</code></pre></div>
Contents:
<div class="code-block" data-lang="nginx"><div class="code-block__header"><span class="code-block__lang">nginx</span></div><pre><code class="language-nginx">server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
root /var/www/example.com;
index index.php;
# TLS cert paths set by certbot in Step 8
# ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 64m;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Define cache skip conditions
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~ "/wp-admin/|/xmlrpc.php|wp-..php|/feed/|index.php|sitemap(_index)?.xml") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") { set $skip_cache 1; }
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 301 302 60m;
add_header X-FastCGI-Cache $upstream_cache_status;
}
# Block hidden files and common PHP execution in uploads
location ~ /\. { deny all; }
location ~ /wp-content/uploads/.\.php$ { deny all; }
# Static asset caching
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|css|js|woff2?)$ {
expires 30d;
access_log off;
add_header Cache-Control "public, max-age=2592000, immutable";
}
}</code></pre></div>
Enable the site and test the config:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">ln -s /etc/nginx/sites-available/example.com.conf /etc/nginx/sites-enabled/
nginx -t</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration test is successful</code></pre></div>
Reload Nginx:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">systemctl reload nginx</code></pre></div>
After Step 8 adds the TLS certificate, uncomment the two ssl_certificate lines.
Step 7: Install Redis and Enable the Object Cache Plugin
Redis is an in-memory key-value store that caches database query results, transients, and WordPress options. On a busy admin dashboard or WooCommerce cart (both bypass the FastCGI HTML cache) Redis reduces page generation time from hundreds of milliseconds to a handful. For a full deep dive see the Redis install guide.
Install redis-server (the php8.3-redis extension is already installed from Step 2):
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">apt install -y redis-server</code></pre></div>
Make Redis listen only on localhost (default in Ubuntu 24.04, but verify):
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">grep -E "^bind|^protected-mode" /etc/redis/redis.conf</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">bind 127.0.0.1 -::1
protected-mode yes</code></pre></div>
Set the memory policy so Redis evicts least-recently-used keys when full:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">sed -i 's/^# maxmemory .*/maxmemory 256mb/' /etc/redis/redis.conf
sed -i 's/^# maxmemory-policy .*/maxmemory-policy allkeys-lru/' /etc/redis/redis.conf</code></pre></div>
Restart Redis and enable it at boot:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">systemctl enable --now redis-server
systemctl restart redis-server
redis-cli ping</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">PONG</code></pre></div>
Install and activate the Redis Object Cache plugin via WP-CLI:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">cd /var/www/example.com
sudo -u www-data wp plugin install redis-cache --activate
sudo -u www-data wp redis enable</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">Success: Object cache enabled.</code></pre></div>
Verify the cache is serving hits:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">sudo -u www-data wp redis status</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">Status: Connected
Client: PhpRedis (v6.0.2)
Host: 127.0.0.1
Port: 6379
Database: 0</code></pre></div>
Step 8: Issue a Let's Encrypt TLS Certificate
Install Certbot and its Nginx plugin:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">apt install -y certbot python3-certbot-nginx</code></pre></div>
Request the certificate. Certbot will automatically detect your Nginx server block, add the ssl_certificate directives, and configure an HTTP -> HTTPS redirect:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">certbot --nginx -d example.com -d www.example.com \
--non-interactive --agree-tos --email [email protected] --redirect</code></pre></div>
Expected output (abbreviated):
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem
This certificate expires on 2026-07-15.
Successfully deployed certificate for example.com to /etc/nginx/sites-enabled/example.com.conf
Your existing certificate has been successfully renewed.</code></pre></div>
Certbot installs a systemd timer that renews within 30 days of expiry. Verify:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">systemctl status certbot.timer
certbot renew --dry-run</code></pre></div>
Reload Nginx one last time:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">systemctl reload nginx</code></pre></div>
Visit https://example.com — you should see the default WordPress theme front page served over TLS.
Step 9: Harden the Server with UFW
UFW (Uncomplicated Firewall) is the default Ubuntu firewall. Lock the server down so only SSH, HTTP, and HTTPS are reachable from the outside.
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow "Nginx Full"
ufw --force enable</code></pre></div>
Check the result:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">ufw status verbose</code></pre></div>
Expected output:
<div class="code-block" data-lang="text"><div class="code-block__header"><span class="code-block__lang">text</span></div><pre><code class="language-text">Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
OpenSSH ALLOW IN Anywhere
Nginx Full ALLOW IN Anywhere
OpenSSH (v6) ALLOW IN Anywhere (v6)
Nginx Full (v6) ALLOW IN Anywhere (v6)</code></pre></div>
Nginx Full opens both 80 and 443. Redis (6379) and MariaDB (3306) stay bound to localhost and are never exposed to the internet.
Verify the Installation
Run a final health check across every layer:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash"># Service status
systemctl is-active nginx mariadb php8.3-fpm redis-server
WordPress sanity via WP-CLI
cd /var/www/example.com
sudo -u www-data wp core version
sudo -u www-data wp option get siteurl
sudo -u www-data wp plugin list
sudo -u www-data wp redis statusFastCGI cache hit check
curl -I https://example.com | grep -i x-fastcgi-cache
curl -I https://example.com | grep -i x-fastcgi-cache</code></pre></div>On the first curl request you should see X-FastCGI-Cache: MISS, on the second X-FastCGI-Cache: HIT. That confirms the Nginx page cache is active.
Log in to wp-admin at https://example.com/wp-login.php, install a theme, install Yoast SEO or Rank Math, and publish your first post.
Troubleshooting
<div class="article-table-wrap"><table><thead><tr><th>Problem</th><th>Cause</th><th>Solution</th></tr></thead><tbody><tr><td>502 Bad Gateway from Nginx</td><td>PHP-FPM socket path mismatch or service down</td><td><code>systemctl status php8.3-fpm</code>, confirm <code>fastcgi_pass unix:/run/php/php8.3-fpm.sock;</code> matches <code>/etc/php/8.3/fpm/pool.d/www.conf</code> listen directive</td></tr><tr><td><code>Error establishing a database connection</code></td><td>Wrong credentials in wp-config.php or MariaDB stopped</td><td>Check <code>systemctl status mariadb</code>, verify <code>DB_USER</code>, <code>DB_PASSWORD</code>, <code>DB_HOST</code> in wp-config.php, test with <code>mysql -u wp_user -p wordpress</code></td></tr><tr><td>White screen (WSOD) on front end</td><td>PHP fatal error, logs suppressed</td><td>Enable <code>WP_DEBUG_LOG</code> in wp-config.php, tail <code>/var/www/example.com/wp-content/debug.log</code> and <code>/var/log/nginx/error.log</code></td></tr><tr><td>Uploaded images show broken thumbnails</td><td>GD or Imagick not installed, or <code>upload_max_filesize</code> too low</td><td>Install <code>php8.3-gd php8.3-imagick</code> and raise <code>upload_max_filesize</code>/<code>post_max_size</code> in <code>/etc/php/8.3/fpm/php.ini</code></td></tr><tr><td>Redis cache never shows as enabled</td><td><code>php8.3-redis</code> extension missing or Redis refusing connection</td><td><code>apt install php8.3-redis && systemctl restart php8.3-fpm</code>, then <code>redis-cli ping</code> should return PONG</td></tr><tr><td><code>X-FastCGI-Cache</code> header always MISS</td><td>Cookies, query string or admin URL hitting skip rules</td><td>Test with an anonymous incognito window against the homepage root URL</td></tr><tr><td>Certbot fails with <code>DNS problem: NXDOMAIN</code></td><td>A record not yet propagated or wrong IP</td><td>Run <code>dig +short example.com</code> — the answer must match your server IP; wait for DNS propagation (up to 24h)</td></tr><tr><td>Site is slow only in wp-admin</td><td>FastCGI cache bypasses admin URLs (correct), but object cache missing</td><td>Confirm Redis is enabled: <code>wp redis status</code> should show Connected</td></tr><tr><td>413 Request Entity Too Large on plugin upload</td><td><code>client_max_body_size</code> in Nginx too small</td><td>Raise <code>client_max_body_size 64m;</code> in the server block and reload Nginx</td></tr><tr><td>Cron events not firing</td><td>Default WP pseudo-cron depends on traffic</td><td>Disable pseudo-cron (<code>define('DISABLE_WP_CRON', true);</code>) and add a system cron: <code><em> </em> <em> </em> * cd /var/www/example.com && php wp-cron.php</code></td></tr></tbody></table></div>
Tailing Logs
Keep these three terminals open when something goes wrong:
<div class="code-block" data-lang="bash"><div class="code-block__header"><span class="code-block__lang">bash</span></div><pre><code class="language-bash">tail -f /var/log/nginx/error.log
tail -f /var/log/php8.3-fpm.log
tail -f /var/www/example.com/wp-content/debug.log</code></pre></div>
FAQ
What is the minimum VPS size to run WordPress in production?
A 2 vCPU / 4 GB RAM server such as our CloudCore Starter plan handles a single production WordPress site with Redis object cache and Nginx FastCGI page cache, serving thousands of daily visitors before you notice any load. Below 2 GB RAM the OPcache, InnoDB buffer pool, and FastCGI cache start competing for memory and latency suffers. If you plan to run WooCommerce, membership plugins, or more than one site, step up to the Professional plan (4 vCPU / 8 GB).
Should I use MariaDB or MySQL for WordPress on Ubuntu 24.04?
Either works, but MariaDB is the recommended default on Ubuntu 24.04. It is a drop-in replacement for MySQL, ships in the main Ubuntu repositories (MySQL requires an external repo), and is maintained by the original authors of MySQL. WordPress requires MySQL 5.7+ or MariaDB 10.4+, and the MariaDB 10.11 package in Ubuntu 24.04 exceeds that comfortably.
Do I really need both FastCGI cache and Redis object cache?
Yes — they solve different problems. FastCGI cache stores fully rendered HTML at the Nginx layer so anonymous visitors never touch PHP at all; a cached hit is served in single-digit milliseconds. Redis object cache stores MySQL query results and WordPress transients in memory, which dramatically speeds up the admin dashboard, logged-in user sessions, WooCommerce carts and checkout, and any request that must bypass the full-page cache. Both layers compound: FastCGI handles public pages, Redis handles everything dynamic.
Is self-hosting WordPress cheaper than WP Engine, Kinsta or Bluehost?
Yes, dramatically so beyond the starter tier. A self-hosted WordPress install on an EUR 7.99/month CloudCore Starter VPS replaces WP Engine's USD 25/month Startup plan (25k visits cap), Kinsta's USD 35/month Starter (35k visits cap), and Bluehost's USD 9.95/month shared hosting (renews at USD 24.95) with no visit caps, no overage fees, and no plugin deny-lists. On renewal, a typical managed WordPress host doubles its price, while a VPS monthly rate stays fixed for the lifetime of the server.
How do I install WordPress without using the web-based five-minute install wizard?
Use WP-CLI. The four commands wp core download, wp config create, wp db create and wp core install perform the entire installation — including the admin user — from the terminal in under 30 seconds, and can be scripted into cloud-init templates or Ansible playbooks for reproducible provisioning.
What are WordPress salt keys and why do I need fresh ones?
Salt keys are eight long random strings in wp-config.php (AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY and their _SALT counterparts) used to sign cookies and session tokens. Fresh values fetched automatically by wp config create from the WordPress salts API prevent anyone who has ever seen a default or leaked wp-config.php template from forging session cookies against your site. Regenerate them any time you suspect a compromise with wp config shuffle-salts.
Can I run multiple WordPress sites on one VPS?
Yes. Create a separate Nginx server block, separate database, separate database user, and separate web root for each site. The LEMP stack comfortably handles a dozen low-to-medium traffic WordPress sites on a single 4 GB RAM VPS. Once you add WooCommerce or cross 10 active sites, move up to an 8 GB plan so OPcache and the InnoDB buffer pool have room to keep everything hot in memory.
How do I migrate from a shared-hosting Apache environment to this Nginx stack?
Export the database with mysqldump, tar up the wp-content directory, install the LEMP stack on your new VPS using this guide, import the database, restore wp-content, update database credentials in wp-config.php, and translate any .htaccess rewrite rules into the Nginx try_files directive (the block in Step 6 already handles standard WordPress permalinks). If the domain changed, run wp search-replace 'https://old.example.com' 'https://new.example.com' to update URLs throughout the database. Test with the new hostname in your local /etc/hosts before switching DNS.
Next Steps
Automate daily backups with restic or BorgBackup. Schedule nightly snapshots of /var/www/example.com and mysqldump wordpress to an offsite S3 bucket or a second VPS.
- Add a CDN. Cloudflare's free plan in front of your Nginx FastCGI cache cuts global latency to single-digit milliseconds and absorbs DDoS traffic before it reaches your server.
- Install WooCommerce for an online store. See our dedicated WooCommerce install guide for the additional PHP extensions and Nginx tuning it needs.
Harden logins with Fail2Ban. Add a jail for wp-login.php` to block brute-force attempts after 5 failed attempts in 10 minutes.
- Publish a companion newsletter with Ghost. Run Ghost on a subdomain of the same VPS for member-only posts, while WordPress handles the public marketing site.
- Switch the reverse proxy to Caddy if you prefer automatic HTTPS without Certbot and a simpler config file format.
- Monitor with Uptime Kuma and Netdata. Get paged the moment the site goes down, and watch MariaDB, Redis, and PHP-FPM metrics in real time.
For canonical reference documentation while building themes and plugins, bookmark the official WordPress documentation hub and the WordPress Developer Resources — the latter is the authoritative source for the plugin API, REST API, block editor, and coding standards.### Skip the Manual Install — Get a Starter VPS
>
Our CloudCore Starter VPS gives you a clean Ubuntu 24.04 server in under 60 seconds for EUR 7.99/month:
>
- 2 vCPU cores / 4 GB RAM / 60 GB NVMe SSD
- Unmetered bandwidth
- 9 global locations
- Full root SSH access
>
Deploy, follow this guide, and run your WordPress site at a fraction of managed-host pricing — with no visit caps, no plugin deny-lists, and no renewal price hikes. Launch your VPS now.