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

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. 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.