How to Install WordPress on Ubuntu 24.04 with Nginx, MariaDB and PHP
By the end of this guide you will have WordPress running on your own Ubuntu server, reachable at your own domain, over HTTPS, on software you installed and control. The order matters here, for one reason. Most guides install WordPress, walk you through the setup wizard, and bolt on a certificate afterwards. That wizard is […]
By the end of this guide you will have WordPress running on your own Ubuntu server, reachable at your own domain, over HTTPS, on software you installed and control.
The order matters here, for one reason. Most guides install WordPress, walk you through the setup wizard, and bolt on a certificate afterwards. That wizard is where you choose your administrator password — so doing it before HTTPS means typing that password into a plain, unencrypted page. Below, you get the certificate working first. It costs nothing and it closes a real hole.
Every version number and every block of output below is what an Ubuntu 24.04 server printed when these commands were run on it in this order. One thing has been edited: that server answers to a hostname of its own, and example.com stands in its place so the paths read the way yours will. Certbot’s output is the exception — it names the real hostname, and the conventions box below says so.
Here is the whole job:
- Install the four pieces: Nginx, MariaDB, PHP, and the WordPress files themselves.
- Give WordPress a database and a user of its own.
- Tell Nginx how to serve the site.
- Point your domain at the server and get a certificate.
- Run WordPress’s setup wizard.
- Lock down the one file that matters and put a firewall in front.
If you already have a WordPress site running somewhere else, this is not the guide you want — moving an existing site is a different job with different risks, and Migrating a Site from Shared cPanel Hosting to a VPS covers it properly.
Before you start
You need four things.
A VPS running Ubuntu 24.04 or 26.04, with its public IP address to hand. If you have not created one yet, Getting Started with Lineserve Cloud: Launch Your First VPS walks through ordering the server.
A user on that server who can run sudo, and a working SSH login. Everything below starts from being logged in. Initial Ubuntu 24.04 Server Setup: Users, SSH, Firewall covers creating that user and getting SSH keys working; do it first. Do not do any of this as root. The sudo in front of each command supplies administrator privilege exactly where it is needed and nowhere else, which is the whole point of typing it.
A domain name you control, and access to wherever its DNS is managed. That is usually your registrar, sometimes Cloudflare. You need this before step 8; without it there is no certificate, because the certificate authority proves you own the name by making a request to it.
A terminal. macOS and Linux have one built in. On Windows, use PowerShell or Windows Terminal — both include ssh.
Three words you will meet immediately. SSH is the encrypted connection you use to type commands on a server that isn’t in front of you. sudo is the prefix that runs one command with administrator rights. A record is the DNS entry that maps your domain to an IP address.
Ubuntu 24.04 already includes curl, nano and ufw, so there is nothing to install before step 2.
What the examples stand for
Every command below uses concrete values. Swap in your own as you go:
| What it is | The value used below | Where yours comes from |
|---|---|---|
| Your domain | example.com |
Your own domain name |
| Your server’s IP address | 100.54.191.124 |
Your VPS provider’s control panel |
| The user you log in as | ubuntu |
Whatever you named it during server setup |
| The site’s directory on the server | /var/www/example.com |
You create it in step 6; name it after your domain |
| Database name | wordpress |
You choose it, in step 4 |
| Database user | wp_user |
You choose it, in step 4 |
| Database password | Rk4$tVn8pQz2 |
You generate your own — do not reuse this one |
| WordPress admin username | janet |
You choose it, in step 10 |
| WordPress admin password | Hq7#zTr4$Wm9pLx2 |
You generate your own |
Anything long and random will do for the two passwords; a password manager will make you one.
One name in the table needs a word of warning in the other direction. wp.lab.lineserve.dev is the hostname the server behind these outputs answers to, and it survives in Certbot’s output in step 8 and in two of the screenshots, because certificate output and browser screens cannot be edited without falsifying them. Read it as your own domain wherever it appears; it is never a name to type.
A note on versions before you install anything
One decision is worth making now rather than regretting later, and it is the one behind “install the latest everything”.
Ubuntu’s own repositories do not carry the newest release of every piece of software. They carry the version that shipped with your Ubuntu release, and then patch that version in place rather than moving you onto a newer one. So on Ubuntu 24.04, apt install php gives you PHP 8.3 — not because it is stale by accident, but because 8.3 is what 24.04 was built and tested against.
That has a consequence worth knowing. PHP 8.3’s own active support from the PHP project ended on 31 December 2025; upstream now ships only security fixes for it. Ubuntu carries on patching what it shipped, which you can see in the package version itself — the -0ubuntu0.24.04.9 on the end of 8.3.6 is Ubuntu’s ninth revision of that same upstream release, and it arrives from the security channel like any other update.
There are two routes to newer PHP, and one of them is much better than the other:
| Ubuntu 24.04 | Ubuntu 26.04 | |
|---|---|---|
apt install php gives you |
PHP 8.3 | PHP 8.5 |
| Nginx | 1.24.0 | 1.28.3 |
| MariaDB | 10.11 | 11.8 |
| Supported until | May 2029 | April 2031 |
If you want the newest PHP, choose the newer Ubuntu when you create the server — not a third-party package repository on an older one. Ubuntu 26.04 has been the current long-term release since 23 April 2026 and its own repositories carry PHP 8.5, which WordPress documents as fully supported from version 6.9 onwards. Every command in this guide works there as written; the version numbers printed will read higher, and php8.3-fpm becomes php8.5-fpm in the places it appears. Individual themes and plugins are their own question on any new PHP release — WordPress’s support statement covers core.
The other route is a third-party repository — the Ondřej Surý PPA is the well-known one — which puts newer PHP onto an older Ubuntu. It works, and plenty of people run it. What it costs you is that a routine sudo apt upgrade can now move you across a PHP major version, which is how a working site becomes a broken one on a Tuesday morning; that your PHP is versioned and released on someone else’s schedule rather than as part of the Ubuntu release you are already running; and that unpicking it later, if you want back onto plain Ubuntu packages, is genuinely awkward. For a first WordPress server that is a maintenance burden bought for very little.
Step 1: Log in to the server
From a terminal on your own computer, substituting your user and your server’s IP:
ssh [email protected]
The first time you connect to any new server, SSH stops to ask whether you trust it, showing a fingerprint it has never seen before. Type the word yes in full — y on its own will not do. You will only be asked once per server.
Ubuntu greets you with a release line and some statistics, and then hands you a prompt ending in $:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.17.0-1019-aws x86_64)
...
ubuntu@wp-vps:~$
Everything from here to step 9 is typed at that prompt.
Confirm what you are actually on before you install anything onto it:
lsb_release -a
The Description line is the one that matters:
Distributor ID: Ubuntu
Description: Ubuntu 24.04.4 LTS
Release: 24.04
Codename: noble
If that says 22.04 or something older, stop and rebuild the server on a current release rather than working around it.
Step 2: Install Nginx, MariaDB and PHP
Refresh the package lists first, then install everything in one go:
sudo apt update
sudo apt install -y nginx mariadb-server php-fpm php-mysql php-curl php-gd php-xml php-mbstring php-zip php-intl
The first time you use sudo in a session it may ask for your login password, and it deliberately prints nothing as you type — no dots, no asterisks. Type it blind and press Enter. If your server uses SSH keys with passwordless sudo, you will not be asked at all; that is normal too.
Part-way through, Ubuntu may throw up a full-screen purple dialog asking which services to restart. The pre-selected answers are correct — press Enter to accept them.
Those seven php-* packages after php-fpm are the PHP extensions WordPress and its common plugins expect: database access, image resizing, XML and multibyte text handling, ZIP for plugin installs, and internationalisation. Installing them now saves you diagnosing a blank page later.
apt prints several hundred lines. Rather than reading them, ask each piece what you ended up with:
nginx -v
mariadb --version
php -v | head -1
systemctl is-active nginx mariadb php8.3-fpm
The | head -1 on the third line trims PHP’s reply to its first line; on its own php -v prints a short paragraph you do not need. You want three version strings and then active three times over:
nginx version: nginx/1.24.0 (Ubuntu)
mariadb Ver 15.1 Distrib 10.11.14-MariaDB, for debian-linux-gnu (x86_64) using EditLine wrapper
PHP 8.3.6 (cli) (built: May 25 2026 13:12:06) (NTS)
active
active
active
If any of the three says inactive or failed, stop here — nothing downstream will work. systemctl status nginx (or mariadb, or php8.3-fpm) will tell you why.
On Ubuntu 26.04 that last command is systemctl is-active nginx mariadb php8.5-fpm, and the version numbers printed will be higher. Note whichever PHP version you have; step 7 needs it.
Step 3: Secure the database server
MariaDB has a handful of settings that are fine on a laptop and not fine on a server facing the internet, and one command walks through all of them. Ubuntu’s packaging already closes some of these when it installs — this confirms that and handles the rest.
sudo mariadb-secure-installation
It asks a series of questions, and the answers are not all obvious. Take them in order:
- “Enter current password for root” — press Enter. There isn’t one. Ubuntu’s MariaDB authenticates the root database user against your system account instead of a password, which is why the script goes on to say your root account is already protected.
- “Switch to unix_socket authentication” — answer
n. That is already how it works. - “Change the root password” — answer
n, for the same reason. - Everything after that — answer
y. Removing anonymous users, disallowing remote root login, dropping the sampletestdatabase, reloading privileges. All four are things you want, and each reportsSuccess!whether it had work to do or had already been handled at install time.
The script narrates itself as it goes, and the four y answers each report back:
Remove anonymous users? [Y/n] ... Success!
Normally, root should only be allowed to connect from 'localhost'. This
ensures that someone cannot guess at the root password from the network.
Disallow root login remotely? [Y/n] ... Success!
Remove test database and access to it? [Y/n] - Dropping test database...
... Success!
- Removing privileges on test database...
... Success!
Reload privilege tables now? [Y/n] ... Success!
Cleaning up...
All done! If you've completed all of the above steps, your MariaDB
installation should now be secure.
Four Success! lines and All done! is what finished looks like.
Step 4: Create a database for WordPress
WordPress needs a database of its own and a user with rights to exactly that database and nothing else. Open the database prompt:
sudo mariadb
You are now inside MariaDB rather than the shell, which the prompt tells you by changing to MariaDB [(none)]>. Type these five lines one at a time, each ending in a semicolon. Change the password to one you generate yourself:
CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'Rk4$tVn8pQz2';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wp_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
The whole session, prompts included, looks like this — every statement answered with Query OK:
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 43
Server version: 10.11.14-MariaDB-0ubuntu0.24.04.1 Ubuntu 24.04
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [(none)]> CREATE DATABASE wordpress DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Query OK, 1 row affected (0.000 sec)
MariaDB [(none)]> CREATE USER 'wp_user'@'localhost' IDENTIFIED BY 'Rk4$tVn8pQz2';
Query OK, 0 rows affected (0.002 sec)
MariaDB [(none)]> GRANT ALL PRIVILEGES ON wordpress.* TO 'wp_user'@'localhost';
Query OK, 0 rows affected (0.002 sec)
MariaDB [(none)]> FLUSH PRIVILEGES;
Query OK, 0 rows affected (0.000 sec)
MariaDB [(none)]> EXIT;
Bye
utf8mb4 in that first line is what lets your posts hold emoji and every non-English alphabet without mangling them. 'wp_user'@'localhost' means this user can only connect from the server itself, never across the network.
If MariaDB shows you a -> prompt and appears to hang, you left the semicolon off the end of a line. Type ; and press Enter.
EXIT; puts you back at the shell. Now prove the account you just made actually works, rather than assuming it:
mariadb -u wp_user -p -e "SHOW DATABASES;"
Type the password you set when it asks. Nothing appears as you type it. You should see exactly two databases — wordpress, and the information_schema catalogue that every user can see:
Enter password:
+--------------------+
| Database |
+--------------------+
| information_schema |
| wordpress |
+--------------------+
If you get ERROR 1045 ... Access denied, the password you typed is not the one in the CREATE USER line. Go back and run that statement again with a password you are sure of.
Step 5: Download WordPress and check what you got
Fetch the current release from wordpress.org, into your home directory:
cd ~
curl -O https://wordpress.org/latest.tar.gz
curl draws a progress meter while it works. Roughly 28 MB:
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 28.1M 100 28.1M 0 0 243M 0 --:--:-- --:--:-- --:--:-- 245M
WordPress publishes a checksum for that file, so you can confirm you received what they published rather than something a broken download or an interfering network handed you. Compare them mechanically rather than by eye:
echo "$(curl -s https://wordpress.org/latest.tar.gz.sha1) latest.tar.gz" | sha1sum -c -
One word is the whole answer, and it needs to be this one:
latest.tar.gz: OK
FAILED means the file is not what wordpress.org published. Delete it and download it again; do not carry on with it.
Now unpack it:
tar -xzf latest.tar.gz
That creates a wordpress directory in your home folder. Check which version you actually have by reading it out of the source rather than trusting the download page:
grep "wp_version =" ~/wordpress/wp-includes/version.php
One line comes back:
$wp_version = '7.0.2';
Yours will be that or newer.
Step 6: Put the files where Nginx will look for them
Nginx serves files from under /var/www. Make a directory named after your site and copy WordPress into it. The . on the end of the source path copies the contents of wordpress, not the folder itself:
sudo mkdir -p /var/www/example.com
sudo cp -a ~/wordpress/. /var/www/example.com/
Those files currently belong to you. The web server runs as a different user, www-data, and it needs to own them:
sudo chown -R www-data:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
The two find lines mean “for every directory below this point run chmod 755, and for every file chmod 644“. The {} stands for each item found and the \; on the end is required punctuation, not a typo.
Giving www-data ownership of the whole tree is what lets WordPress install plugins and update itself from the dashboard, and it is also what lets the setup wizard in step 10 write its own configuration file. It is a real trade: code the web server can write is code an exploited plugin can rewrite. WordPress’s own hardening guidance prefers that you own the files and grant write access only where it is needed. If you would rather manage every update over SSH, that is the stricter arrangement and it is a legitimate choice.
Check it took:
ls -ld /var/www/example.com
Both the owner and the group should read www-data:
drwxr-xr-x 5 www-data www-data 4096 Jul 17 17:52 /var/www/example.com
If it still says your own username, the chown did not run — WordPress will load but will refuse to update itself.
Step 7: Tell Nginx about the site
Nginx needs a file describing your site. Create it:
sudo nano /etc/nginx/sites-available/example.com
nano is a plain text editor: arrow keys move, typing edits, Ctrl+O then Enter saves, Ctrl+X quits. Type this in, with your own domain on the server_name line and your own directory on root:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.php index.html;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
location ~ /\.ht {
deny all;
}
}
Four of those lines are doing the real work:
try_files $uri $uri/ /index.php?$args;is what makes WordPress’s readable post URLs work. Nginx looks for a real file, then a real directory, and when neither exists it hands the request to WordPress to interpret. This is thetry_filesdirective, and if you have used WordPress on Apache before, it does the job that.htaccessdid there. Nginx has no.htaccessat all; rewriting lives here instead.fastcgi_pass unix:/run/php/php8.3-fpm.sock;is where PHP lives. Nginx cannot run PHP itself — it passes those requests to PHP-FPM over that socket. On Ubuntu 26.04 this line readsphp8.5-fpm.sock. Getting it wrong is the usual cause of a WordPress install serving a blank page, or offering to downloadindex.phpinstead of running it. If you are unsure,ls /run/php/lists the sockets that actually exist.client_max_body_size 64M;lets you upload media larger than Nginx’s 1 MB default. It has to match the PHP limits set in step 9.server_namemust list every form of the domain you intend to use,wwwincluded.
Save and exit. Now enable the site, and switch off the Ubuntu placeholder page that currently owns port 80:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
Neither command prints anything. A prompt coming straight back is what success looks like here, so do not run them a second time — and if the rm says No such file or directory, the placeholder was already gone and you can carry on.
Before reloading, check the configuration — a syntax error here will stop Nginx from starting at all:
sudo nginx -t
Both lines need to be positive:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Anything else names the file and the line number to go and fix. With a clean test, apply it:
sudo systemctl reload nginx
A reload prints nothing and hands the prompt straight back.
Step 8: Point your domain at the server, then get a certificate
Go to wherever your domain’s DNS is managed and add an A record. Three fields, whatever the panel looks like:
- Type:
A - Name:
@for the bare domain (some panels want the domain itself, or leave it blank). Add a second A record with the namewwwif you wantwww.example.comto work too. - Value / Points to: your server’s IP address —
100.54.191.124here. - TTL: whatever the panel already has. Leave it alone.
Every registrar’s panel looks different and the labels move around, so go by what each field is for rather than by what it is called. The second field is often Host instead of Name; either way, @ or an empty box means the bare domain. Some panels split the record into a row you fill in and a separate Add or Save button, and the record does not exist until you press it — a half-filled row that looks right on screen is the usual reason the check below fails.
If your provider offers a proxy or CDN toggle on the record (Cloudflare calls it the orange cloud), leave it off for now. The certificate step below needs the request to reach your server directly.
DNS changes are not instant. Check from the server that the name now reaches it, before going on:
curl -sI http://example.com/ | head -1
WordPress has not been set up yet, so it answers by redirecting you to its own installer. A 302 is therefore exactly right at this point, and so is any other status line — all you are testing is that the request arrived:
HTTP/1.1 302 Found
If instead it stalls and then reports Connection timed out, one of two things is true: DNS has not propagated yet, in which case wait and retry; or your provider’s firewall is blocking inbound ports 80 and 443, in which case open them in their control panel. It is much easier to sort out here than to diagnose in step 10, where it looks exactly like a broken WordPress install.
With the name resolving, get the certificate:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Name only domains that actually point at this server. Certbot validates them as one request, so including a www that does not resolve fails the whole thing and issues nothing. If you did not create a www record, drop that second -d.
Certbot asks three things the first time — an email address for expiry warnings, whether you accept the terms of service, and whether you want EFF’s mailing list. Then it works:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/wp.lab.lineserve.dev/fullchain.pem
Key is saved at: /etc/letsencrypt/live/wp.lab.lineserve.dev/privkey.pem
This certificate expires on 2026-10-17.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
Deploying certificate
Successfully deployed certificate for wp.lab.lineserve.dev to /etc/nginx/sites-enabled/example.com
Congratulations! You have successfully enabled HTTPS
Certbot edited the file you wrote in step 7 — it added a second server block listening on 443 with the certificate attached, and turned the original port 80 block into a redirect. You do not need to do anything with that; it is why --nginx exists.
Certbot writes the certificate under a directory named after your domain — /etc/letsencrypt/live/example.com/ on your server. That is where to look if you ever need the files themselves.
Confirm both halves behave. The second command asks for the installer page rather than /, because / is still redirecting to it:
curl -sI http://example.com/ | head -1
curl -sI https://example.com/wp-admin/setup-config.php | head -1
Plain HTTP should now bounce you to HTTPS, and the installer should answer over HTTPS:
HTTP/1.1 301 Moved Permanently
HTTP/1.1 200 OK
That certificate expires in 90 days, and Certbot installed a timer to renew it. A renewal that quietly fails takes your site offline three months from now, so rehearse it rather than trusting it:
sudo certbot renew --dry-run
This simulates the whole renewal without touching the live certificate:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/wp.lab.lineserve.dev/fullchain.pem (success)
Step 9: Raise PHP’s limits before you need them
PHP ships with limits that are too small for a site with pictures on it. The default caps uploads at 2 MB, which is smaller than a photo from any phone made this decade.
Running php -i shows you the settings for PHP’s command-line mode, which are not the ones your website uses. The website runs through PHP-FPM, which reads a different file. Look at the right one:
grep -E '^(upload_max_filesize|post_max_size|memory_limit|max_execution_time)' /etc/php/8.3/fpm/php.ini
grep -E prints only the lines that start with one of the four settings named in the brackets, out of a file that is some two thousand lines long. The defaults as shipped:
max_execution_time = 30
memory_limit = 128M
post_max_size = 8M
upload_max_filesize = 2M
Raise all four in one pass:
sudo sed -i 's/^upload_max_filesize = .*/upload_max_filesize = 64M/; s/^post_max_size = .*/post_max_size = 64M/; s/^memory_limit = .*/memory_limit = 256M/; s/^max_execution_time = .*/max_execution_time = 300/' /etc/php/8.3/fpm/php.ini
sed -i edits the file in place and prints nothing, so read it back to see that it worked:
grep -E '^(upload_max_filesize|post_max_size|memory_limit|max_execution_time)' /etc/php/8.3/fpm/php.ini
All four should have moved:
max_execution_time = 300
memory_limit = 256M
post_max_size = 64M
upload_max_filesize = 64M
upload_max_filesize and post_max_size need to match each other, and both need to be no larger than the client_max_body_size 64M you put in the Nginx file — the smallest of the three wins.
A configuration file change needs a full restart, not a reload:
sudo systemctl restart php8.3-fpm
systemctl is-active php8.3-fpm
The restart says nothing at all when it works, so the second command is how you know it did:
active
On Ubuntu 26.04, the path is /etc/php/8.5/fpm/php.ini and the service is php8.5-fpm.
Step 10: Run the WordPress setup wizard
Everything is in place, and it is all behind HTTPS. Open your site in a browser:
https://example.com/
WordPress notices it has no configuration yet and starts its setup. First it asks which language to install in:
{{SHOT:01-language|WordPress setup configuration screen showing the language selection list with English (United States) at the top}}
Pick yours and click Continue. Next it tells you what it is about to ask for:
{{SHOT:02-before-you-begin|WordPress “Before getting started” screen listing the five pieces of database information the installer will ask for}}
Click Let’s go!. Now the form that matters — the database details from step 4:
{{SHOT:03-db-form-empty|Empty WordPress database connection form with fields for database name, username, password, host and table prefix}}
Fill it in with the values you chose:
- Database Name:
wordpress - Username:
wp_user - Password: the one you generated in step 4
- Database Host:
localhost— leave this alone. The database is on the same machine as the site, which is whatlocalhostmeans. - Table Prefix:
wp_— leave this alone too unless you intend to run two WordPress sites in one database.
{{SHOT:04-db-form-filled|WordPress database connection form filled in with database name wordpress, username wp_user, host localhost and table prefix wp_}}
Click Submit. WordPress tries the credentials immediately and tells you whether they worked:
{{SHOT:05-connection-ok|WordPress “Successful database connection” screen confirming it can now communicate with the database}}
If instead you get “Error establishing a database connection”, the details do not match what you created in step 4. The mariadb -u wp_user -p check at the end of that step is the fastest way to find out which of them is wrong.
Click Run the installation, and WordPress asks about the site itself:
{{SHOT:06-install-arrival|WordPress “Welcome” screen with empty fields for site title, username, password and email address}}
Four things to decide, and two of them matter more than they look:
- Site Title — changeable later, so do not agonise.
- Username — do not use
admin. Automated login attempts try that one name against WordPress sites relentlessly, and picking anything else sidesteps that entire class of guess.janetis the name used below. - Password — WordPress suggests a strong one; keep it, or paste in your own. This is the password that was worth turning HTTPS on for.
- Your Email — where password resets and update notices go. Use one you can actually read.
{{SHOT:08-site-details-filled|WordPress site details form filled in with site title Example Site, username janet, a strong password marked Strong, and an email address}}
Click Install WordPress. It takes a couple of seconds:
{{SHOT:09-install-success|WordPress installation success screen confirming the install completed, with a Log In button}}
Now log in with the username and password you just chose:
{{SHOT:10-login|WordPress login screen for Example Site with username and password fields}}
And you are in:
{{SHOT:11-dashboard|WordPress 7.0.2 admin dashboard showing the welcome panel, Site Health, At a Glance with one published post, and the WordPress events feed}}
Check that step 9 reached WordPress. Go to Media → Add Media File in the left-hand menu:
{{SHOT:12-media-upload-limit|WordPress Upload Media screen showing “Maximum upload file size: 64 MB”}}
Maximum upload file size: 64 MB. That number is PHP’s upload_max_filesize, read back through WordPress. If it still says 2 MB, the systemctl restart php8.3-fpm did not happen.
Step 11: Turn on readable post URLs
By default WordPress serves posts at addresses like example.com/?p=123. Nobody wants that, and search engines prefer the readable form. Go to Settings → Permalinks:
{{SHOT:13-permalinks|WordPress Permalink Settings screen with the default Plain structure selected and the other options listed}}
Choose Post name and click Save Changes:
{{SHOT:14-permalinks-saved|WordPress Permalink Settings with the Post name structure selected, showing post URLs in the form example.com/sample-post/}}
The try_files line from step 7 already handles this, so there is nothing further to configure.
Now go back to your SSH session on the server — the terminal window from step 1 — because the next few checks are typed there rather than in the browser. WordPress created a sample post called Hello world! during the install, so the address below already exists on your site. Check that it loads:
curl -s -o /dev/null -w "post -> %{http_code}\n" https://example.com/hello-world/
The status code is the test:
post -> 200
A 404 here is covered below.
Step 12: Close the file that holds your database password
Still in your SSH session on the server. The setup wizard wrote wp-config.php for you, which saved you a job. It also left it readable by every account on the server, and that file contains your database password in plain text. Look at it:
ls -l /var/www/example.com/wp-config.php
The mode is the block of letters at the very start of the line:
-rw-rw-rw- 1 www-data www-data 3619 Jul 19 21:17 /var/www/example.com/wp-config.php
-rw-rw-rw- means read and write for the owner, for the group, and for everybody else. You can prove what that means to yourself in one command — your own login is not www-data, and yet:
cat /var/www/example.com/wp-config.php | head -3
No sudo, no permission error, and the file opens:
<?php
/**
* The base configuration for WordPress
Scroll further and your database password is sitting there in plain text. On a server where you are the only account that is survivable. The moment anything else runs under its own account — a second site, another person, a service that gets compromised — it can read those credentials, and write to the file as well.
Fix it:
sudo chmod 640 /var/www/example.com/wp-config.php
Read the mode back:
ls -l /var/www/example.com/wp-config.php
Same file, three permissions shorter:
-rw-r----- 1 www-data www-data 3619 Jul 19 21:17 /var/www/example.com/wp-config.php
Now run the identical cat command again, as the same user:
cat /var/www/example.com/wp-config.php
The account that read the whole file a moment ago cannot open it at all:
cat: /var/www/example.com/wp-config.php: Permission denied
That is the property you wanted, demonstrated rather than assumed. PHP still reads the file, because PHP runs as www-data and www-data is the group on that line. Confirm the site itself is unharmed:
curl -s -o /dev/null -w "site -> %{http_code}\n" https://example.com/
Still serving, because nothing WordPress needs was taken away:
site -> 200
Step 13: Put a firewall in front of it
Your server is exposed directly to the internet in a way shared hosting never was, and by default nothing on the machine is filtering what reaches it. Ubuntu ships ufw for this, and Nginx registers itself with it during install:
sudo ufw app list
Nginx has registered three profiles of its own, alongside the one for SSH:
Available applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH
Allow SSH and the web, then turn it on. Allow OpenSSH before you enable anything — get this order wrong and you lock yourself out of your own server:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
ufw enable warns that this may disrupt existing SSH connections and asks you to confirm. Because you allowed OpenSSH first, it will not. Answer y:
Firewall is active and enabled on system startup
Check what you ended up with:
sudo ufw status verbose
Incoming denied by default, with three deliberate exceptions:
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
80,443/tcp (Nginx Full) ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
80,443/tcp (Nginx Full (v6)) ALLOW IN Anywhere (v6)
ufw is the first layer and not the whole story. How to Set Up a Firewall with UFW on Ubuntu 24.04 goes further into rules and logging.
When the homepage works but every post returns 404
This is the failure to know by sight on Nginx, because the site looks half-alive: the front page is perfect and every individual post is a bare “404 Not Found”.
The cause is almost always the try_files line in step 7 — missing, mistyped, or written to end in =404 rather than handing off to /index.php. Ask your own server the same two questions, from your SSH session:
curl -s -o /dev/null -w "homepage -> %{http_code}\n" https://example.com/
curl -s -o /dev/null -w "a post -> %{http_code}\n" https://example.com/hello-world/
With that line deliberately broken on an otherwise working install, this is genuinely what came back — and it is the signature to recognise:
homepage -> 200
a post -> 404
Now look at the page the post returned:
curl -s https://example.com/hello-world/
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.24.0 (Ubuntu)</center>
</body>
</html>
The tell is that last line: the 404 is coming from Nginx, not from WordPress. A WordPress 404 wears your theme. This one does not, because WordPress never saw the request — Nginx looked for a directory called hello-world, did not find one, and gave up without asking.
Open the file and look at the location / block:
sudo grep -n try_files /etc/nginx/sites-available/example.com
It must read exactly this, /index.php?$args and all:
try_files $uri $uri/ /index.php?$args;
Fix it if it does not, then test and reload:
sudo nginx -t
sudo systemctl reload nginx
The same post immediately afterwards:
a post -> 200
If posts still 404 with that line correct, go to Settings → Permalinks in the dashboard and click Save Changes without altering anything. That makes WordPress rebuild its own rewrite rules.
Confirm it all survives a restart
Every service you installed is set to start at boot, but that is the configuration rather than the proof. Test it once now, while nothing depends on it:
sudo reboot
Your SSH session will freeze and then print something like client_loop: send disconnect: Broken pipe. That is the server going down, not a mistake on your part. Wait a minute, reconnect with the same ssh command from step 1, and ask:
systemctl is-active nginx mariadb php8.3-fpm
sudo ufw status | head -1
All three services back on their own, and the firewall still up:
active
active
active
Status: active
Then check the site itself:
curl -s -o /dev/null -w "homepage -> %{http_code}\n" https://example.com/
curl -s -o /dev/null -w "a post -> %{http_code}\n" https://example.com/hello-world/
curl -s -o /dev/null -w "plain http -> %{http_code}\n" http://example.com/
Two 200s and the redirect still redirecting:
homepage -> 200
a post -> 200
plain http -> 301
{{SHOT:15-live-post|The published Hello world! post rendering on the live site over HTTPS in the default Twenty Twenty-Five theme}}
What size server this needs
Everything above was set up and checked on a machine with 2 vCPU and 2 GB of RAM, and the whole stack installed and ran there without complaint. The WordPress files themselves come to 94 MB, so disk is not what you are buying — a fresh install and its database leave most of a 20 GB volume untouched.
Two honest caveats before the plans, because a sizing recommendation is only worth what it was measured on. Nothing here was put under sustained traffic, so treat any number as a starting point rather than a capacity figure. And the machine used had two cores; a single-core box was not exercised, so the paragraph below is a recommendation, not a result.
On Lineserve, that sits between S1 — 1 vCPU, 2 GB RAM, 20 GB SSD, 1 TB transfer, KES 1,746 ($13.51) a month — and M1, at 2 vCPU, 4 GB RAM, 80 GB SSD and 3 TB transfer for KES 4,418 ($34.18). S1 matches the memory this was built on and halves the processor, which is the usual and sensible starting point for a new site with modest traffic; M1 is the like-for-like match on cores with room above it, and the one to choose if you already know the site will be busy or you would rather not think about it again for a year. If it is photographs you are worried about rather than traffic, S2 sits between them on storage alone — same 1 vCPU and 2 GB, 40 GB SSD and 2 TB transfer, KES 2,372 ($18.35).
All three come with a dedicated IPv4 address, which is what the A record in step 8 needs, and all three are billed in shillings by M-Pesa, bank transfer or card, with no foreign card or currency conversion involved.
If you land on a 2 GB plan and later find WordPress running out of memory during plugin updates, Swap Space on a VPS: When You Need It and How to Add It explains when adding swap genuinely helps and when it only hides the problem.