WIUNIX ACADEMY
Install WordPress on Nginx with a privacy-safe FastCGI cache
Install WordPress, PHP-FPM and MariaDB over HTTPS, then cache only reviewed public pages and prove that sessions and password-protected content stay separate.
Make this guide yoursYOUR SYSTEM. YOUR STEPS.
Where are you working?
Choose your OS and version. The prerequisites, commands and troubleshooting follow your environment.
USE YOUR OWN VALUES
Configure this tutorial
Set the example values for your environment; related explanations and copy panels update together. Never enter passwords, private keys or tokens. Values stay in this tab and are not sent to the server or share links. Check actual DNS, certificates and network policy before running commands.
Different IP addresses, domains or ports? Open the settings below, change the values and select “Apply to tutorial”, then copy the updated commands.
Set values for my environment4 editable values
THE OUTCOME
What will you have at the end?
A working single-site WordPress installation over HTTPS, with a dedicated PHP identity and a bounded public cache validated through actual response bodies and user isolation.
Supported operating systems and versions
Reviewed2026-11-05 against official documentation, release hashes and package paths for13 environments. Historical2026-10-20 Ubuntu24.04.4 namespace fixture used WordPress7.1.3/PHP8.3.6/MariaDB10.11.14/Nginx1.24.0 and checked real bodies, sessions, protected content, cache bypass/invalidation and rollback. The current7.1.4/fresh-host guard/RPM/rolling/service-start paths were not executed here. No public ACME/firewall, full OS installation, SQL restore or WooCommerce claim. DEMO images are illustrative.
Before you begin
- Fresh disposable x86_64 systemd VM with Python3/iproute2 already available, sudo/SSH,2GB RAM and10GB free disk for this small lab; production sizing is separate.
- Hostname under your control, correct A/AAAA, reachable 80/443, approved ACME email/account and your workstation public IP.
- Bash/curl test client and staging accounts with private cookie jars. No existing site/database, CDN/proxy or public FPM listener is assumed.
- Fresh x86_64 VM with systemd, Python3, iproute2 and an existing reviewed firewall policy; no existing database/site/PHP state.
LET’S BUILD
Confirm the fresh host and DNS
Use only a disposable fresh x86_64 systemd VM with Python3 and iproute2 already available. This is not an in-place install or migration. The guard rejects pre-existing web/database/PHP/certificate state, service units and unsafe or overlapping paths before package changes. Choose absolute system directories outside home; the form describes path syntax, not permission to overwrite existing state. Review the printed listeners and stop if any other service owns ports80/443/3306.
Set real DNS A/AAAA and the exact administrator source IP. Keep SSH and the existing firewall policy. No CDN/trusted-proxy design is included; public HTTP serves only challenges until TLS setup.
WordPress server, Bash, existing sudo administrator.
(
set -euo pipefail
. /etc/os-release
test "$ID" = almalinux
test "$VERSION_ID" = 10.2
test "$(uname -m)" = x86_64
test "$(ps -p 1 -o comm=)" = systemd
sudo python3 - <<'PY'
import os,pathlib,stat
paths=['/var/www/wordpress','/var/cache/nginx/wordpress','/etc/nginx','/etc/php','/etc/php-fpm.d','/var/lib/mysql','/etc/letsencrypt','/etc/academy-wordpress','/etc/php-fpm.conf','/etc/mysql','/etc/my.cnf','/etc/my.cnf.d','/var/lib/letsencrypt']
selected=[pathlib.Path(x)for x in paths[:2]]
if any(not x.is_absolute()or '..'in x.parts or x.parts[1:2]in[('home',),('root',)] for x in selected):raise SystemExit('Choose absolute system paths; STOP')
if selected[0]==selected[1] or any(x in y.parents for x,y in (selected,selected[::-1])):raise SystemExit('Paths must be disjoint')
for x in selected:
for raw in paths[2:]:
y=pathlib.Path(raw)
if x==y or x in y.parents or y in x.parents:raise SystemExit('Selected path overlaps reserved state: '+str(x))
for raw in paths:
p=pathlib.Path(raw)
if p.exists()or p.is_symlink():raise SystemExit('Existing state: '+raw)
for a in p.parents:
try:s=a.lstat()
except FileNotFoundError:continue
if stat.S_ISLNK(s.st_mode)or not stat.S_ISDIR(s.st_mode)or s.st_uid!=0 or s.st_mode&0o022:raise SystemExit('Unsafe parent: '+str(a))
PY
if getent passwd wp-php || getent group wp-php; then exit 1; fi
for unit in nginx.service mariadb.service mysql.service php-fpm.service php8.5-fpm.service php8.4-fpm.service php8.3-fpm.service php8.2-fpm.service certbot.service certbot.timer certbot-renew.service certbot-renew.timer; do
state=$(systemctl show "$unit" -p LoadState --value)
test "$state" = not-found
done
sudo ss -lntup
getent ahosts blog.example.com
)
Selected OS matches the server, DNS is correct and tutorial paths/ports do not collide with another installation.
LET’S BUILD
Install the distribution packages
Install the selected release packages from its signed repositories. Services are masked before installation; only the database starts here after a loopback-only binding file is written. Nginx stays off until its complete new configuration validates. The selected PHP pool later starts independently. This fresh-host procedure must not be reused on an existing service.
WordPress server, Bash, existing sudo administrator.
(
set -euo pipefail
. /etc/os-release
test "$ID" = 'almalinux'
test "$VERSION_ID" = '10.2'
sudo systemctl mask nginx.service mariadb.service php-fpm.service
sudo dnf install -y dnf-plugins-core epel-release
sudo dnf config-manager --set-enabled crb
curl_pkg=curl-minimal
if rpm -q curl >/dev/null 2>&1; then curl_pkg=curl; fi
sudo dnf install -y nginx mariadb-server php-fpm php-cli php-mysqlnd php-gd php-mbstring php-xml php-intl php-pecl-zip "$curl_pkg" ca-certificates tar rsync openssl nano certbot python3 shadow-utils policycoreutils-python-utils audit
sudo test ! -e /etc/my.cnf.d/zz-academy-wordpress.cnf
sudo test ! -L /etc/my.cnf.d/zz-academy-wordpress.cnf
printf '[mysqld]\nbind-address=127.0.0.1\n' | sudo tee /etc/my.cnf.d/zz-academy-wordpress.cnf >/dev/null
sudo systemctl unmask mariadb.service
sudo systemctl enable --now mariadb.service
php -v
mariadb --version
nginx -v
rpm -q nginx mariadb-server php-fpm certbot
sudo ss -lntp 'sport = :3306'
)
APT signature/dependency checks pass; record printed versions. No public 3306 listener.
LET’S BUILD
Preserve the unused vendor configuration before editing
Only on this freshly installed, still-masked Nginx: preserve its package configuration under a new name, then replace nginx.conf with the next complete file. No unrelated site is carried over. Do not overwrite an existing backup.
Fresh dedicated Linux VM, existing sudo administrator.
(
set -euo pipefail
sudo test ! -e /etc/nginx/nginx.conf.academy-before
sudo test ! -L /etc/nginx/nginx.conf.academy-before
sudo cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.academy-before
sudo install -d -o root -g root -m 0755 /etc/nginx/conf.d
sudo nano /etc/nginx/nginx.conf
)
The check succeeds; stop and diagnose any failure.
LET’S BUILD
Use the complete dedicated Nginx configuration
This fresh single-site configuration intentionally replaces the unused package default server and defines the http include. Preserve mime.types and fastcgi_params supplied by the package.
Fresh dedicated Linux VM, existing sudo administrator.
user nginx;
worker_processes auto;
pid /run/nginx.pid;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
include /etc/nginx/conf.d/*.conf;
}
The check succeeds; stop and diagnose any failure.
Open the local database console
Fresh Ubuntu/Debian MariaDB commonly uses Unix-socket authentication for local root. Open without SQL history and never pass a real password in a shell argument.
WordPress server, Bash, existing sudo administrator.
sudo env MYSQL_HISTFILE=/dev/null mariadb
Check succeeds; stop on an error before continuing.
LET’S BUILD
Create a schema-scoped application user
In the database console replace the password with a unique random 64-character hexadecimal password from your password manager (only 0–9/a–f, safe in both SQL/PHP string literals). The account has only the schema permissions needed to install/update WordPress tables, no global administration, remote login or GRANT OPTION. GRANT takes effect immediately; no FLUSH PRIVILEGES is needed. Keep this password for wp-config. Stop on an existing schema/user collision.
MariaDB administrator console opened in the previous panel, not Bash.
CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wp_site'@'localhost' IDENTIFIED BY 'REPLACE_WITH_A_LONG_RANDOM_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX
ON wordpress.* TO 'wp_site'@'localhost';
SHOW GRANTS FOR 'wp_site'@'localhost';
EXIT;
SHOW GRANTS lists wordpress.* permissions, with no global administrative grant.