New WordPress Site from the Template¶
Clone a ready-made WordPress site instead of building one from scratch. One command produces a live, TLS-secured, hardened site with an empty theme ready to build on.
That creates <name>.franzvoid.is: docroot, database, files and content
cloned from the template, a hardened Apache vhost, a Let's Encrypt certificate,
and an active empty theme called <name>.
For building a site from nothing, see Set up a WordPress site — that page is still the reference for how each piece works.
Why this exists
thingvad.franzvoid.is was left sitting at the browser install wizard in
July 2026. On 1 August a stranger completed the wizard, made themselves an
admin, and ran a Monero miner on the box for eight days. Every step below is
arranged so a WordPress docroot is never reachable over HTTP until it is
fully installed. Full write-up: incident report INC-20260801-THINGVAD;
evidence archive at /root/incident-thingvad-20260801.tar.gz.
The template site¶
https://template.franzvoid.is/ — a normal WordPress site. Log in with the
credentials in /var/www/.env and change whatever you like.
| Docroot | /var/www/template.franzvoid.is |
| Database | template_wp |
| Admin | DEFAULT_WP_USER / DEFAULT_WP_PASS from /var/www/.env (root-only, mode 600) |
| Seeded | Icelandic timezone, /%postname%/ permalinks, comments closed, no third-party plugins, three hardening mu-plugins |
| Indexing | Discouraged, and the vhost sends X-Robots-Tag: noindex |
What propagates and what doesn't¶
Everything in the template's files and database is copied into every site created afterwards — plugins, settings, pages, media, users, menus.
Design does not propagate. /new-wp activates the empty <name> theme, and
block-theme templates plus Global Styles are stored per-theme, so they are
orphaned on activation. Treat the template as content and configuration; design
starts fresh in each site's own theme.
Keep it lean
Every upload and post in the template is copied into every future site, forever. Audit it before a batch of new sites.
Naming¶
<name> must be 2–26 characters, lowercase a-z0-9-, starting with a letter.
Underscores are rejected on purpose.
| You type | Domain | Database + user | Theme |
|---|---|---|---|
haukar |
haukar.franzvoid.is |
haukar_wp |
haukar |
my-site |
my-site.franzvoid.is |
my_site_wp |
my-site |
Hyphens are kept in the domain and theme slug and converted to underscores only
for the database. Underscores are forbidden in the name itself because otherwise
my-site and my_site would silently collide on the same database.
Reserved names (www, mail, api, template, every existing site, and
thingvad) are refused.
What is cloned and what is regenerated¶
| Cloned from the template | Regenerated per site |
|---|---|
| WordPress core, at the template's exact version | Database name, user and password |
| Plugins and mu-plugins | Authentication salts (wp config shuffle-salts) |
| All content, media, options, users | siteurl / home / site title |
.htaccess and permalink structure |
Apache vhost and Let's Encrypt certificate |
| Admin password (hashes are self-salted, so it carries over) | The <name> theme, activated |
Fresh salts matter: reusing the template's would mean every site shares auth-cookie signing keys, and a session cookie from one site could validate on another.
Security posture¶
Every site the skill creates gets these, and so does the template.
The installer is never exposed. WordPress is installed entirely over WP-CLI
while no vhost exists. The script refuses to run a2ensite unless
wp core is-installed succeeds, and after provisioning it asserts from the
outside that /wp-admin/install.php does not return 200. A 200 there means the
site is claimable by anyone and the provision has failed.
vhost deny rules (written before certbot runs, so they propagate into the
-le-ssl.conf it generates):
| Blocked | Why |
|---|---|
.git/ and any .git* file |
A scanner cloned https://franzvoid.is/.git/ on 2026-08-01 |
install.php |
The thingvad failure mode |
.php/.phar/.sh/etc. under wp-content/uploads, including double extensions like evil.php.jpg |
The thingvad dropper was placed under wp-content and run with a direct GET |
readme.html, license.txt, wp-config-sample.php, dotfiles |
Version fingerprinting |
mu-plugins in wp-content/mu-plugins/:
disable-user-enumeration.php— removes thewp-jsonuser endpoints and the?author=Nredirectdisable-xmlrpc.php— turns offxmlrpc.phpand stops advertising itharden-uploads.php— rejects executable uploads at the application layer too, in case the docroot is ever moved to a vhost without the deny block
The firewall is never touched. ufw is default-deny inbound with only
22/80/443 open. The scripts check that it is active and refuse to run if it
isn't, but contain no ufw or iptables commands at all.
Verifying a new site¶
F=<name>.franzvoid.is; D=/var/www/$F
T() { sudo -u www-data wp --path="$D" "$@"; }
curl -s -o /dev/null -w "install %{http_code}\n" "https://$F/wp-admin/install.php" # 403 — never 200
curl -s -o /dev/null -w "git %{http_code}\n" "https://$F/.git/config" # 403
curl -s -o /dev/null -w "http %{http_code}\n" "http://$F/" # 301
curl -s -o /dev/null -w "https %{http_code}\n" "https://$F/" # 200
curl -s -o /dev/null -w "users %{http_code}\n" "https://$F/wp-json/wp/v2/users" # 404
T option get siteurl # https://<name>.franzvoid.is
T theme list # <name> active
T db query "SELECT COUNT(*) FROM wp_options WHERE option_value LIKE '%template.franzvoid.is%';" # 0
sudo apache2ctl configtest # Syntax OK
getfacl -p "$D" | grep -E '^(user:franz|default:user:franz)'
curl and SNI
When testing a vhost from the server itself, use
curl --resolve host:443:127.0.0.1 https://host/. Using -H "Host: ..."
against localhost returns 421 Misdirected Request, because the TLS SNI
name doesn't match.
Going live in search¶
New sites inherit the template's "discourage search engines" setting. WordPress 7
implements that with a <meta name="robots" content="noindex, nofollow"> tag —
not with Disallow: / in robots.txt, so don't look for it there.
When the site is ready:
If something goes wrong¶
The script refuses rather than clobbers. If the docroot, vhost, database or
database user already exists, it exits with code 2 and creates nothing. There
is no --force: to rebuild a site, tear it down first.
On a failure partway through it rolls back automatically, unwinding in reverse: disable and remove the vhost, reload Apache, drop the database and user, delete the docroot. Certificates are deliberately left behind — an orphan cert is harmless, and Let's Encrypt allows only 5 duplicate certificates per week, which a rollback-retry loop would burn through.
Use --keep-on-failure to leave the wreckage for inspection.
Warning
Don't background a /new-wp run and then quit Claude. claude-sudo removes
its sudoers rule on exit, and the rollback needs sudo too.
Removing a site¶
Drops the database and rm -rfs the docroot. It prints exactly what it will
remove before doing anything, and hard-refuses on the template and on every
pre-existing site, so it can only delete something /new-wp created.
Files¶
| Path | |
|---|---|
~/.claude/skills/new-wp/SKILL.md |
The /new-wp skill |
~/.claude/skills/new-wp/scripts/new-wp.sh |
Provisioning engine — runs standalone, no Claude needed |
~/.claude/skills/new-wp/scripts/teardown-wp.sh |
Guarded undo |
~/.claude/skills/new-wp/assets/vhost.conf.tmpl |
The hardened vhost, __FQDN__ placeholders |
~/.claude/skills/new-wp/assets/mu-plugins/ |
The three hardening mu-plugins |
~/.claude/skills/new-wp/assets/theme/ |
Starter theme sources |
/var/www/.wp-cli/config.yml |
Tells WP-CLI mod_rewrite exists, so wp rewrite --hard can write .htaccess |
new-wp.sh is a plain bash script — you can run it from a normal shell without
Claude:
~/.claude/skills/new-wp/scripts/new-wp.sh --dry-run <name> # preflight only
~/.claude/skills/new-wp/scripts/new-wp.sh --staging <name> # test cert, saves quota
~/.claude/skills/new-wp/scripts/new-wp.sh <name>
The starter theme¶
wp-content/themes/<name>/ — six files, deliberately unstyled:
style.css (theme header plus a minimal reset and a centered wrapper),
header.php, footer.php, index.php, functions.php, and a generated
screenshot.png with the site name on it.
It renders a valid HTML document with wp_head() and wp_footer() firing and
its own stylesheet enqueued — so it works out of the box and you extend from
there. functions.php registers a primary menu location and the usual theme
supports.