Skip to content

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.is record (at Dynu) pointing at the server, so any new *.franzvoid.is subdomain resolves automatically — no A record to add. For a different domain, create an A record first and confirm it resolves: dig +short A <subdomain>.
  • sudo access.

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-FPMmod_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:

php -v
sudo systemctl is-active mariadb php8.2-fpm

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:

sudo setfacl -R -m u:franz:rwX,d:u:franz:rwX /var/www/<subdomain>

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:

sudo a2ensite <subdomain>.conf
sudo apache2ctl configtest
sudo systemctl reload apache2

Sanity-check over HTTP before TLS — this should redirect to /wp-admin/install.php:

curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://<subdomain>/

8. Enable HTTPS with Certbot

Certbot writes <subdomain>-le-ssl.conf (SSL vhost) and adds the HTTP→HTTPS redirect:

sudo certbot --apache -d <subdomain> --non-interactive --agree-tos -m <email> --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 under wp-content/uploads; consider copying them from ~/.claude/skills/new-wp/assets/vhost.conf.tmpl if you build a site by hand.