Setting up a WordPress Subdomain¶
This guide sets up a fresh WordPress site on its own subdomain (example: blek.franzvoid.is → /var/www/blek.franzvoid.is/), served by Apache with PHP-FPM and MariaDB, secured with Let's Encrypt. It leaves WordPress at its browser install wizard so you can create the admin account yourself.
For the general subdomain/vhost mechanics, see Setup Subdomains. This page is the WordPress-specific end-to-end.
Faster path
To clone a ready-made site instead of building from scratch, see New site
from the template. The /new-wp <name> skill does
everything on this page in one command, finishes the install over WP-CLI
rather than in the browser, and writes a hardened vhost.
0. Prerequisites¶
- Apache already installed and running (see Setup Apache).
- DNS: franzvoid.is has a wildcard
*.franzvoid.isrecord (at Dynu) pointing at the server, so any new*.franzvoid.issubdomain resolves automatically — no A record to add. For a different domain, create an A record first and confirm it resolves:dig +short A <subdomain>. sudoaccess.
Throughout, replace <subdomain> with your site (e.g. blek.franzvoid.is) and <docroot> with /var/www/<subdomain>.
1. Install PHP-FPM, extensions, and MariaDB¶
This server runs the Apache event MPM, so PHP must run through PHP-FPM — mod_php is not an option.
sudo apt update
sudo apt install -y \
php8.2-fpm php8.2-mysql php8.2-gd php8.2-curl php8.2-xml \
php8.2-mbstring php8.2-zip php8.2-intl php8.2-bcmath php8.2-imagick \
mariadb-server
Confirm both services are up:
2. Wire PHP-FPM into Apache¶
sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.2-fpm # routes *.php to /run/php/php8.2-fpm.sock for all vhosts
sudo systemctl reload apache2
3. Create the database¶
MariaDB root uses unix_socket auth, so sudo mariadb logs in without a password. Pick a strong password (openssl rand -base64 24 is handy) and substitute it below:
sudo mariadb <<'SQL'
CREATE DATABASE blek_wp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'blek_wp'@'localhost' IDENTIFIED BY 'CHANGE-ME-STRONG-PASSWORD';
GRANT ALL PRIVILEGES ON blek_wp.* TO 'blek_wp'@'localhost';
FLUSH PRIVILEGES;
SQL
Keep the DB name, user, and password — they go into wp-config.php next.
4. Download WordPress¶
cd /tmp
curl -fsSL -o wp.tar.gz https://downloads.wordpress.org/release/wordpress-7.0.1.tar.gz
tar xzf wp.tar.gz
grep "wp_version =" wordpress/wp-includes/version.php # sanity-check the version
sudo mkdir -p /var/www/<subdomain>
sudo cp -a wordpress/. /var/www/<subdomain>/
For the latest version instead of a pinned one, use
https://wordpress.org/latest.tar.gz.
5. Create wp-config.php¶
Generate fresh security salts and write the config. Run this from a working dir, then copy the result into place:
DBPASS='CHANGE-ME-STRONG-PASSWORD'
SALTS="$(curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/)"
cat > /tmp/wp-config.php <<EOF
<?php
// ** Database settings ** //
define( 'DB_NAME', 'blek_wp' );
define( 'DB_USER', 'blek_wp' );
define( 'DB_PASSWORD', '${DBPASS}' );
define( 'DB_HOST', 'localhost' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
// ** Authentication unique keys and salts ** //
${SALTS}
\$table_prefix = 'wp_';
define( 'WP_DEBUG', false );
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
require_once ABSPATH . 'wp-settings.php';
EOF
php -l /tmp/wp-config.php # syntax check
sudo cp /tmp/wp-config.php /var/www/<subdomain>/wp-config.php
6. Set ownership and permissions¶
Apache and PHP-FPM run as www-data, so the whole tree must be owned by it:
sudo chown -R www-data:www-data /var/www/<subdomain>
sudo find /var/www/<subdomain> -type d -exec chmod 755 {} \;
sudo find /var/www/<subdomain> -type f -exec chmod 644 {} \;
sudo chmod 640 /var/www/<subdomain>/wp-config.php # keep DB creds tighter
Then grant franz write access via ACL, so the site is editable without changing
ownership away from www-data:
The d: (default) entries are what matter: PHP-FPM runs with umask 022, so files
WordPress creates later would otherwise come back 644 www-data:www-data and lock
franz out again. Do not substitute chown franz:www-data + chmod g+w — it does
not survive new files for that reason.
7. Create the Apache virtual host¶
Create only the :80 vhost — certbot generates the HTTPS vhost and redirect in the next step. AllowOverride All lets WordPress's .htaccess (permalinks) work.
/etc/apache2/sites-available/<subdomain>.conf:
<VirtualHost *:80>
ServerName <subdomain>
DocumentRoot /var/www/<subdomain>
DirectoryIndex index.php index.html
<Directory /var/www/<subdomain>>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/<subdomain>_error.log
CustomLog ${APACHE_LOG_DIR}/<subdomain>_access.log combined
</VirtualHost>
Enable it and reload:
Sanity-check over HTTP before TLS — this should redirect to /wp-admin/install.php:
8. Enable HTTPS with Certbot¶
Certbot writes <subdomain>-le-ssl.conf (SSL vhost) and adds the HTTP→HTTPS redirect:
Verify:
curl -s -o /dev/null -w "%{http_code}\n" http://<subdomain>/ # expect 301
curl -s -L https://<subdomain>/ | grep -o '<title>[^<]*</title>' # expect "WordPress › Installation"
9. Finish the install — over the CLI, not the browser¶
Never leave the wizard open
Between step 7 (vhost enabled) and the moment the install completes,
/wp-admin/install.php answers to the entire internet and anyone who
reaches it first owns the site. Do not enable the vhost and
"come back to it later".
Finish the install immediately, in one command, using WP-CLI:
sudo -u www-data wp --path=/var/www/<subdomain> core install \
--url="https://<subdomain>" \
--title="<Site Title>" \
--admin_user="<user>" \
--admin_password="<password>" \
--admin_email="<email>" \
--skip-email
sudo -u www-data wp --path=/var/www/<subdomain> core is-installed && echo installed
Better still, do steps 3–8 in the safe order: install WordPress over the CLI
before running a2ensite, so the docroot is never served while claimable.
That is what /new-wp does.
Then confirm the installer is shut:
curl -s -o /dev/null -w "%{http_code}\n" https://<subdomain>/wp-admin/install.php # 302 or 403, never 200
If you must use the browser wizard, open https://<subdomain>/ and complete
it now — WordPress skips the database step since wp-config.php already has the
settings.
Notes¶
- Harden the REST API user endpoints afterwards: see Disable wp-json user enumeration.
- The Let's Encrypt cert auto-renews via certbot's scheduled task; nothing else to do.
- To spin up a new site as a clone of
template.franzvoid.is— files, database, hardened vhost, certificate and an empty theme in one command — see New site from the template. - The vhost in step 7 is the bare minimum. The template-based flow adds deny rules for
.git,install.php, and PHP execution underwp-content/uploads; consider copying them from~/.claude/skills/new-wp/assets/vhost.conf.tmplif you build a site by hand.