Installing WordPress on Nginx is a popular self-hosted setup: a few packages, a database, a clean server block, PHP-FPM for executing PHP, and a fresh wp-config.php. This DevAnswers guide walks through a complete, secure install on Debian 12 (Bookworm) with the distro’s stock Nginx 1.22 and MariaDB 10.11 plus PHP 8.2 from the official repositories.
The walkthrough installs Nginx, MariaDB, and PHP-FPM from scratch, then covers the PHP extensions WordPress needs, a dedicated database and user, the file-permission scheme PHP-FPM expects, a clean nginx server block with try_files for pretty permalinks, a Let’s Encrypt certificate obtained before the installer runs, and a hardened wp-config.php.
Prerequisites
You need a server running Debian 12 (Bookworm) and a domain name. Everything else is installed below.
- A non-root user with
sudoprivileges (see our initial server setup guide if you haven’t created one). - A domain name with A records for both the apex and the
wwwhost, pointed at your server’s public IP. Certbot requests a certificate covering both names later and fails if either does not resolve. Examples below usedevanswers.test; substitute your real domain.
Install the LEMP Stack
WordPress needs three things before it can run: a web server, a database, and PHP. This section installs all three on a clean Debian 12 (Bookworm) server, along with curl and wget, which later steps use to fetch WordPress and its salt keys. For the reasoning behind each component, our complete LEMP stack guide for Debian 12 (Bookworm) covers the same ground in depth.
sudo apt update
sudo apt install -y nginx curl wget
Open the firewall so the site is reachable. The nginx package registers an Nginx Full profile covering ports 80 and 443:
Debian does not ship ufw, so install it first. Allow SSH in the same breath: if you enable the firewall later without that rule, you lock yourself out of the server.
sudo apt install -y ufw
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
If you followed our initial server setup guide, ufw is active with only SSH allowed, and this command is what lets visitors reach your site. If the firewall is not enabled, the rule is stored and applies whenever you turn it on.
If Nginx fails to start with an address-already-in-use error, another web server holds port 80. Some images ship with Apache installed:
sudo systemctl disable --now apache2
sudo systemctl start nginx
Install the database server, then run the hardening script:
sudo apt install -y mariadb-server
sudo mariadb-secure-installation
Keep root on unix_socket authentication rather than setting a root password, then accept the prompts to remove anonymous users, disallow remote root login, drop the test database, and reload privileges. Leaving root on unix_socket is deliberate: it is why the sudo mysql command in the Create a Database step works without a password.
Install the PHP-FPM runtime that Nginx hands .php requests to:
sudo apt install -y php8.2-fpm
systemctl is-active php8.2-fpm
active
You now have Nginx 1.22, MariaDB 10.11, and PHP 8.2 running. The next section adds the extensions WordPress specifically needs.
Install Required PHP Extensions
WordPress requires a small set of PHP extensions beyond the core. mysqli is mandatory (database connectivity); gd handles image resizing for uploaded media (with imagick as an optional higher-quality alternative); curl, mbstring, xml, and zip are used by the updater, the REST API, and plugin/theme installers. Without these, the dashboard will display warnings on the Site Health screen.
sudo apt install -y php8.2-mysql php8.2-curl php8.2-gd php8.2-mbstring php8.2-xml php8.2-zip
Then install imagick separately. It produces higher-quality thumbnails than gd and is the WordPress-community-preferred image backend. It lives in Ubuntu’s universe component (and the equivalent on Debian); on the rare minimal image where universe is disabled, the next command fails harmlessly and WordPress falls back to gd:
sudo apt install -y php8.2-imagick || echo "imagick not available; WordPress will use gd as fallback"
The php8.2-mysql meta-package pulls in mysqli and the modern mysqlnd driver. After installation, reload PHP-FPM so the new modules are picked up:
sudo systemctl reload php8.2-fpm
Confirm all required extensions are loaded against the FPM SAPI (not the CLI), since that’s the one nginx will use:
php-fpm8.2 -m | grep -E '^(mysqli|curl|gd|mbstring|xml|zip|imagick)$'
curl
gd
imagick
mbstring
mysqli
xml
zip
You should see all 7 names if imagick installed, or 6 (everything except imagick) if it fell back. If any of the required ones (everything except imagick) are missing, install the matching php8.2-<name> package individually and reload PHP-FPM.
Create a Database for WordPress
WordPress stores all site content (posts, settings, users) in a single MySQL/MariaDB database. Create a dedicated database and user with privileges scoped to just that database. Never let WordPress use the root account.
Log into the database shell as root:
sudo mysql
Now create the database and user. Use utf8mb4 with utf8mb4_unicode_ci collation; this is the standard for modern WordPress (4-byte UTF-8 supports emoji and the full Unicode range). Substitute change-this-strong-password with a strong generated password before running:
CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'change-this-strong-password';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Verify the user can connect to the new database:
mysql -u wpuser -p wordpress -e 'SHOW DATABASES;'
Enter the password when prompted. You should see wordpress listed alongside information_schema.
Download WordPress
Fetch the latest stable release from wordpress.org. The latest.tar.gz URL always serves the current version, so this command keeps working without edits as new releases ship.
cd /tmp
wget https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
This extracts a wordpress/ directory inside /tmp. Move its contents into nginx’s web root, then remove the empty extracted folder and the downloaded archive:
sudo rm -f /var/www/html/index.nginx-debian.html /var/www/html/index.html
sudo cp -a /tmp/wordpress/. /var/www/html/
rm -rf /tmp/wordpress /tmp/latest.tar.gz
index.nginx-debian.html is what shows the “Welcome to nginx” page after a fresh install. Removing it (along with any index.html) ensures the server block’s index index.php directive lands on WordPress’s bootstrap script. Set File Permissions
WordPress runs as the PHP-FPM user (www-data on Ubuntu and Debian, which is also nginx’s user) and needs write access to upload media, install plugins, and update itself. The canonical permission scheme is 755 for directories (read+execute for everyone, write for owner), 644 for files (read for everyone, write for owner), and 640 for wp-config.php (which contains your database password, so other users on the system don’t need to read it).
sudo chown -R www-data:www-data /var/www/html
sudo find /var/www/html -type d -exec chmod 755 {} \;
sudo find /var/www/html -type f -exec chmod 644 {} \;
You’ll tighten wp-config.php to 640 in a later step, after it’s been created.
Configure Nginx for WordPress
WordPress’s pretty permalinks (/2026/05/post-title/ rather than /?p=123) require a single nginx directive: try_files $uri $uri/ /index.php?$args;. This tells nginx to serve a static file if one exists at the requested path, fall back to a directory index, and otherwise pass the request to WordPress’s index.php for routing. Unlike Apache, there’s no .htaccess involved; the rule lives directly in the server block.
Create a server block for your WordPress site. Replace devanswers.test with your real domain:
sudo nano /etc/nginx/sites-available/wordpress
Paste the following:
server {
listen 80;
listen [::]:80;
server_name devanswers.test www.devanswers.test;
root /var/www/html;
index index.php index.html;
access_log /var/log/nginx/wordpress-access.log;
error_log /var/log/nginx/wordpress-error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
# Allow ACME (Let's Encrypt) HTTP-01 challenges even though dotfiles are denied below
location ^~ /.well-known/acme-challenge/ {
allow all;
root /var/www/html;
}
# Block PHP execution inside the uploads directory (defense against malicious uploads)
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
# Deny access to dotfiles, including .htaccess, .git, etc.
location ~ /\. {
deny all;
}
# Deny direct PHP access to wp-config.php
location = /wp-config.php {
deny all;
}
# Block xmlrpc.php (brute-force and pingback amplification target)
location = /xmlrpc.php {
deny all;
}
}
Save and exit (press CTRL + X, press Y, then press ENTER)
The try_files line is the load-bearing one for pretty permalinks. The location ~ \.php$ block sends .php requests to PHP-FPM over its Unix socket; the fastcgi-php.conf snippet (shipped with the nginx package) sets SCRIPT_FILENAME and the other FastCGI variables WordPress expects. One caveat on the xmlrpc.php deny at the bottom: it shuts down a heavily brute-forced endpoint, but Jetpack and the official WordPress mobile apps both use it, so drop that block if you rely on either.
Disable the default site (which still owns /var/www/html) and enable yours:
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/wordpress /etc/nginx/sites-enabled/wordpress
sudo nginx -t
nginx -t should report syntax is ok and test is successful. If you see “server_names_hash_bucket_size”, add server_names_hash_bucket_size 64; at the top of /etc/nginx/nginx.conf in the http block. Otherwise reload:
sudo systemctl reload nginx
Set Up wp-config.php
WordPress ships with wp-config-sample.php; you copy it to wp-config.php and fill in the database credentials plus a set of cryptographic salt keys.
sudo -u www-data cp /var/www/html/wp-config-sample.php /var/www/html/wp-config.php
WordPress provides a free API that generates fresh salt keys on every request. Fetch them directly:
curl -s https://api.wordpress.org/secret-key/1.1/salt/
You’ll get eight define() statements with random keys. Copy that block, then open wp-config.php:
sudo nano /var/www/html/wp-config.php
Make three edits:
- Database connection: set
DB_NAMEtowordpress,DB_USERtowpuser, andDB_PASSWORDto the password you used when creating the database. LeaveDB_HOSTaslocalhost. - Salt keys: find the block of eight
define('AUTH_KEY', ...)lines near the middle of the file (they say'put your unique phrase here') and replace the entire block with the output from the salt API. - Table prefix (optional but recommended): change
$table_prefix = 'wp_';to something less guessable like$table_prefix = 'da_';. This is a small obscurity measure that only deflects low-effort bots hardcoded to the default prefix; it does not defend against real SQL injection, since an injection able to reach the database can read the prefix frominformation_schema. Treat it as tidy-up, not a security control.
Save and exit (Ctrl+X, then Y, then Enter). Now tighten the permissions on this file, which holds your database password:
sudo chmod 640 /var/www/html/wp-config.php
Enable HTTPS Before You Install
Run Certbot before the WordPress installer, not after. WordPress records the site URL you install under, so obtaining the certificate first means it records https:// and you avoid chasing mixed-content warnings later.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx --redirect -d devanswers.test -d www.devanswers.test
Certbot edits the server block you created above and adds the port 443 listener. The --redirect flag sends all HTTP traffic to HTTPS without waiting on a prompt. Both names must resolve to this server, or the certificate request fails.
Renewal runs from a systemd timer installed with the package. Confirm it is armed:
systemctl is-active certbot.timer
active
Our full SSL guide for Debian 12 (Bookworm) covers DNS validation, wildcard certificates, and renewal troubleshooting.
Run the WordPress Installer
Everything is in place. Time to let WordPress finish the setup. In your browser, navigate to your domain. WordPress detects that wp-config.php exists but the database is unpopulated, and presents the famous “five-minute install” form.
- Pick a language.
- Set a Site Title (changeable later).
- Choose a Username for the admin account. Avoid the default
admin, which is the first thing brute-force bots try. - Use the strong password WordPress generates (or paste your own), and store it in a password manager immediately.
- Enter an admin email address. WordPress sends update notifications and password-reset emails here.
- Untick “Discourage search engines from indexing this site” when you’re ready to go live (leave it ticked while still testing; you can flip it later under Settings → Reading).
Click Install WordPress. Within a few seconds you’ll land on a “Success!” screen with a Log In button.
Set Permalinks
By default WordPress uses ugly query-string URLs (/?p=123). Switch to a human-readable structure:
- Log into the dashboard and go to Settings → Permalinks.
- Select Post name (recommended for blogs and most sites) or Day and name (better for news-style date relevance).
- Click Save Changes.
Unlike Apache, nginx doesn’t need a separate rewrite file. The try_files rule in your server block already routes every non-matching URL through /index.php, where WordPress’s internal router handles it. The Permalinks settings page only writes a database setting; no .htaccess is generated.
Test by visiting any post URL with the new structure (e.g. devanswers.test/hello-world/). If you see a 404 with nginx’s white-on-grey error page, your try_files directive is wrong or your server block isn’t enabled. Check sudo nginx -t and tail the error log: sudo tail -f /var/log/nginx/wordpress-error.log.
Security Hardening
Three low-cost constants in wp-config.php close common attack vectors without changing how WordPress feels to use.
sudo nano /var/www/html/wp-config.php
Add these lines just above the comment that reads /* That's all, stop editing! Happy publishing. */:
// Disable the in-dashboard theme/plugin file editor.
// Anyone who gains admin access can otherwise drop PHP into the editor and execute it.
define( 'DISALLOW_FILE_EDIT', true );
// Force all admin and login pages over HTTPS.
// Safe to switch on here: you obtained the certificate earlier in this guide.
define( 'FORCE_SSL_ADMIN', true );
// Skip WordPress's slower FTP-credentials prompt for plugin updates.
// Works because www-data already owns the files.
define( 'FS_METHOD', 'direct' );
Save and exit (press CTRL + X, press Y, then press ENTER)
FORCE_SSL_ADMIN will lock you out of the dashboard with a redirect loop. Next Steps
Your WordPress site is live over HTTPS. Two things to do next:
- Confirm automatic certificate renewal: Certbot renews from a systemd timer, and our Secure Nginx with Let’s Encrypt SSL on Debian 12 (Bookworm) guide covers renewal troubleshooting, DNS validation, and wildcard certificates.
- Set up automated backups: at minimum, a daily database dump (
mysqldump wordpress) plus a weekly archive of/var/www/html. Plugins like UpdraftPlus handle this through the dashboard if you’d rather not write cron jobs.
Conclusion
Your Debian 12 server now hosts a fresh WordPress installation served by Nginx and PHP-FPM, with a dedicated database user, hardened file permissions, working pretty permalinks via try_files, and three security constants protecting the admin area.
With HTTPS already in place, the next thing to sort is backups: at minimum a daily database dump and a weekly archive of the web root. WordPress is robust, but databases corrupt for unrelated reasons (failed disks, runaway plugins) and you will only miss a backup the one time you needed it. Beyond that, a stock install leaves most of the server’s performance unused: speeding up WordPress on a VPS walks through the measured Nginx recipe: FastCGI page caching, a Redis object cache, OPcache, and sizing your PHP-FPM workers.
Comments