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. That is exactly how
thingvad.franzvoid.is was taken over on 2026-08-01 and mined for eight
days. 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.