wordpress¶
i run one wordpress multisite on the swarm. one install serves several sites on
subdomains of mydomain.com and a site on another domain, mydomain1.com. the
stack is wordpress2025, with three services: wordpress, its db, and
db-dump, which keeps an hourly dump of the database for the backup.
compose.yml, 124 lines, 2 notes
each in the code opens a note on that line. download compose.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 | |
- binary logs for two days instead of mysql 8's thirty. they are only for replaying changes after a restore, see the hourly dump.
- the hourly dump: the db service's image, running
db-dump.shinstead of mysqld. it dumpswordpressdbat start and at :50, see the hourly dump.
before you deploy¶
-
create the three folders on the cephfs mount:
sudo mkdir -p /mnt/docker-cephFS/wordpress_html /mnt/docker-cephFS/wordpress_db sudo mkdir -m 700 /mnt/docker-cephFS/wordpress_dumps- each volume binds its folder by path, so a missing folder fails the task
wordpress_dumpsholds a full copy of the database, password hashes included, so only root can read it
-
create two docker secrets:
wordpress_db_password_v2for the database user's password, andwordpress_mysql_root_passwordfor mysql's root password- mysql reads both only when it first starts on an empty
wordpress_dbfolder, to create the database. wordpress readswordpress_db_password_v2on every connection, so that one has to match the database user's password in mysql
- mysql reads both only when it first starts on an empty
state considerations¶
htmlis wordpress's/var/www/htmlanddbis mysql's/var/lib/mysql. both are named binds to/mnt/docker-cephFS/wordpress_htmlandwordpress_dbon the replicated storage, see stack conventions.db-dumpwrites to a third,wordpress_dumps, see the hourly dump- mysql is pinned by digest, and wordpress only by its version tag. renovate asks before bumping either, because either can run a schema migration on start
- both official images read passwords from files, so
WORDPRESS_DB_PASSWORD_FILEand theMYSQL_*_FILEvariables point at docker secrets, and no password is in the service spec
network considerations¶
the stack publishes 8180 for wordpress's port 80, and 8443 for its 443,
through the ingress mesh. its three services share the stack's default network,
where wordpress and db-dump reach the database as db.
traefik terminates TLS for every site name and proxies plain
http to port 8180 on the swarm's VIP, 192.168.1.45:
| name | inside the lan | outside |
|---|---|---|
www., blog. |
served | served, as public sites |
mydomain.com, the root site |
served, but the name is active directory's, see below | every path redirects to www., keeping the path |
mydomain1.com, the site on another domain |
through cloudflare, as from outside: the lan has no DNS of its own for it | served, as a public site |
- the root site is never reachable from outside. it holds the network admin: wordpress sends every network-admin page to the root site's name
- on the lan that name belongs to active directory, so traefik's route for it is reached only by wordpress itself, see the container reaching itself, and by a browser that maps the name to the VIP, see the network admin
- the other domain is outside traefik's wildcard certificate, so its route asks for a certificate of its own, see traefik. the cloudflare token has to cover that domain's zone too
wordpress only sees http, so it has to be told the visitor used https. the
wp-config.php that the official image generates does that when the proxy
sends X-Forwarded-Proto, and traefik sends it:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
$_SERVER['HTTPS'] = 'on';
}
the network admin¶
the network admin is on the root site, and on the lan mydomain.com belongs
to active directory. i open it in a browser window that maps just that name
to the VIP. on a mac, with chrome or edge:
open -na "Google Chrome" --args --user-data-dir="$HOME/Library/Application Support/wp-network-admin" --host-resolver-rules="MAP mydomain.com 192.168.1.45"
open -na "Microsoft Edge" --args --user-data-dir="$HOME/Library/Application Support/wp-network-admin-edge" --host-resolver-rules="MAP mydomain.com 192.168.1.45"
on windows, with edge, in powershell:
& "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --user-data-dir="$env:LOCALAPPDATA\wp-network-admin" --host-resolver-rules="MAP mydomain.com 192.168.1.45"
then go to https://mydomain.com/wp-admin/network/.
- only that window, and only that one name, goes to traefik. the rest of the machine is unchanged, so active directory's use of the name keeps working. a hosts file entry on a domain-joined PC would break it
- the separate
--user-data-dirstarts a browser of its own. without it, the command hands the url to the browser already running, which ignores the switch - traefik serves the root site on its lan listener with the domain's own certificate, so the window shows no warning
- tested with chrome and edge on a mac
what makes multisite fiddly¶
- multisite picks the site from the
Hostheader, so every site name has to reach wordpress unchanged. public DNS needs a record per name pointing at my WAN address, and the proxy needs a route per name that passes the host header through - the root site is the bare
mydomain.com, and inside my lan that name belongs to active directory. it resolves to the windows server domain controllers, not to the proxy, so from inside the lan the root site's name doesn't reach wordpress. the container gets round it withextra_hosts, below
the multisite config¶
the image generates wp-config.php from environment variables and evaluates
WORDPRESS_CONFIG_EXTRA in it, so the multisite settings sit in git, not in a
file in the volume:
WORDPRESS_CONFIG_EXTRA: |
/* Multisite */
define('WP_ALLOW_MULTISITE', true );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', true );
define( 'DOMAIN_CURRENT_SITE', 'mydomain.com' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );
| define | does |
|---|---|
WP_ALLOW_MULTISITE |
adds Tools → Network Setup, where the network gets created |
MULTISITE |
says the network exists |
SUBDOMAIN_INSTALL |
sites are subdomains, blog.mydomain.com, not paths |
DOMAIN_CURRENT_SITE, PATH_CURRENT_SITE |
the main site's domain and path |
SITE_ID_CURRENT_SITE, BLOG_ID_CURRENT_SITE |
the network's and the main site's ids, 1 for the first |
to turn multisite on:
-
deploy with only
WP_ALLOW_MULTISITE -
in Settings → General, set the WordPress Address (URL) and the Site Address (URL) both to
https://mydomain.com- keep the two the same. if they differ, everything breaks once multisite is on
- the page breaks as soon as you save, until the proxy serves the name over https
-
make the proxy serve
mydomain.comover https (npm, when i did this; in traefik it's a route like any other), then log in again athttps://mydomain.com- do this in a browser that maps
mydomain.comto the proxy, see the network admin. inside the lan that name resolves to the domain controllers, see what makes multisite fiddly
- do this in a browser that maps
-
in Tools → Network Setup, choose sub-domains and install
-
add the other defines that network setup shows you to
WORDPRESS_CONFIG_EXTRA, and redeploy -
in the html volume, replace the rewrite rules in
.htaccesswith the multisite ones that network setup shows you
the only active change to the generated wp-config.php in the volume is
define( 'WP_CACHE', true );, which the WP Rocket plugin adds itself.
the container reaching itself¶
wordpress makes requests to its own url (cron, site health). inside the lan
that name resolves to the domain controllers, so the container pins it to the
VIP, where traefik serves the root site on its lan listener. outside, every
path of the root site redirects, so these requests must not go out through the
WAN: cron calls /wp-cron.php on its own name, and a redirect would stop it.
apache's header limit¶
apache refuses a request with any single header over 8 KB, and an admin's
browser can pass that. the oauth2-proxy sign-in cookie is
set on the whole domain, and it travels with wordpress's, wordfence's and the
shop's own cookies. apache then answers every page with a 400 before wordpress
runs. the stack mounts a config into apache's conf-enabled that raises the
limit to 32 KB:
header-limits.conf: apache's request-header limit, raised to 32 KB, 1 lines
the hourly dump¶
db-dump writes wordpressdb.sql to /mnt/docker-cephFS/wordpress_dumps when
it starts and at :50 every hour. ceph's snapshot at :00 catches it, and the
cephFS backup ships it at :15, so every backup
holds a dump known to be consistent next to mysql's own files.
- it runs the db service's image and digest, with
db-dump.shas its entrypoint instead of mysqld, somysqldumpmatches the server --single-transactionreads one consistent view of the InnoDB tables without locking them, so wordpress keeps serving while it runs. the dump is about 70 MB and takes a few seconds--source-data=2writes the binary log position the dump was taken at into the dump, as a comment. reading it takes a global read lock for a moment at the start- the dump goes to a temporary name and is renamed only once complete, so a snapshot never catches half of one
- it is uncompressed on purpose. PBS compresses and deduplicates, and a dump whose rows barely changed shares almost every chunk with the hour before
- it has no healthcheck. on the swarm a failing one gets the task restarted, which can't fix a missing database or cephFS, and the script already retries every five minutes. a stale dump is for gatus to report, which isn't set up yet
db-dump.sh: the dump sidecar's script, 37 lines, 2 notes
each in the code opens a note on that line. download db-dump.sh
- how the dump stays consistent while the app runs:
--single-transactionfor InnoDB, or--lock-tablesfor Aria and MyISAM, which have no consistent read view. - the rename happens only after
mysqldumpexits 0 and writes its closing line. a rename is atomic, so a snapshot sees the previous whole dump or the new one.
binary logs, and rewinding to a minute¶
mysql's binary log records every change in order. mysql 8 keeps it for thirty
days by default, which here was 6.1 GB. nothing replicates from this server, so
the log is only for replaying changes after a restore, and the database keeps
two days of it (--binlog-expire-logs-seconds=172800).
- with an hourly dump, two days is plenty: a restore needs the log only from the dump's position onward
- to rewind to just before a mistake, load the last dump from before it, then
replay the log from the position in the dump's header comment up to the
minute before, with
mysqlbinlog --start-positionand--stop-datetime. mysqldump 8.0.42 writes that comment asCHANGE MASTER TO; newer versions writeCHANGE REPLICATION SOURCE TO - older logs are still in older PBS backups of the
wordpress_dbfolder
restoring the whole site¶
the wordpress_html folder and the dump from one backup are the whole site.
all four sites share the folder, and the dump holds every site's tables.
-
stop all three services, on any manager:
- moving a folder doesn't move a running container's mount. mysql would keep writing the folder you moved aside, and redeploying an unchanged stack restarts nothing
-
copy
wordpressdb.sqlout ofwordpress_dumps, to a folder the stack doesn't mountdb-dumpdumps when it starts. started before the dump is loaded, it would replace it with a dump of the new, empty database
-
put
wordpress_htmlback from the backup. movewordpress_dbaside and make an empty one in its place- from a snapshot in
/mnt/docker-cephFS/.snap/while cephFS is fine, withcp -ato keep owners and modes - from PBS when cephFS is gone. that isn't written up yet, see cephFS
- mysql sets itself up only in an empty folder. with files in it, it
starts on those and ignores its
MYSQL_*variables
- from a snapshot in
-
start the database alone:
- mysql creates
wordpressdband the database user, with the passwords from the stack's secrets
- mysql creates
-
load the dump, on the node running the
dbtask:docker exec -i $(docker ps -q --filter 'name=^wordpress2025_db\.') \ sh -c 'MYSQL_PWD="$(cat /run/secrets/wordpress_mysql_root_password)" mysql -uroot' \ < wordpressdb.sql- the anchored name skips
wordpress2025_db-dump, which a plainname=wordpress2025_dbalso matches MYSQL_PWDkeeps the password out of the command line- the dump makes the database's tables. about 70 MB takes two minutes
- the anchored name skips
-
start the other two:
db-dumpnow dumps the restored database
-
check every site, as in checking it, with each site's name in the
Hostheader:mydomain.com,blog.mydomain.com,www.mydomain.comandmydomain1.com- each should print its own title
-
to replay the changes made after the dump, see binary logs. the logs are in the
wordpress_dbfolder you moved aside, or in its backup
wpadmin.conf¶
wpadmin.conf is an apache virtual host for mydomain.com, www., wpadmin.
and blog.. the compose makes it into the swarm config wpadmin_conf_v2, but
no service mounts it, so apache never reads it. the _v2 is the config's
version, see
stack conventions.
wpadmin.conf: an apache virtual host that no service mounts, 6 lines
checking it¶
wordpress picks the site by name, so ask for the root site's:
it prints the site's title, which wordpress reads from the database, so a
title means both services work. without the Host header wordpress redirects
to wp-signup.php.
gatus
runs this check every two minutes.