WordPress is a state-of-the-art publishing platform with a focus on aesthetics, web standards, and usability. It is one of the worlds most popular blog publishing applications, includes tons of powerful core functionality, extendable via literally thousands of plugins, and supports full theming.
This appliance includes all the standard features in TurnKey Core, and on top of that:
WordPress configurations:
- Installed from upstream source code to /var/www/wordpress
- Integrated upgrade mechanism: get WordPress updates straight from WordPress' creator Automattic.
- Uploading of media such as images, videos, etc.
- Permalinks configuration supported through admin console (convenience)
- Plugin and theme management remains available through WordPress. These directories intentionally contain web-writable executable code; install only updates and extensions you trust.
- WordPress core is root-owned and does not update automatically. Apply a
supervised core update as
rootwithkeel-wordpress-update. The command verifies official WordPress core checksums and restores the appliance ownership boundary after updating.
Landing page provides links to WordPress plugin search, plus a number of useful and popular Wordpress plugins (none pre-installed):
- Yost SEO: Optimizes your WordPress blog for search engines and XML sitemaps.
- NextGEN Gallery: Easy to use image gallery with thumbnail & slideshow options.
- JetPack by WordPress.com: Jetpack adds powerful features previously only available to WordPress.com users including customization, traffic, mobile, content, and performance tools.
- WP Super Cache: Accelerates your blog by serving 99% of your visitors via static HTML files.
- Social Media Share Buttons & Icons: Promote your content by adding links to social sharing and bookmarking sites.
- Simple Tags: automatically adds tags and related posts to your content.
- BackupWordPress: easily backup your core WordPress tables.
- Google Analytics Dashboard for WordPress: track visitors, AdSense clicks, outgoing links, and search queries.
- WP-Polls: Adds an easily customizable AJAX poll system to your blog.
- WP-PageNavi: Adds more advanced paging navigation.
- Ozh admin dropdown menu: Creates a drop down menu with all admin links.
- Contact Form 7: Customizable contact forms supporting AJAX, CAPTCHA and Akismet integration.
- Seriously Simple Podcasting: Simple Podcasting from your WordPress site.
SSL support out of the box.
Postfix MTA (bound to localhost) to allow sending of email (e.g., password recovery).
Webmin modules for configuring PHP, MySQL (MariaDB) and Postfix.
See the WordPress docs for further details (including multisite howto).
This appliance is built as a Keel layer, not as a whole root filesystem:
bt-layer wordpress --parent lnmp
The parent is the ``lnmp`` layer (handbook decision 0059: nginx is the only web server in Keel, and Apache is retired). The chain is Core, Web, LNMP and WordPress. Web brings nginx; LNMP brings PHP-FPM 8.4 and MariaDB 11.8 and the first boot hooks of the database. What this recipe adds is the PHP modules that WordPress wants, WordPress and wp-cli, its route, its first boot hook and its manifest, and nothing else.
LNMP also brings a stack check: the first boot hook 40lnmp-check, which
makes a test database keel_check, and the page /keel-check/ that reads
it. A WordPress product ships no test database and no page that shows the
stack, so conf.d/main removes the hook, the route
50-keel-lnmp.conf and the page. The build records each removal in the
layer, and keel assemble removes the path from the image. The build check
of the image and the boot test prove that none is left; /keel-check/ is a
URL of WordPress here and answers its 404.
The site is /etc/nginx/keel-default.d/60-keel-wordpress.conf, in the include
directory of Keel Web, so it answers in the simple mode and behind Anubis. The
default site of Keel Web already has a location /, so the site is made of
regular expressions, which nginx tries after the prefixes and in file order,
and the last one is the catch-all (try_files with a fallback to
index.php, which is what pretty permalinks need). It refuses
wp-config.php, PHP in uploads and the other directories the web user
writes, the PHP files of wp-includes, hidden files except .well-known,
and log and dump files. xmlrpc.php is reachable, as it was under Apache.
The appliance has a manifest, wordpress.yaml (base lnmp), so an
instance description names it with appliance.name: wordpress. The database
account is lnmp's option db_user: set it to wordpress.
Behind Anubis and Coraza (the cloud modes) two files of this layer make the
site work. /etc/nginx/keel-front.d/60-keel-wordpress.conf sets the body
limit of the front site of Keel Web to 64 MB, as PHP has it (the front site
has 1 MB without it, and answers 413 to a media upload). It needs keel-web
0.3.2. The files of an upload are no argument for Coraza (keel-overlay-coraza
0.1.4), so a worker keeps its memory: an upload of 32 MiB costs about 10 MiB.
And /etc/coreruleset/plugins/keel-wordpress-before.conf keeps the Core
Rule Set from reading the content fields of a JSON write to /wp-json/,
because the rules take the HTML of the blocks of the editor as an attack.
A replica gets wp-config.php and nothing in the database. The WordPress
schema is made by the first boot of a primary or a standalone node and not by
the image, because keel replaces a replica's databases with a copy of the
primary and refuses to do it without --destroy-local-database when the
server holds a schema that is not its own.
Upstream's conf.d/main installs WordPress while the image is being built,
with DB_PASS=turnkey and ADMIN_PASS=turnkey written in the recipe. A
layer is published once and reused by every machine built on it, so those are
the same two passwords on every one of them. This recipe leaves the layer with
no credential at all: no wp-config.php, and a database account created with
a password hash that no input produces.
The installation happens on the first boot, from
/etc/keel/instance.yaml, and the WordPress installer is never offered to a
visitor. firstboot.d/40wordpress reads:
Every secret is a file reference in the instance description, never a literal.
Neither password is ever an argument of another process: wp-config.php is
written by a shell function, and the administrator's password is set through
the environment of one wp-cli call, and the statements that give the
database account its password reach mysql on its standard input. The hook
finishes by asking the site to authenticate that account, and to refuse a
wrong password, before it lets the site serve anything.
The hook owns its database account. Under keel-init the inithooks conf is
empty, so the parent layer's 35mysqlpass sets its own account admin,
and 40wordpress sets wordpress with the password that it asks.
A headless first boot that does not declare the two passwords asks nothing:
nobody can answer tty1 of a container (inithooks' lib/console.sh). The
hook records that it is deferred and finishes, the site answers 503, and the
first login starts keel-init, which asks the passwords and completes the
installation.
wp-config.php derives WP_HOME and WP_SITEURL from the Host
header of each request rather than reading them from the database, so the site
answers on whatever address or name reaches it. A WordPress whose recorded site
URL is not the one it is reached by answers a permanent redirect to the
recorded one, which for an appliance reached by its IPv6 literal means sending
the visitor to a name that does not resolve. The recorded URL is the fallback
for WP-CLI and wp-cron, which carry no Host header.
wp-config.php holds no password and no salt. The site logs in to MariaDB
over the socket as www-data (unix_socket), and wp-cli as root logs in as
root over the same socket. The salts are options of the database: the first
boot stores them (lib/wordpress-set-salts.php), and wp_salt() reads them
when wp-config.php defines none. On a cloud pair keel seeds the database to
the replica and replicates it, so both nodes have one account and one set of
salts, and a login stays a login after a failover. No salt is fetched from
WordPress's API, because a first boot must not need the internet.
The appliance ships /etc/apt/sources.list.d/keel.sources enabled for
the signed trixie distribution of https://archive.keellinux.org, with
Signed-By naming /usr/share/keyrings/keel-archive-keyring.gpg from the
keel-archive-keyring package, and /etc/apt/preferences.d/keel pins that
origin at 1001. So on a booted appliance:
apt-get update # reads our archive and verifies its signature apt-get upgrade # takes newer Debian and newer project packages apt-get install keel-transition # a project package, from our archive
covers two things:
- the system, from Debian and Debian security, as any Debian machine does;
- the project's own packages:
inithooks,confconsole,keel,keel-archive-keyring,keel-transition,fab.
The boot test proves that path rather than proving that a newer version
happens to exist on the day it runs. It asserts that apt-get update
verifies our archive's signature; that apt-cache policy shows a project
package the image carries with our archive as the source of its candidate at
that pin priority; that keel-transition, which the image does not carry,
comes down from our archive against the digest the signed index holds; and
that a project package the image does carry is downloaded again and put
through dpkg. apt refuses an archive it cannot verify before it asks for a
single byte, so those last two are the signature reaching real files and not
only an index.
All of it stays inside our own archive on purpose. Installing
keel-transition on a real appliance also pulls gpgv from Debian, which
a Debian 13 image does not carry because apt verifies with sqv; that is
fine on a machine with a route to Debian, and the CI runner has no IPv4 route
out, so a test step that needed it would be measuring the runner's network
instead of this appliance.
It does not cover WordPress core, and saying otherwise would be a lie in
the documentation. Nobody packages WordPress for Debian and we do not package
it either: it is unpacked from a release archive pinned by digest in
conf.d/main and verified a second time against WordPress's own per file
checksums. WordPress core therefore updates through WordPress's own mechanism,
either from the dashboard or, preferably on this appliance, with:
keel-wordpress-update
which runs wp core update, verifies WordPress's checksums again and puts
the ownership boundary back: core and wp-config.php root owned, and only
wp-content/uploads, cache, upgrade, plugins and themes
writable by the web server. Automatic core updates are off in
wp-config.php for the same reason: a supervised update keeps that
boundary, an unsupervised one does not.
One consequence of the pin worth knowing, because it is a priority that
downgrades as well as upgrades: an image must not carry a project package that
the signed archive has not got. If it does, apt-get upgrade replaces the
newer installed package with the archive's older one. The boot test's policy
check is where that shows up.
Plugins and themes are WordPress's business and are installed through WordPress. Those two directories are deliberately web writable, which means they hold executable code the web server can change: install only what you trust.
80 and 443 are the site, on both address families, and 12321 is Webmin from
Core. 3306 is closed to the network: this appliance reaches its own
database over the loopback, and wp-config.php names [::1]. On a pair
the manifest of lnmp makes 3306 a mesh port, open on the WireGuard interface
only. /etc/cron.d/wordpress-cron runs the due events with wp cron event
run as the web user, not through a request to wp-cron.php. Port 80 serves
the site as 443 does while the certificate is the self-signed one of the first
boot, and redirects to HTTPS when a certificate authority's is in place
(keel-web 0.4.0, handbook decision 0079). The loopback addresses of port 80
always serve the site, so http://127.0.0.1/ reaches WordPress on the
machine; /keel-health there is Keel Web's probe.
Adminer and the rest of turnkey/lamp are left out. The database is
administered through webmin-mysql in the panel Core already carries. The
parent layer's own admin database account is left unable to authenticate
unless an instance declares it, which is the state that layer publishes it in.
keel/instance.example.yaml is the description to start from, and
tests/README.md is how to boot and check one by hand. The appliance's own
view of itself, on the machine:
keel diff --spec /etc/keel/instance.yaml
Every password comes from the instance description; none is set here and none is typed at a console on a headless first boot.
- Webmin and SSH: username root, from
secrets.root_password - the MariaDB account of lnmp (
app.options.db_user), fromsecrets.db_password; the site logs in as www-data over the socket, with no password - WordPress: username admin (
app.options.admin_user), fromsecrets.app_password
Adminer is not installed, so it has no account here.