To install WordPress on a VPS running Ubuntu 24.04, install PHP 8.3-FPM, the PHP extensions WordPress recommends and MariaDB from Ubuntu’s archive, download WordPress and verify its SHA-1 checksum, and create wp-config.php with fresh salts from api.wordpress.org. Then run the site in its own PHP-FPM pool under a dedicated user, install it with WP-CLI, and point Caddy at the pool with php_fastcgi; Caddy gets and renews the HTTPS certificate on its own.
The commands follow the official documentation as checked on October 3, 2026, for WordPress 7.1.2 (released September 22, 2026), Ubuntu 24.04’s PHP 8.3 and MariaDB 10.11 packages, and Caddy 2.11. After the seven install steps come the parts most guides skip: file ownership that lets WordPress update itself safely, real cron, caching, backups, sizing by traffic and a troubleshooting matrix.
Key takeaways
- On Ubuntu 24.04, Ubuntu's own php8.3-fpm and mariadb-server packages meet WordPress's recommended PHP 8.3+ and MariaDB 10.11+; WordPress 7.1 is fully compatible with PHP 8.3, and Ubuntu backports security fixes into it.
- Check the download against wordpress.org's published SHA-1 file, stream fresh keys and salts from api.wordpress.org into wp-config.php, and run wp core verify-checksums after the install.
- Give each site its own PHP-FPM pool and system user that owns the files: WordPress then updates itself with direct writes, and listen.owner = caddy avoids the 502 caused by Ubuntu's www-data-owned default socket.
- Ubuntu's MariaDB listens only on localhost and lets root in by Unix socket, so mysql_secure_installation is unnecessary; Caddy handles HTTPS without certbot, and php_fastcgi makes permalinks work without .htaccess.
- Size by memory first: PHP workers × their measured size plus MariaDB's 128 MB buffer pool and the OS; one site starts on 2 GB, WooCommerce or several sites on 4 GB.
Which stack should you use: Caddy and PHP-FPM, or Docker?
WordPress needs PHP, a MySQL-compatible database and a web server. Its requirements page recommends PHP 8.3 or greater, MariaDB 10.11 or greater (or MySQL 8.0 or greater) and HTTPS support. It calls Apache and Nginx the most robust choices but adds that “any server that supports PHP and MySQL will do.” Ubuntu 24.04 ships PHP 8.3 and MariaDB 10.11, so its own packages meet the recommendation without third-party repositories.
This guide pairs them with Caddy. WordPress core recognizes Caddy by name (the $is_caddy check in wp-includes/vars.php) and treats it like Nginx, so pretty permalinks work without an .htaccess file. Caddy also obtains and renews certificates itself, so there is no certbot step. Docker is the other route:
| Factor | Native: Caddy + PHP-FPM + MariaDB | Docker: official wordpress + mariadb images |
|---|---|---|
| PHP version | 8.3.6 from Ubuntu, with security fixes backported into it | Your pick of image tag, php8. to php8.; latest is PHP 8.3 with Apache |
| Extra PHP extensions | apt install one package | Not included; Docker’s docs tell you to build your own image FROM theirs |
| Updates | Ubuntu security updates through apt; WordPress updates itself | WordPress updates itself inside its volume; pull new images for PHP and the database |
| Overhead | No container runtime | Docker Engine, plus Apache inside the WordPress container |
| Good fit for | One to a few sites, small plans, admins who want every file in plain sight | Servers that already run Compose stacks, or sites that want PHP 8.4 or 8.5 today |
The Docker route in one Compose file
If Docker is already on the server (see how to install Docker on a VPS), this Compose file runs the official images. It differs from the example on Docker Hub in two places. It uses MariaDB 11.8 LTS instead of mysql:8.0, which the WordPress hosting handbook lists as reaching end of life in April 2026. And it publishes WordPress on 127.0.0.1 only, because ports Docker publishes on all addresses bypass UFW.
services:
db:
image: mariadb:11.8
restart: unless-stopped
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: change-me
MARIADB_RANDOM_ROOT_PASSWORD: "1"
volumes:
- db:/var/lib/mysql
wordpress:
image: wordpress:7.1-php8.4-apache
restart: unless-stopped
depends_on:
- db
ports:
- "127.0.0.1:8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: change-me
WORDPRESS_DB_NAME: wordpress
volumes:
- wordpress:/var/www/html
volumes:
db:
wordpress:
Replace both change-me values with the same long random string (openssl rand -hex 24 prints one), start it with docker compose up -d, and proxy to it from Caddy on the host with a site block holding a single line, reverse_proxy localhost:8080. Configured through these environment variables, the image writes unique keys and salts and adds the X-Forwarded-Proto handling WordPress needs behind a TLS proxy. The rest of this guide follows the native route.
Before you start: DNS, SSH, the firewall and Caddy
You need a domain, an Ubuntu 24.04 VPS and a non-root user with sudo. Four jobs come first; sibling guides cover each one:
- Log in over SSH (connecting to a VPS with SSH) and work through the VPS security checklist: updates, a sudo user, SSH keys and no root login.
- Point the domain’s A record, and its AAAA record if you use IPv6, at the server, as in setting the A and AAAA records.
- Allow 80/tcp, 443/tcp and 443/udp in UFW, as in opening ports 80 and 443.
- Install Caddy from its official apt repository. The package creates the
caddysystem user that Step 5 hands the PHP socket to, so it has to exist first.
Deploy the server near most of your visitors. HourlyVPS servers run in Istanbul today, with New York coming soon; what server location does for SEO explains why speed matters more than the IP address. Moving an existing site rather than starting fresh? Use the migration steps from shared hosting to copy its files and database in place of the fresh download in Step 3 and the install in Step 6.
How to install WordPress on a VPS running Ubuntu 24.04
Run the seven steps in order as your sudo user. Replace example.com with your domain everywhere.
Step 1: Install PHP 8.3, its extensions and MariaDB
The WordPress hosting team’s Server Environment handbook page sorts PHP extensions into required, highly recommended, cache and fallback groups. This command installs PHP-FPM, the PHP command line (for WP-CLI later), every required and highly recommended extension that Ubuntu packages, two fallbacks, OPcache and MariaDB:
sudo apt update
sudo apt install php8.3-fpm php8.3-cli php8.3-mysql php8.3-curl php8.3-xml php8.3-mbstring php8.3-zip php8.3-intl php8.3-gd php8.3-bcmath php8.3-opcache php-imagick php-igbinary mariadb-server
Note: php8.3-fpm, php8.3-intl, php8.3-zip, php-imagick, php-igbinary and mariadb-server come from Ubuntu’s universe component. Most Ubuntu server images enable it; if apt reports “Unable to locate package”, run sudo add-apt-repository universe and repeat the install.
| Extension (handbook group) | Ubuntu 24.04 package | Notes |
|---|---|---|
json, hash (required), pcre, openssl, sodium, zlib, filter | None: built into Ubuntu’s PHP | No action needed |
mysqli (required) | php8. | Also brings mysqlnd and pdo_ |
curl (highly recommended) | php8. | Remote requests, such as update checks |
dom, xml, simplexml, xmlreader | php8. | One package for all XML extensions |
exif, fileinfo, iconv, sockets | php8. | Installed automatically as a dependency |
mbstring, intl, zip (highly recommended) | php8., php8., php8. | zip unpacks plugin, theme and core updates |
imagick (highly recommended) | php-imagick | Built against ImageMagick 6.9.12 on 24.04; the handbook recommends ImageMagick 7.1 or later |
igbinary (highly recommended) | php-igbinary | A faster serializer, used when a cache or setting asks for it |
gd and bcmath (fallback) | php8., php8. | GD takes over image work if Imagick is missing |
opcache (cache) | php8. | Enabled by default |
apcu, memcached or redis (cache; one is enough) | php-apcu or php-redis | Only with a persistent object cache; see caching |
Ubuntu enables each extension for both the command line and PHP-FPM as its package installs, so php -m shows the same list FPM will load:
php -m | grep -E -i 'curl|dom|exif|fileinfo|igbinary|imagick|intl|mbstring|mysqli|openssl|zip|opcache'
A word on the version. The handbook recommends PHP 8.4 or later for production and notes that PHP 8.3 moved to security-only support on December 31, 2025, with end of life on December 31, 2027. It also lists WordPress 7.1 as fully compatible with PHP 8.3. Ubuntu keeps 24.04 on PHP 8.3.6 and backports security fixes into it: the current security update, 8.3.6-0ubuntu0.24.04.11, is dated September 2, 2026 in its changelog and patches CVEs published in 2026. If you want PHP 8.4 now without a third-party repository, the Docker route is the simpler way.
Step 2: Create the database and its user
Ubuntu’s MariaDB arrives locked down. The package’s README states that it listens only on localhost, ships no test database or test accounts, and lets the system root user in through Unix-socket authentication. That makes mysql_secure_installation unnecessary; the README warns it “might fail.” Generate a password and create the database with the statements from WordPress’s guide to creating a database:
DB_PASS=$(openssl rand -hex 24)
sudo mariadb -e "CREATE DATABASE wordpress; CREATE USER 'wordpress'@'localhost' IDENTIFIED BY '$DB_PASS'; GRANT ALL PRIVILEGES ON wordpress.* TO 'wordpress'@'localhost'; FLUSH PRIVILEGES;"
Keep this shell open: Step 4 writes $DB_PASS into wp-config.php, the only place it needs to live. The database, its user and the system user are all called wordpress here; for a second site, repeat Steps 2 to 7 with another name.
Step 3: Download WordPress and verify the checksum
wordpress.org publishes an MD5 and a SHA-1 checksum next to every download on its releases page. Fetch the archive and its .sha1 file and let sha1sum compare them:
cd /tmp
curl -fLO https://wordpress.org/latest.tar.gz
curl -fLO https://wordpress.org/latest.tar.gz.sha1
echo "$(cat latest.tar.gz.sha1) latest.tar.gz" | sha1sum -c -
The last line must print latest.tar.gz: OK. On October 3, 2026 the archive was WordPress 7.1.2, about 35 MB. The check proves the file arrived intact and matches what wordpress.org publishes. Both files come from the same server, so it cannot catch a compromised origin; Step 6 adds a second check of every installed file.
Now create the system user that will own the site and run its PHP, unpack WordPress and apply the 755/644 scheme from WordPress’s hardening guide:
sudo useradd --system --user-group --create-home --home-dir /home/wordpress --shell /usr/sbin/nologin wordpress
sudo mkdir -p /var/www
sudo tar -xzf /tmp/latest.tar.gz -C /var/www
sudo chown -R wordpress:wordpress /var/www/wordpress
sudo find /var/www/wordpress -type d -exec chmod 755 {} \;
sudo find /var/www/wordpress -type f -exec chmod 644 {} \;
The wordpress user cannot log in. Its home directory sits outside the web root, so the files WP-CLI caches there can never be downloaded through the website.
Step 4: Create wp-config.php with fresh keys and salts
Copy the sample, fill in the database settings, and swap the eight placeholder keys and salts for a fresh set from the WordPress.org secret-key service, the generator wp-config-sample.php itself points to. The salts stream from the API straight into the file, so no copy is left in /tmp:
cd /var/www/wordpress
sudo -u wordpress cp wp-config-sample.php wp-config.php
sudo -u wordpress sed -i "s/database_name_here/wordpress/; s/username_here/wordpress/; s/password_here/$DB_PASS/" wp-config.php
curl -fsS https://api.wordpress.org/secret-key/1.1/salt/ | sudo sed -i -e "/define( 'AUTH_KEY'/r /dev/stdin" -e "/put your unique phrase here/d" wp-config.php
sudo -u wordpress sed -i "/That's all, stop editing/i define( 'DISALLOW_FILE_EDIT', true );\ndefine( 'DISABLE_WP_CRON', true );" wp-config.php
sudo chmod 400 wp-config.php
sudo grep -c -E "^define\( ?'(AUTH|SECURE_AUTH|LOGGED_IN|NONCE)_(KEY|SALT)'" wp-config.php
sudo -u wordpress php -l wp-config.php
The grep must print 8, one line per key and salt; 0 means nothing was written, so remove the file with sudo rm wp-config.php and run the block again. The salts line runs sed as root because sed running as wordpress cannot open a pipe that belongs to your user, and the GNU sed manual treats an unreadable file as empty, with no error; sed -i keeps the file’s owner. The last command should print No syntax errors detected in wp-config.php.
The two added constants come from the wp-config.php documentation:
DISALLOW_FILE_EDITremoves the dashboard’s theme and plugin code editor, which the hardening guide calls “often the first tool an attacker will use if able to login.”DISABLE_WP_CRONstops WordPress from running scheduled tasks during page loads. Step 6 hands them to the system’s cron instead, the pairing WordPress’s WP-Cron docs recommend.
The hardening guide asks for mode 400 or 440 on wp-config.php. Mode 400 is enough here, because PHP runs as the file’s owner.
Step 5: Give the site its own PHP-FPM pool
Ubuntu’s default pool, www, runs PHP as www-data and creates /run/php/php8.3-fpm.sock owned by www-data with mode 0660. Caddy runs as the caddy user, so it cannot open that socket: the classic 502 error on a fresh Caddy and PHP-FPM server. PHP’s FPM configuration docs explain why: on Linux, the socket’s read and write permissions must let the web server connect. Rather than loosening the shared socket, give the site a pool that runs as wordpress and hands its socket to Caddy:
sudo nano /etc/php/8.3/fpm/pool.d/wordpress.conf
[wordpress]
user = wordpress
group = wordpress
listen = /run/php/php8.3-fpm-wordpress.sock
listen.owner = caddy
listen.group = caddy
listen.mode = 0660
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
- The process-manager values copy Ubuntu’s
wwwpool, so the site starts with at most 5 PHP workers. Sizing by traffic shows how to change that from your own measurements. pm.max_requests = 500recycles each worker after 500 requests, which PHP’s docs suggest as a way to work around memory leaks in third-party libraries.- The
php_admin_valuelines raise Ubuntu’s defaults (128M of memory, 2M uploads, 8M request bodies) to the 256M memory limit the handbook recommends and a 64 MB upload limit. Values set this way cannot be overridden withini_set().
Switch off the default pool so its idle workers stop holding memory, test the configuration and restart PHP-FPM:
sudo mv /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/www.conf.disabled
sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm
ls -l /run/php/php8.3-fpm-wordpress.sock
The test should report that the configuration file “test is successful”, and the socket should be listed as srw-rw---- owned by caddy caddy. PHP-FPM loads only files ending in .conf from pool.d, so renaming www.conf is enough to disable it.
Step 6: Install WordPress with WP-CLI before the site goes live
Until WordPress is installed, anyone who reaches /wp-admin/install.php can create its first administrator. Running the installer from the shell with WP-CLI means that page never faces the internet, and you will want WP-CLI anyway for updates, cron and backups. Install it as the WP-CLI handbook recommends, including its optional GPG check:
cd /tmp
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar.asc
curl -L https://raw.githubusercontent.com/wp-cli/builds/gh-pages/wp-cli.pgp | gpg --import
gpg --verify wp-cli.phar.asc wp-cli.phar
php wp-cli.phar --info
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
Then install WordPress as the site user. Without --admin_password, WP-CLI generates a password and prints it once as Admin password:; --skip-email skips a welcome mail the server cannot send yet. Pick a username other than admin: the hardening guide notes that easily guessed names “are typically subject to attacks first.”
cd /var/www/wordpress
sudo -u wordpress wp core install --url=https://example.com --title="Example Site" --admin_user=yourname [email protected] --skip-email
sudo -u wordpress wp core verify-checksums
wp core verify-checksums compares every core file with the checksums WordPress.org publishes for your version and should end with Success: WordPress installation verifies against checksums. Finally, give WP-Cron to the system scheduler, running due events every 5 minutes as the wordpress user with no HTTP request involved:
echo '*/5 * * * * wordpress /usr/local/bin/wp --path=/var/www/wordpress cron event run --due-now --quiet' | sudo tee /etc/cron.d/wordpress
Step 7: Point Caddy at PHP-FPM with php_fastcgi
Open the Caddyfile and replace the default site with the block below. It is the WordPress example from Caddy’s common patterns page with Ubuntu 24.04’s socket path, plus a browser-cache rule for static files, a redirect from www and the ACME email address from the Caddy guide:
sudo nano /etc/caddy/Caddyfile
{
email [email protected]
}
example.com {
root /var/www/wordpress
encode
php_fastcgi unix//run/php/php8.3-fpm-wordpress.sock
file_server
@blocked path /xmlrpc.php *.sql /wp-content/uploads/*.php
rewrite @blocked /index.php
@static path *.css *.js *.png *.jpg *.jpeg *.gif *.webp *.avif *.svg *.woff2 *.ico
header @static Cache-Control "public, max-age=2592000"
}
www.example.com {
redir https://example.com{uri} permanent
}
What each part does, per Caddy’s php_fastcgi docs and the patterns page:
rootsets the site directory. The docs sayphp_fastcgishould almost always be paired withrootandfile_server.php_fastcgisends.phprequests to the pool’s socket and rewrites every path that is not a file on disk toindex.php, which is exactly how WordPress permalinks work. Because WordPress detects Caddy, Settings → Permalinks saves pretty URLs with no.htaccess.file_serverserves CSS, JavaScript, images and uploads directly, without waking PHP.@blockedrefuses the XML-RPC endpoint (“often targeted by brute-force attacks”), database dumps and PHP files inside uploads; WordPress answers them with its normal 404 page. Remove/xmlrpc.phpfrom the list if an app or plugin you use needs XML-RPC.@staticlets browsers keep static files for 30 days (2,592,000 seconds), using the Cache-Control pattern from Caddy’s header directive docs. WordPress adds a?ver=query string to the CSS and JavaScript it loads, so updated files still reach visitors.
Format, validate and reload as in reloading Caddy safely, then confirm the site answers over HTTPS:
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo -u caddy caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://example.com/
Expect HTTP/2 200 once the certificate is in place, which can take a few seconds after the reload. A 502 almost always means Caddy cannot open the socket; see the troubleshooting matrix. Then log in at https://example.com/wp-admin/ with the password from Step 6 and open Tools → Site Health: it should report no missing PHP modules.
File ownership: why one user owns the files and runs PHP
WordPress decides how to write files by testing. Its get_filesystem_method() function (in wp-admin/includes/file.php) creates a temporary file and compares that file’s owner with the owner of WordPress’s own files. Only when they match does it write directly. Otherwise it falls back to FTP or SSH, and the dashboard asks for “Connection Information” every time you update a plugin. That leaves three workable models:
| Model | Who owns the files | Dashboard updates | Trade-off |
|---|---|---|---|
| Dedicated pool user (this guide) | wordpress, which also runs this site’s PHP | Work, written directly | PHP can change its own site’s code, but no other site’s; wp-config. is unreadable to other users |
www-data owns everything (most tutorials) | www-data, shared by every PHP site on the server | Work | A compromised plugin on one site can write into every other site |
| Read-only code | Your admin user; only wp-content/ writable by PHP | Blocked; set DISALLOW_ and update with WP-CLI as the owner | Strongest isolation, but every core, plugin and theme update becomes a manual step |
Whichever model you pick, never fix a permission error with 777. WordPress’s file-permissions guide explains that world-writable files hand full access to every account on the server, including the web server’s. If the dashboard asks for FTP details after you copied files in by hand, fix ownership instead: sudo chown -R wordpress:wordpress /var/www/wordpress.
Is HTTPS automatic, and what should you cache?
HTTPS is already done. Caddy requested a certificate as soon as the site block loaded and renews it in the background; the Caddy reverse proxy guide covers how that works and what breaks it. WordPress needs nothing more: Caddy’s FastCGI transport sets HTTPS=on for TLS requests, which is what WordPress’s is_ssl() reads. The FORCE_SSL_ADMIN and X-Forwarded-Proto snippets in WordPress’s HTTPS guide are for setups where a proxy talks plain HTTP to WordPress, such as the Docker route. Behind Cloudflare, use the Full (strict) SSL mode; Flexible causes a redirect loop.
Caching in four layers
WordPress’s caching guide and the hosting handbook describe these layers. Add them in this order and stop when the site is fast enough:
- OPcache keeps compiled PHP in memory and is on by default with
php8.3-opcache. The caching guide says an opcode cache improves PHP’s performance “by many times,” and since WordPress 7.0 Site Health checks for one. - A page cache plugin, such as WP Super Cache, Cache Enabler or W3 Total Cache (the three the guide names), stores rendered pages as static files. The guide says this “can improve performance several hundred times over for fairly static pages.” Logged-in users, carts and checkouts bypass it.
- Browser caching is the
@staticrule from Step 7: repeat visitors reuse CSS, JavaScript and images. - A persistent object cache keeps database query results in Redis, APCu or Memcached between requests. The handbook says only one of the three is needed, and it needs a matching drop-in plugin. Our reasoning: it pays off on WooCommerce and membership sites with many logged-in requests, much less on a small blog that already has a page cache.
How do you keep WordPress updated and backed up?
Updates: four layers, four schedules
| Layer | Updated by | What to do |
|---|---|---|
| WordPress core | WordPress itself. Per the upgrade docs, installs created on WordPress 5.6 or later get minor and major automatic updates by default | Nothing; they succeed because PHP owns the files. Define WP_ as 'minor' if you want to test major releases first |
| Plugins and themes | Automatic only in special cases the WordPress.org security team chooses, unless you enable auto-updates | Turn on auto-updates per plugin, or run the WP-CLI pass below every week |
| PHP, MariaDB and Ubuntu | unattended-upgrades, which installs security updates from Ubuntu’s own archive | Confirm it is on (checklist step 6) |
| Caddy | Its own apt repository, which unattended-upgrades skips by default | Run sudo apt update && sudo apt upgrade now and then |
cd /var/www/wordpress
sudo -u wordpress wp core update
sudo -u wordpress wp core update-db
sudo -u wordpress wp plugin update --all
sudo -u wordpress wp theme update --all
sudo -u wordpress wp core verify-checksums
sudo -u wordpress wp plugin verify-checksums --all
The two verify-checksums commands catch modified files, a common sign of a compromised site; the plugin check covers plugins hosted on WordPress.org. One support detail: mariadb-server and php8.3-fpm sit in Ubuntu’s universe component. Both had current security updates on October 3, 2026, but Canonical’s standard 5-year security maintenance is promised for main; Ubuntu Pro extends that commitment to universe.
Backups: the database plus four folders
WordPress’s backup guide splits a backup into the database (posts, comments, settings) and the files; you need both to restore. Core files can be downloaded again, so the files that matter are wp-content and wp-config.php. Add the Caddyfile and the pool file and a rebuild is quick. This line dumps the database every night at 03:30 server time; --single-transaction is passed through to the mysqldump tool for a consistent dump without locking InnoDB tables:
sudo -u wordpress mkdir -p /home/wordpress/backups
echo '30 3 * * * wordpress /usr/local/bin/wp --path=/var/www/wordpress db export /home/wordpress/backups/wordpress.sql --single-transaction --quiet' | sudo tee -a /etc/cron.d/wordpress
Then get it off the server: copy /var/www/wordpress, /home/wordpress/backups, /etc/caddy and /etc/php/8.3/fpm/pool.d to storage outside this VPS, such as an S3-compatible bucket, with an encrypting backup tool like restic; our VPS backup guide with restic walks through the setup and a restore drill. WordPress’s guide suggests keeping at least 3 to 5 recent backups in different locations.
Tip: Test a restore before you need one. Deploy an hourly VPS, restore the latest backup with wp db import, confirm it with wp db check and wp post list --format=count, then delete the server. Two hours on Quartz Q2 cost $0.04. Take a provider snapshot before major updates too: a snapshot is a quick undo, not a backup.
WordPress security on a VPS: what this setup already covers
The server baseline (SSH keys, no root login, UFW, automatic security updates) lives in the VPS security checklist. On top of it, the install steps above already apply most of WordPress’s hardening guide:
| Risk | Covered by | Where |
|---|---|---|
| PHP code edited through a stolen admin login | DISALLOW_ | Step 4 |
wp-config. read by other accounts | Mode 400, owned by the pool user | Step 4 |
XML-RPC brute force, PHP uploaded into uploads, exposed .sql dumps | The @blocked rule in Caddy | Step 7 |
| Database reachable from the internet | MariaDB listens on localhost only (Ubuntu default) | Step 2 |
| One compromised site spreading to others | One pool and one user per site | Step 5 |
| Guessable admin username | --admin_ other than admin | Step 6 |
| Tampered core or plugin files | wp core verify-checksums, wp plugin verify-checksums | Updates |
Three things stay with you. Keep plugins few and current: “if you are not using a specific plugin, delete it from the system,” says the hardening guide. Use a strong, unique admin password and turn on two-factor login. And never open port 3306 or put phpMyAdmin on a public hostname; reach the database through an SSH tunnel.
Email: why password resets never arrive
A fresh server has no mail transport, so WordPress’s password-reset and notification emails go nowhere. New HourlyVPS servers also start with outbound port 25 closed. Send mail through a transactional email provider with an SMTP or API plugin instead, and publish the SPF and DKIM records the provider gives you, which receiving servers check.
How much VPS does WordPress need? Sizing by traffic
WordPress publishes no RAM minimum. The documented figures you can build on are Ubuntu 24.04’s 1 GB minimum for cloud images (3 GB suggested), MariaDB’s default InnoDB buffer pool of 128 MiB, and the handbook’s PHP memory_limit tiers: 128M minimum, 256M recommended, and 512M or more for WooCommerce, page builders and other memory-heavy sites. The limit is a ceiling per PHP worker, so the worst case for PHP is pm.max_children × memory_limit: 5 × 256 MB = 1.25 GB with this guide’s pool.
Real workers usually stay below their ceiling, so measure once the site has its theme, plugins and some content. After a few minutes of browsing the site and the dashboard, average the PHP-FPM processes’ resident memory:
ps --no-headers -o rss -C php-fpm8.3 | awk '{s+=$NF; n++} END {printf "%d processes, %.0f MiB average\n", n, s/n/1024}'
Then size the pool from the memory you can spare. Our rule of thumb: (RAM − about 1 GB for Ubuntu, MariaDB and Caddy, matching Ubuntu’s cloud-image minimum) ÷ average worker size. A worked example with an assumed 80 MiB per worker on a 4 GB server: 3,072 ÷ 80 ≈ 38 workers fit in memory, so CPU, not RAM, becomes the limit. Raise pm.max_children when evidence says so: PHP-FPM writes “server reached pm.max_children setting” to /var/log/php8.3-fpm.log whenever requests had to wait for a free worker.
Traffic is smaller than it sounds. 100,000 page views a month average 0.04 views per second (100,000 ÷ 2,592,000 seconds in 30 days); 1,000,000 a month average 0.39 per second. Peaks are what count, and only uncached views reach PHP. A rough ceiling for uncached pages is vCPUs ÷ CPU seconds per page. If a page needs 0.2 seconds of CPU, 1 vCPU serves about 5 uncached pages a second, or 18,000 an hour. To estimate your own figure, run this on the server a few times against a page that skips the page cache and take the lowest result:
curl -o /dev/null -s -w '%{time_starttransfer}\n' https://example.com/
| Your site | Starting plan | Monthly cap | Reasoning |
|---|---|---|---|
| Staging copy, test installs, a personal blog with a page cache | Quartz Q1: 1 vCPU, 1 GB, 25 GB NVMe | $5.00/month cap | Meets Ubuntu’s 1 GB cloud-image minimum and no more; set pm. to 2 or 3 and add a swap file |
| One blog or business site | Quartz Q2: 1 vCPU, 2 GB, 50 GB | $10.00/month cap | About 1 GB left for PHP above Ubuntu’s minimum; check the default 5 workers with the ps command |
| WooCommerce, a membership site, or 3 to 5 small sites | Quartz Q4: 2 vCPU, 4 GB, 80 GB | $15.00/month cap | Carts, checkouts and logged-in pages skip the page cache; meets Ubuntu’s 3 GB suggestion. For the handbook’s 512M WooCommerce tier, raise memory_ in the pool file: php_ stops WordPress from raising it |
| A busy store or many logged-in users at once | Chrono C8: 2 dedicated vCPU, 8 GB, 100 GB | $35.00/month cap | Uncached traffic is CPU-bound; dedicated vCPUs keep response times steady, and the RAM fits a larger buffer pool |
Before launch, load-test the site with k6 from a second, short-lived server to replace the estimate with a measured number. Media-heavy sites should also watch traffic: Istanbul plans include the monthly traffic allowance listed for each plan, prorated for a server that exists for part of a billing period.
WordPress on Caddy not working? Troubleshooting matrix
| Symptom | Likely cause | Check and fix |
|---|---|---|
| 502 Bad Gateway | Caddy cannot open the PHP socket: wrong path, PHP-FPM stopped, or the socket belongs to www-data | ls -l /run/ and systemctl status php8.; journalctl -u caddy shows “permission denied” or “no such file”. Set listen. in the pool |
| “Error establishing a database connection” | Wrong DB_, DB_ or DB_, or MariaDB stopped | sudo -u wordpress wp db check and systemctl status mariadb |
| Plugin updates ask for FTP “Connection Information” | Files not owned by the pool user, so WordPress cannot write directly | sudo chown -R wordpress: |
| “The uploaded file exceeds the upload_max_filesize directive” | PHP’s 2M default still applies | Raise upload_ and post_ in the pool, then sudo systemctl restart php8. |
| Too many redirects | Cloudflare set to Flexible, or the site URL saved as http: | Use Full (strict); check sudo -u wordpress wp option get home |
| Scheduled posts publish late or never | DISABLE_ is set but the cron file is missing | cat /etc/ and sudo -u wordpress wp cron event list |
| Site Health lists a missing module | Package not installed, or PHP-FPM not restarted after installing it | Install the php8. package, then sudo systemctl restart php8. |
| Password-reset emails never arrive | No mail transport; port 25 closed | An SMTP or API plugin with a mail provider (details) |
| No certificate, or a TLS error | DNS not pointing here, or ports 80/443 closed | Caddy’s troubleshooting matrix |
What does WordPress on a VPS cost per month?
WordPress itself is free to download; the server is the bill. Every HourlyVPS server is billed by the hour with a monthly cap, so an always-on site never costs more than its plan’s monthly price in a billing period (one month from your order date): $10.00 on Quartz Q2 and $15.00 on Quartz Q4. That cap is what a monthly VPS means here: no contract and no prepaid term. Every server comes with one dedicated IPv4 address and IPv6, included in the plan price.
| Duration | Hours on the meter | Cost $0.02 | Note |
|---|---|---|---|
| 2 hours | 2 | $0.04 | |
| 1 day | 24 | $0.48 | |
| 7 days | 168 | $3.36 | |
| 30 days | 720 | $10.00 | Capped at the monthly price |
Hourly billing earns its keep around the live site. Rehearse this guide before you touch production, test a major WordPress or PHP update on a restored copy, or run a load test, then delete the server. A three-hour update rehearsal on Q2 costs $0.06. Before deleting a test server, run the checklist before you delete a VPS so no data you need goes with it.
Billing: A new server starts with an initial credit, paid at checkout, that funds its own prepaid balance; its usage is deducted from that balance every hour. A stopped server is still billed, because its vCPU, memory, disk and IP addresses stay reserved for you; only deleting the server stops billing. How hourly billing works walks through the balance, top-ups and the cap, the hourly vs monthly guide shows why a capped hourly server never costs more than a monthly one, and the pricing page lists every plan.
Deploy this setup
Host a WordPress site on Caddy with automatic HTTPS
Quartz Q2 · 1 shared vCPU · 2 GB RAM · 50 GB NVMe · Istanbul
- Per hour$0.02/hour
- Per day (24 h)$0.48/day
- Monthly cap$10.00/monthFor this job
Starts with a $5 initial credit, which goes into the server’s balance and pays for its hours.
Billed by the hour, never more than $10.00 per billing period. Delete the server and billing stops.



