Wiki.tetrain.com Runbook
This is a reproducible, step-by-step runbook — commands in the order they were actually run, so someone else could redo this migration on a fresh box and land in the same state. It's the companion to wiki.tetrain.com_upgrade_plan.md, which is the decision log (why each choice was made, problems hit, alternatives considered). Read that first for context; use this to actually execute.
Provenance note on exactness: every command in Phases 3, 5-8 was either captured live in this session or reconstructed from artifacts still present on the server (Docker image layer history via docker history --no-trunc, .git remotes/branches on installed extensions, live config files, certbot's own renewal-params file). Phases 1-2 predate what this session could verify command-by-command (interactive shell, no bash history retained) — those steps are given as the standard/equivalent command for what the decision log describes, explicitly marked [reconstructed] where the literal originally-run command isn't recoverable. Nothing below is guessed silently.
Phase 1 — Target server base setup [reconstructed — standard equivalents][edit]
Run on the fresh Rocky Linux 9 box (wiki-new.tetrain.com, provided by client with DNS already pointed at it).
hostnamectl set-hostname wiki-new.tetrain.com dnf update -y # kernel gets bumped by this update; a reboot is needed later to actually run it (Phase 8 covers # the reboot that happened, driven by other work — if doing this fresh, reboot here instead) systemctl enable --now firewalld firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload # Docker CE (official repo, not the distro's own docker package) dnf install -y dnf-plugins-core dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin systemctl enable --now docker
Verify: docker ps runs without error; firewall-cmd --list-services shows http https ssh (confirmed live state — dhcpv6-client and cockpit are also present on the real box, likely Rocky defaults, harmless).
Phase 2 — Retrieve source data from the old server [reconstructed — standard equivalents][edit]
On wiki.tetrain.com (old CentOS 7 box, 172.105.56.64 at the time):
# DB dump mysqldump -u root my_wiki > my_wiki_source.sql # 49MB # images + config/extensions, packaged for transfer tar czf images.tar.gz -C /var/www/wiki images # 215MB uncompressed source tar czf config_and_code.tar.gz -C /var/www/wiki extensions LocalSettings.php skins # 161MB, archival only
Transfer both to the new box (scp or equivalent) into /root/mw-hop-migration/:
mkdir -p /root/mw-hop-migration scp root@<old-box>:my_wiki_source.sql /root/mw-hop-migration/ scp root@<old-box>:images.tar.gz /root/mw-hop-migration/ scp root@<old-box>:config_and_code.tar.gz /root/mw-hop-migration/
Verify integrity (this session used MD5, confirmed matching before/after transfer):
md5sum my_wiki_source.sql images.tar.gz config_and_code.tar.gz # compare against source-side sums
All three files are still present on the live box at /root/mw-hop-migration/ if you need to re-derive anything (49MB SQL, 225MB images tarball, 168MB config/code tarball).
Phase 3 — Docker-based version-hop chain (verified exact — reconstructed from live Docker images)[edit]
Why Docker per-hop: the hop chain needs 5 different PHP runtimes (5.6 → 7.2 → 7.4 → 8.0 → 8.1) that can't coexist natively on one Rocky 9 box. Each hop is a disposable container built from the official php Docker Hub image (which retains every historical tag) plus the matching MediaWiki release tarball downloaded directly from releases.wikimedia.org.
3.1 Download the 6 release tarballs[edit]
cd /root/mw-hop-migration
mkdir -p tarballs && cd tarballs
for v in 1.23.16 1.27.7 1.31.16 1.35.13 1.39.11 1.43.2; do
curl -O "https://releases.wikimedia.org/mediawiki/${v%.*}/mediawiki-${v}.tar.gz"
done
# 1.43.2 was later replaced with 1.43.9 (see 3.5) — its pinned Composer deps hit a security
# advisory block; 1.43.9 resolved cleanly:
curl -O "https://releases.wikimedia.org/mediawiki/1.43/mediawiki-1.43.9.tar.gz"
(All 7 tarballs — including the superseded 1.43.2 — are still on disk at /root/mw-hop-migration/tarballs/.)
[edit]
docker network create mwmigrate mkdir -p /root/mw-hop-migration/db-data docker run -d --name mw-mariadb \ --network mwmigrate \ -e MARIADB_ROOT_PASSWORD=migrate_temp_pw \ -v /root/mw-hop-migration/db-data:/var/lib/mysql \ mariadb:10.11
(Verified live — this exact container is still running, docker inspect mw-mariadb confirms the env var, mount, and network above.)
Load the source dump into it:
docker exec -i mw-mariadb mariadb -uroot -pmigrate_temp_pw -e "CREATE DATABASE my_wiki;" docker exec -i mw-mariadb mariadb -uroot -pmigrate_temp_pw my_wiki < /root/mw-hop-migration/my_wiki_source.sql
[edit]
One template, parameterized by PHP tag:
# Dockerfile.template
ARG PHP_TAG
FROM php:${PHP_TAG}
RUN apt-get update && apt-get install -y --no-install-recommends \
libicu-dev libpng-dev libjpeg-dev libxml2-dev libonig-dev zlib1g-dev \
&& docker-php-ext-configure gd --with-jpeg 2>/dev/null || docker-php-ext-configure gd 2>/dev/null || true \
&& docker-php-ext-install mysqli intl gd mbstring calendar opcache 2>&1 | tail -20 \
&& rm -rf /var/lib/apt/lists/*
(This is the literal, byte-for-byte content still on disk at /root/mw-hop-migration/hops/Dockerfile.template; the RUN line matches exactly what docker history --no-trunc mw-hop1-1.23:latest shows as that image's top custom layer.)
PHP tag per hop (confirmed by running php -v inside each surviving image):
| Hop | MediaWiki | PHP tag | Confirmed running php -v
|
|---|---|---|---|
| 1 | 1.23.16 | php:5.6-apache |
PHP 5.6.40 |
| 2 | 1.27.7 | php:5.6-apache |
PHP 5.6.40 |
| 3 | 1.31.16 | php:7.2-apache |
PHP 7.2.34 |
| 4 | 1.35.13 | php:7.4-apache |
PHP 7.4.33 |
| 5 | 1.39.11 | php:8.0-apache |
PHP 8.0.30 |
| 6 | 1.43.9 | php:8.1-apache |
PHP 8.1.34 |
Build each image (repeat per row above):
cd /root/mw-hop-migration/hops mkdir -p hop1-1.23 && cd hop1-1.23 tar xzf ../../tarballs/mediawiki-1.23.16.tar.gz --strip-components=1 -C . --one-top-level=src 2>/dev/null \ || (mkdir -p src && tar xzf ../../tarballs/mediawiki-1.23.16.tar.gz --strip-components=1 -C src) cp ../Dockerfile.template Dockerfile docker build --build-arg PHP_TAG=5.6-apache -t mw-hop1-1.23:latest . cd ..
Repeat for hop2 (5.6-apache), hop3 (7.2-apache), hop4 (7.4-apache), hop5 (8.0-apache), hop6 (8.1-apache), substituting the tarball, directory name, and PHP_TAG each time.
Verify: docker images should list all 6 (still true on the live box today, ~700-850MB each).
[edit]
For each hop, in order (1→2→3→4→5→6), with the *previous* hop's DB state already loaded:
# Example shown for hop 1; same shape for hops 2-6, just swap the image/paths.
docker run --rm \
--network mwmigrate \
-v /root/mw-hop-migration/hops/hop1-1.23/src:/var/www/html \
mw-hop1-1.23:latest \
bash -c "
cat > /var/www/html/LocalSettings.php <<'EOF'
<?php
\$wgDBtype = 'mysql';
\$wgDBserver = 'mw-mariadb';
\$wgDBname = 'my_wiki';
\$wgDBuser = 'root';
\$wgDBpassword = 'migrate_temp_pw';
\$wgServer = 'http://localhost';
\$wgScriptPath = '';
EOF
php maintenance/update.php --quick
"
Minimal LocalSettings.php only — no extensions loaded during the hop chain, deliberately: the goal at this stage is walking the core DB schema forward through every LTS version, not reconciling extension-specific schema/data, which happens once at the end on the final 1.43 codebase (Phase 5).
Verify after every hop before moving to the next: update.php exits 0 with no fatal errors; spot-check row counts are unchanged (SELECT COUNT(*) FROM page; etc. inside the mariadb container) — this session confirmed 1707 pages / 3634 revisions / 163 users identical before and after the full chain.
3.5 Final hop note — 1.43.2 → 1.43.9 substitution[edit]
Hop 6 was first attempted against mediawiki-1.43.2.tar.gz; its pinned composer.lock hit a security-advisory-driven dependency block during composer install. Fix: re-extracted mediawiki-1.43.9.tar.gz in its place (rebuilt hop6-1.43's src/ from the newer tarball) — same PHP tag (8.1-apache), same Dockerfile, same update.php --quick invocation. No other hop was affected.
Phase 4 — Production stack (verified exact — matches live installed packages/config)[edit]
On the same Rocky 9 box, independent of the Docker hop chain (which is only used to walk the DB schema forward — production runs natively, not in Docker).
4.1 Packages[edit]
dnf install -y httpd mod_ssl dnf module reset php -y dnf module enable php:8.3 -y # confirms which stream — live box runs 8.3.31 dnf install -y php php-fpm php-mysqlnd php-gd php-mbstring php-xml php-intl php-opcache dnf install -y mariadb-server mariadb-backup dnf install -y certbot python3-certbot-apache
(Exact versions confirmed live: httpd-2.4.62, php-8.3.31/php-fpm-8.3.31, mariadb-server-10.11.18, certbot-3.1.0.)
4.2 PHP-FPM ↔ Apache wiring[edit]
/etc/php-fpm.d/www.conf (confirmed live values):
listen = /run/php-fpm/www.sock listen.acl_users = apache,nginx listen.allowed_clients = 127.0.0.1
Apache vhost routes .php to that socket (see the full vhost block in Phase 8, which shows the final version after the DNS-cutover edit — the original pre-cutover version was identical minus the ServerAlias):
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php-fpm/www.sock|fcgi://localhost"
</FilesMatch>
systemctl enable --now php-fpm systemctl enable --now httpd
4.3 MariaDB — hardened, scoped DB user (not root)[edit]
systemctl enable --now mariadb
mysql_secure_installation # [reconstructed] — remove anonymous users, remove remote root,
# drop test DB; interactive, answers: Y to all prompts, set a root
# password when asked
mysql -u root -p <<'EOF'
CREATE DATABASE my_wiki CHARACTER SET binary;
CREATE USER 'wikiuser'@'localhost' IDENTIFIED BY '<random-generated-password>';
GRANT ALL PRIVILEGES ON my_wiki.* TO 'wikiuser'@'localhost';
FLUSH PRIVILEGES;
EOF
Credentials were generated randomly and stored root-only-readable at /root/.mariadb_root_credentials and /root/.wiki_db_credentials on the box — never in chat, never in this doc. Confirmed live: wikiuser@localhost exists and is what LocalSettings.php actually uses ($wgDBuser = "wikiuser"), not root.
4.4 Firewall + TLS[edit]
firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload certbot --apache -d wiki-new.tetrain.com --non-interactive --agree-tos \ -m root@wiki-new.tetrain.com --redirect
Confirmed live via /etc/letsencrypt/renewal/wiki-new.tetrain.com.conf: authenticator = apache, installer = apache, ECDSA key type, auto-renewal configured. This is the exact same command pattern used again in Phase 8 to expand the cert — see there for the real, later-run command including --expand.
Verify: curl -I https://wiki-new.tetrain.com/ returns a real TLS handshake (this session's original note: "confirmed serving HTTPS correctly, 403 expected — empty docroot, MediaWiki not deployed yet" at this point in the sequence).
Phase 5 — Deploy migrated MediaWiki 1.43.9 to production[edit]
5.1 Export from the hop chain, import into production MariaDB[edit]
docker exec mw-mariadb mariadb-dump -uroot -pmigrate_temp_pw my_wiki > /root/mw-hop-migration/my_wiki_migrated_1.43.sql mysql -u wikiuser -p my_wiki < /root/mw-hop-migration/my_wiki_migrated_1.43.sql
(The migrated dump — 26.5MB — is still on disk at /root/mw-hop-migration/my_wiki_migrated_1.43.sql if you need to re-import.)
5.2 Deploy the codebase[edit]
mkdir -p /var/www/wiki tar xzf /root/mw-hop-migration/tarballs/mediawiki-1.43.9.tar.gz --strip-components=1 -C /var/www/wiki cd /var/www/wiki curl -sS https://getcomposer.org/installer | php php composer.phar install --no-dev
5.3 Extensions — 9 standard + MsUpload[edit]
Most of the 9 (CheckUser, ConfirmEdit, Gadgets, Nuke, ParserFunctions, WikiEditor, plus many more not in the original list) already ship inside the 1.43.9 tarball itself — only Renameuser, TimedMediaHandler, and MsUpload needed fetching separately:
cd /var/www/wiki/extensions git clone --branch REL1_43 https://gerrit.wikimedia.org/r/mediawiki/extensions/Renameuser.git git clone --branch REL1_43 https://gerrit.wikimedia.org/r/mediawiki/extensions/TimedMediaHandler.git git config --global --add safe.directory /var/www/wiki/extensions/MsUpload git clone --branch REL1_43 https://github.com/wikimedia/mediawiki-extensions-MsUpload.git MsUpload
(Confirmed live: MsUpload's .git remote is exactly https://github.com/wikimedia/mediawiki-extensions-MsUpload.git, branch REL1_43, current commit 0c261546d07d9cdd152541e220b14a8a969b58a.)
UserAdmin and HTML5video are not cloned here — dropped per the decision log (UserAdmin: abandoned upstream, no PHP8 fork; HTML5video: PHP8-incompatible, later replaced by EmbedVideo in Phase 7, not by TimedMediaHandler as originally assumed).
5.4 LocalSettings.php — production version[edit]
Written fresh (not copied from the hop chain's minimal version). Full current content, secrets redacted, confirmed live via cat /var/www/wiki/LocalSettings.php:
<?php
if ( !defined( 'MEDIAWIKI' ) ) { exit; }
$wgSitename = "TetraWiki";
$wgMetaNamespace = "TetraWiki";
$wgScriptPath = "";
$wgServer = "https://wiki-new.tetrain.com"; // updated again in Phase 8 to the real domain
$wgResourceBasePath = $wgScriptPath;
$wgEmergencyContact = "biswajit.tetra@gmail.com";
$wgPasswordSender = "biswajit.tetra@gmail.com";
$wgEnableEmail = true;
$wgEnableUserEmail = true;
$wgEnotifUserTalk = false;
$wgEnotifWatchlist = false;
$wgEmailAuthentication = true;
$wgDBtype = "mysql";
$wgDBserver = "localhost";
$wgDBname = "my_wiki";
$wgDBuser = "wikiuser";
$wgDBpassword = "<from /root/.wiki_db_credentials>";
$wgSecretKey = "<freshly generated, not the migration-testing placeholder>";
$wgUpgradeKey = "<freshly generated>";
$wgDBprefix = "";
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
$wgSharedDB = null;
$wgMainCacheType = CACHE_ACCEL;
$wgMemCachedServers = [];
$wgEnableUploads = true;
$wgUseImageMagick = false;
$wgUseInstantCommons = false;
$wgLanguageCode = "en";
$wgLocaltimezone = "UTC";
$wgLocalisationCacheConf['storeDirectory'] = "/var/www/wiki/cache";
$wgSitemapNamespaces = false;
$wgDefaultSkin = "vector-2022"; // see Phase 6 — legacy "vector" was the original wrong value
wfLoadSkin( 'Vector' );
wfLoadExtension( 'CheckUser' );
wfLoadExtension( 'ConfirmEdit' );
wfLoadExtension( 'ConfirmEdit/QuestyCaptcha' );
wfLoadExtension( 'Gadgets' );
wfLoadExtension( 'Nuke' );
wfLoadExtension( 'ParserFunctions' );
wfLoadExtension( 'Renameuser' );
wfLoadExtension( 'TimedMediaHandler' );
wfLoadExtension( 'WikiEditor' );
wfLoadExtension( 'MsUpload' );
wfLoadExtension( 'EmbedVideo' ); // added in Phase 7, not present at initial deploy
$wgGroupPermissions['*']['edit'] = true;
$wgGroupPermissions['*']['createaccount'] = true;
$wgGroupPermissions['*']['read'] = true;
$wgRightsPage = "";
$wgRightsUrl = "";
$wgRightsText = "";
$wgDiff3 = "/usr/bin/diff3";
5.5 Images + SELinux[edit]
tar xzf /root/mw-hop-migration/images.tar.gz -C /var/www/wiki chown -R apache:apache /var/www/wiki semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/wiki/images(/.*)?" semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/wiki/cache(/.*)?" restorecon -Rv /var/www/wiki/images /var/www/wiki/cache
Confirmed live: ls -Zd shows httpd_sys_content_t on /var/www/wiki itself and httpd_sys_rw_content_t on images/ and cache/ specifically — matches the pattern above (Rocky 9 is SELinux-enforcing by default, this was required, not optional).
Verify: homepage loads (200, title "TetraWiki"); a real content page renders (ISGEC_-_Next_Cloud_Deployment was the one checked); thumbnail generation works (GD-based since $wgUseImageMagick = false).
Phase 6 — Logo sizing fix (verified exact, from the decision log)[edit]
Two root causes, both already diagnosed precisely in the decision log:
# 1. Wrong skin variant — legacy Vector was being selected instead of Vector 2022 # Fix: in LocalSettings.php, change: # $wgDefaultSkin = "vector"; # to: # $wgDefaultSkin = "vector-2022";
# 2. Logo was being force-fit into Vector 2022's fixed 50x50 icon slot, distorting it. mkdir -p /var/www/wiki/resources/assets/logo # download the real company logo (tetra_logo_2017Web.png, 198x399px) and host it locally # rather than hotlinking www.tetrain.com, then re-composite onto properly sized, transparent, # square canvases at each density (letterboxed/centered, not stretched): # tetrain_logo_1x.png -> 50x50 # tetrain_logo_1.5x.png -> 75x75 # tetrain_logo_2x.png -> 100x100
Then set (already reflected in the Phase 5.4 LocalSettings.php listing above):
$wgLogos = [ "1x" => "$wgResourceBasePath/resources/assets/logo/tetrain_logo_1x.png", "1.5x" => "$wgResourceBasePath/resources/assets/logo/tetrain_logo_1.5x.png", "2x" => "$wgResourceBasePath/resources/assets/logo/tetrain_logo_2x.png", "icon" => "$wgResourceBasePath/resources/assets/logo/tetrain_logo_1x.png", ];
php maintenance/run.php purgeList # or Special:Purge on the affected pages — clear the cached logo
Verify: served logo image is genuinely 50×50px (checked via direct image fetch), no distortion.
Phase 7 — YouTube video-embed restoration (exact — run in this session)[edit]
Problem: HTML5video (dropped in Phase 5) was actually the extension embedding external YouTube videos, not TimedMediaHandler's job (uploaded files only) — this broke 149 embeds across 44 pages.
7.1 Install EmbedVideo[edit]
cd /var/www/wiki/extensions git clone https://github.com/StarCitizenTools/mediawiki-extensions-EmbedVideo.git EmbedVideo
(Default branch — no REL1_43 branch exists for this third-party extension; extension.json requires MediaWiki ≥1.29.0, satisfied.)
7.2 Patch for MediaWiki 1.43's strict array typing[edit]
MediaWiki 1.43 core requires ParserOutput::addModules()/addModuleStyles() to receive an array, not a bare string. Exact diff applied (confirmed live via diff against the .bak):
--- EmbedVideo.hooks.php.bak
+++ EmbedVideo.hooks.php
@@ -650,2 +650,2 @@
- $out->addModules('ext.embedVideo');
- $out->addModuleStyles('ext.embedVideo.styles');
+ $out->addModules( [ 'ext.embedVideo' ] );
+ $out->addModuleStyles( [ 'ext.embedVideo.styles' ] );
cp EmbedVideo/EmbedVideo.hooks.php EmbedVideo/EmbedVideo.hooks.php.bak
sed -i "s/\$out->addModules('ext.embedVideo');/\$out->addModules( [ 'ext.embedVideo' ] );/" EmbedVideo/EmbedVideo.hooks.php
sed -i "s/\$out->addModuleStyles('ext.embedVideo.styles');/\$out->addModuleStyles( [ 'ext.embedVideo.styles' ] );/" EmbedVideo/EmbedVideo.hooks.php
Diagnosed by temporarily enabling $wgShowExceptionDetails = true; on a scratch test page, reverted immediately after confirming the fix.
7.3 Load it[edit]
// add to LocalSettings.php, after the other wfLoadExtension() calls: wfLoadExtension( 'EmbedVideo' );
7.4 Fix content — per page, per video[edit]
For each of the 44 pages holding broken embeds:
- Pull current wikitext:
php maintenance/run.php getText.php "<full title, with namespace prefix>"— note: most of these "pages" are actuallyCategory:-namespace pages being used to hold real content (a pre-existing quirk of this wiki, not introduced here); a bare title without theCategory:prefix resolves to "page does not exist." - Replace every
<HTML5video type="youtube" ...>VIDEO_ID</HTML5video>with:
{{#ev:youtube|VIDEO_ID|640}}
'''Video summary:''' <written summary paragraph, sourced from the video's actual YouTube
transcript>
- Apply:
php maintenance/run.php edit.php -s "<summary>" -u Admin -b --nocreate "<full title>" < newcontent.txt(content piped via stdin from a file, not passed as a shell argument — avoids quoting issues with apostrophes/backticks in the prose).
Verify: DB sweep for zero remaining <HTML5video> tags across all touched pages; confirm in a real browser that each embed renders as an actual <iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID">, not just literal wikitext.
Content-quality notes (not a server step, but part of "what was actually done"): summaries are based on real YouTube auto-caption transcripts. Where audio/ASR quality made a transcript unusable, the paragraph says so explicitly ([VERY POOR TRANSCRIPT QUALITY] / [PARTIAL TRANSCRIPT]) rather than fabricating detail. 5 of the 149 videos are flagged inaccessible (1 private, 4 removed by YouTube) and 2 are flagged as duplicate content — all noted directly in the published paragraph, not just in this log.
Phase 8 — Production DNS cutover (exact — run in this session)[edit]
Client repointed DNS directly (A record for wiki.tetrain.com → 172.105.39.56; new record wiki-old.tetrain.com → 172.105.56.64, the old box) — no registrar access was available in this session to do that part. Everything below is what made the *server* actually ready for it.
8.1 Expand the TLS certificate to cover the real domain[edit]
certbot --apache -d wiki-new.tetrain.com -d wiki.tetrain.com --expand \ --non-interactive --agree-tos -m root@wiki-new.tetrain.com --redirect
This issues/saves the expanded cert correctly but can fail to auto-install with: Encountered vhost ambiguity when trying to find a vhost for wiki.tetrain.com — if so (happened here), the cert itself is fine; only the vhost wiring needs a manual step next.
8.2 Add ServerAlias to both vhosts (only needed if 8.1 hit the ambiguity error)[edit]
Back up first:
cp /etc/httpd/conf.d/wiki.conf /etc/httpd/conf.d/wiki.conf.bak.$(date +%s) cp /etc/httpd/conf.d/wiki-le-ssl.conf /etc/httpd/conf.d/wiki-le-ssl.conf.bak.$(date +%s)
Add ServerAlias wiki.tetrain.com right after each ServerName wiki-new.tetrain.com line, in both /etc/httpd/conf.d/wiki.conf (port 80) and /etc/httpd/conf.d/wiki-le-ssl.conf (port 443):
sed -i '/ServerName wiki-new.tetrain.com/a\ ServerAlias wiki.tetrain.com' /etc/httpd/conf.d/wiki.conf sed -i '/ServerName wiki-new.tetrain.com/a\ ServerAlias wiki.tetrain.com' /etc/httpd/conf.d/wiki-le-ssl.conf
Broaden the HTTP→HTTPS redirect in wiki.conf to match either hostname:
sed -i 's/RewriteCond %{SERVER_NAME} =wiki-new.tetrain.com$/RewriteCond %{SERVER_NAME} =wiki-new.tetrain.com [OR]\nRewriteCond %{SERVER_NAME} =wiki.tetrain.com/' /etc/httpd/conf.d/wiki.conf
Resulting wiki.conf (port 80), confirmed live:
<VirtualHost *:80>
ServerName wiki-new.tetrain.com
ServerAlias wiki.tetrain.com
DocumentRoot /var/www/wiki
<Directory /var/www/wiki>
AllowOverride All
Require all granted
DirectoryIndex index.php
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php-fpm/www.sock|fcgi://localhost"
</FilesMatch>
ErrorLog /var/log/httpd/wiki-error.log
CustomLog /var/log/httpd/wiki-access.log combined
RewriteEngine on
RewriteCond %{SERVER_NAME} =wiki-new.tetrain.com [OR]
RewriteCond %{SERVER_NAME} =wiki.tetrain.com
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
Resulting wiki-le-ssl.conf (port 443), confirmed live:
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName wiki-new.tetrain.com
ServerAlias wiki.tetrain.com
DocumentRoot /var/www/wiki
<Directory /var/www/wiki>
AllowOverride All
Require all granted
DirectoryIndex index.php
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php-fpm/www.sock|fcgi://localhost"
</FilesMatch>
ErrorLog /var/log/httpd/wiki-error.log
CustomLog /var/log/httpd/wiki-access.log combined
SSLCertificateFile /etc/letsencrypt/live/wiki-new.tetrain.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/wiki-new.tetrain.com/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>
apachectl configtest systemctl reload httpd
8.3 Update MediaWiki's own server config[edit]
cp /var/www/wiki/LocalSettings.php /var/www/wiki/LocalSettings.php.bak.$(date +%s) sed -i 's|$wgServer = "https://wiki-new.tetrain.com";|$wgServer = "https://wiki.tetrain.com";|' /var/www/wiki/LocalSettings.php
Without this, MediaWiki keeps generating every internal link/redirect/canonical URL against the old hostname even with correct DNS and TLS.
8.4 Verify end-to-end[edit]
# cert now covers both names: echo | openssl s_client -connect wiki.tetrain.com:443 -servername wiki.tetrain.com 2>/dev/null \ | openssl x509 -noout -subject -issuer -ext subjectAltName # redirect chain: curl -sIL https://wiki.tetrain.com/ | grep -i "^location\|^HTTP" # expect: 301 -> /index.php?title=Main_Page -> 200 OK # old box still reachable at its new name: dig +short wiki-old.tetrain.com A # -> 172.105.56.64
Confirmed live: cert SAN lists DNS:wiki-new.tetrain.com, DNS:wiki.tetrain.com; redirect chain lands on 200 OK; a page with restored video embeds (Phase 7) renders correctly — real <iframe> present — when loaded via https://wiki.tetrain.com.
Phase 9 — File upload formats: PDF, then Office/image/document formats (exact — run in this session)[edit]
9.1 PDF uploads[edit]
cp /var/www/wiki/LocalSettings.php /var/www/wiki/LocalSettings.php.bak.$(date +%Y%m%d%H%M%S)
Add to LocalSettings.php, right after $wgEnableUploads = true;:
$wgFileExtensions = array_merge( $wgFileExtensions ?? [ 'png', 'gif', 'jpg', 'jpeg' ], [ 'pdf' ] );
Add after the other wfLoadExtension() calls:
wfLoadExtension( 'PdfHandler' );
PdfHandler ships bundled inside the 1.43.9 tarball already on disk (Phase 5) — it just isn't loaded by default, no separate download needed.
PdfHandler needs four system binaries it depends on for rendering/thumbnailing/text-indexing, none of which were present on the box:
dnf install -y ghostscript poppler-utils ImageMagick # provides: gs ($wgPdfProcessor), convert ($wgPdfPostProcessor), # pdfinfo ($wgPdfInfo), pdftotext ($wgPdftoText) — all four are MediaWiki defaults, # no explicit $wgPdf* overrides were needed once the binaries existed on PATH
(This dnf install hit the 60s SSH command timeout mid-transaction — verified separately via rpm -qa | grep -iE 'ghostscript|poppler-utils|ImageMagick' and which gs convert pdfinfo pdftotext that it had actually completed; not re-run.)
Verify:
curl -s 'https://wiki.tetrain.com/api.php?action=query&meta=siteinfo&siprop=fileextensions&format=json' # confirm "pdf" present in the list
Also checked Special:Version on the live wiki — confirms "PDF Handler" registered as a loaded extension.
9.2 Extend further: ppt, pptx, tiff, bmp, docx, xlsx, ps, odt, ods, odp, odg[edit]
Before touching config, checked whether MediaWiki core already knows how to map each extension to a MIME type (the actual mapping source in this version is includes/libs/mime/MimeMap.php — not a flat mime.types/mime.info file):
grep -iE "'docx'|'xlsx'|'pptx'|'ppt'|'odt'|'ods'|'odp'|'odg'|'tiff'|'tif'|'bmp'|'ps'" \ /var/www/wiki/includes/libs/mime/MimeMap.php
Confirmed: every one of these was already correctly mapped (e.g. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => [ 'docx' ], 'image/tiff' => [ 'tiff', 'tif' ], 'application/postscript' => [ 'ai', 'eps', 'ps' ], etc.) — no core patching needed, only the extensions allowlist.
cp /var/www/wiki/LocalSettings.php /var/www/wiki/LocalSettings.php.bak.$(date +%Y%m%d%H%M%S)
Edit the line from 9.1 to:
$wgFileExtensions = array_merge( $wgFileExtensions ?? [ 'png', 'gif', 'jpg', 'jpeg' ], [ 'pdf', 'ppt', 'pptx', 'docx', 'xlsx', 'tiff', 'tif', 'bmp', 'ps', 'odt', 'ods', 'odp', 'odg' ] );
(Legacy doc/xls deliberately not added — not part of what was requested.)
Verify: same siprop=fileextensions API check as 9.1; confirmed all 11 new extensions present, no httpd error-log entries after reload.
Known limitation, disclosed rather than silently shipped: TIFF and PS files upload fine but render no thumbnail preview — the site's bitmap pipeline is GD, not ImageMagick ($wgUseImageMagick = false, unchanged from Phase 5), GD has no TIFF decoder, and no PostScript handler extension is installed (ls extensions/ | grep -iE 'tiff|postscript' → empty). Both formats behave like Office documents already do (download-only, generic file icon). Not fixed in this pass — flagged to the client as an open choice (add a TIFF/PS handler vs. accept upload-only) rather than guessed at.
Phase 10 — 'Outdated article' banner system (exact — run in this session)[edit]
10.1 Template:Outdated[edit]
Created as a wiki page via the same edit.php mechanism as Phase 7. Final version (styling was revised once, from a generic amber box to client-specified red/bold/large, per explicit instruction to match exactly):
<div style="border:2px solid #c0392b; background:#fdecea; color:#c0392b; padding:14px 18px; margin:0 0 16px 0; border-radius:4px; line-height:1.35;">
<span style="font-weight:700; font-size:22px;">This is an Old / Outdated Article, Please use this only as technical Reference</span>{{#if:{{{reason|}}}|<br><span style="font-weight:700; font-size:15px;">Reason: {{{reason}}}</span>}}{{#if:{{{date|}}}|<br><span style="font-size:13px;">Flagged: {{{date}}}</span>}}
</div><noinclude>
== Usage ==
Add <nowiki>{{Outdated}} at the top of a page to mark it as outdated (red/bold/large banner).
Optional parameters:
* {{Outdated|reason=Server decommissioned}}
* {{Outdated|date=2026-07}}
* Both together: {{Outdated|reason=Client migrated off this platform|date=2026-07}}
[[Category:Templates]]
</noinclude>
</nowiki>
Applied via:
php maintenance/run.php edit.php -s "Create Outdated template" -u Admin -b "Template:Outdated" < template_text.txt
10.2 Page inventory (for the client to review and flag)[edit]
SELECT p.page_namespace, p.page_title, p.page_is_redirect, p.page_len,
r.rev_timestamp, COUNT(r2.rev_id) AS rev_count
FROM page p
JOIN revision r ON r.rev_id = p.page_latest
LEFT JOIN revision r2 ON r2.rev_page = p.page_id
WHERE p.page_namespace IN (0,14)
GROUP BY p.page_id
ORDER BY r.rev_timestamp ASC
Run via php maintenance/run.php sql --query "...", 637 rows returned (490 Article, 147 Category, 7 redirects). Converted to:
- an HTML dashboard artifact (sortable/filterable table, live banner mockup) for browsing, and
wiki_pages_for_review.csv(Page Title, Type, URL, Last Edited, Age, Revisions, Redirect?, and two blank columns —Mark as Outdated? (Y/N)andReason / Subject— for the client to fill in and return).
10.3 Batch-apply the client's markings[edit]
Client returned the CSV with 468 of 637 rows marked Y/y, each with a filled-in reason. Rather than 468 separate SSH round-trips, wrote a one-off maintenance script (BatchMarkOutdated.php, dropped into /var/www/wiki/maintenance/ alongside core scripts, run once, then deleted) that loads a JSON {title, reason} list built from the CSV and, for each page:
- resolves the title (namespace prefix already baked into the CSV's URL column from 10.2's query, so
Category:/main-namespace handling is correct going in — no re-lookup needed), - skips it if the text already contains
{{Outdated(idempotent on re-run), - prepends
{{Outdated|reason=<csv reason>|date=2026-07}}and saves viaWikiPage::newPageUpdater()/saveRevision()(the same underlying APIedit.phpitself calls, just invoked in-process for all 468 rows in one PHP bootstrap instead of 936 separate CLI invocations).
php maintenance/BatchMarkOutdated.php --file=/var/www/wiki/maintenance/outdated_batch.json --date=2026-07 # Done: 465, already-tagged: 3, missing: 0, failed: 0 # (3 "already-tagged" were a 3-row test slice run first to confirm the script before the full batch)
Verify: spot-checked several pages via getText.php and live in-browser (computed style color: rgb(192, 57, 43), font-weight: 700, font-size: 22px — matches Template:Outdated exactly); banner text includes each page's specific client-supplied reason and the flag date.
Phase 11 — Outbound email / SMTP relay fix (exact — run in this session)[edit]
Starting state: $wgEnableEmail/$wgEnableUserEmail were already true (Phase 5.4), but no MTA existed on the box at all — PHP's mail() fallback had nowhere to hand off to. Client provided SMTP relay credentials directly: host mail.tetrain.com, port 25, STARTTLS, username smtp_wiki, password (entered directly into LocalSettings.php over SSH, never written to this doc or any other file — same handling as the DB credentials in Phase 4.3).
11.1 Identify the correct config shape[edit]
This MediaWiki version relays mail via PEAR's Mail_smtp class (vendor/pear/mail/Mail/smtp.php), not PHPMailer — confirmed by reading includes/mail/UserMailer.php::sendInternal(), which calls Mail::factory('smtp', $smtp) when $wgSMTP is an array. Accepted keys per Mail/smtp.php's constructor: host, port, auth, starttls, username, password, localhost, timeout, debug (no IDHost — that's a PHPMailer-only concept and was deliberately not used).
11.2 Apply config[edit]
cp /var/www/wiki/LocalSettings.php /var/www/wiki/LocalSettings.php.bak.$(date +%Y%m%d%H%M%S)
Inserted right after $wgEnableEmail = true;:
$wgSMTP = [ 'host' => 'mail.tetrain.com', 'port' => 25, 'auth' => true, 'starttls' => true, 'username' => 'smtp_wiki', 'password' => '<provided by client, entered directly, redacted here>', 'localhost' => 'wiki.tetrain.com', ];
chmod 640 /var/www/wiki/LocalSettings.php # owner apache:apache — tightened since the file now
# holds an SMTP password, not just the DB password;
# confirmed httpd (runs as `apache`) still reads it
# fine via the owner bit (curl to the site: 200)
11.3 Test send, and the two false starts[edit]
Wrote a temporary maintenance script calling UserMailer::send() directly (the same code path a password-reset email uses), deleted after use.
Attempt 1 — From: biswajit.tetra@gmail.com → relay returned 250 OK (SEND OK) but the email never arrived. Diagnosed via DNS, not guessed:
dig +short TXT gmail.com # "v=spf1 redirect=_spf.google.com" — only Google infra allowed dig +short TXT _dmarc.gmail.com # p=none; sp=quarantine dig +short TXT spf.tetrain.com # only server-relay(.1/.2)/indorama-relay.tetrain.com authorized dig +short A mail.tetrain.com # 139.84.171.188 — NOT one of the authorized relay hosts above
mail.tetrain.com is not in tetrain.com's own SPF record, and the From address wasn't even on the tetrain.com domain — a message claiming to be from a personal Gmail address, relayed through an unrelated third-party server, reads as spoofing to the receiving side.
Attempt 2 — From: wiki@tetrain.com (still via mail.tetrain.com) → also SEND OK at the relay, not independently confirmed delivered/undelivered (superseded by attempt 3 before checking).
Attempt 3 (fix) — client identified the real constraint directly: the From address must match the *authenticated* SMTP account itself, i.e. smtp_wiki@tetrain.com, not an arbitrary @tetrain.com address. Updated:
sed -i 's/$wgEmergencyContact = "biswajit.tetra@gmail.com";/$wgEmergencyContact = "smtp_wiki@tetrain.com";/' /var/www/wiki/LocalSettings.php sed -i 's/$wgPasswordSender = "biswajit.tetra@gmail.com";/$wgPasswordSender = "smtp_wiki@tetrain.com";/' /var/www/wiki/LocalSettings.php
Re-ran the test with From: smtp_wiki@tetrain.com → SEND OK, and client confirmed actual receipt in their inbox. This is the live, working state.
Verify (if reproducing): $wgEmergencyContact / $wgPasswordSender in LocalSettings.php both read smtp_wiki@tetrain.com; a real password-reset or "email this user" send succeeds and arrives.
Known-incomplete items (not yet part of a repeatable 'done' state)[edit]
- Old
wiki.tetrain.combox (nowwiki-old.tetrain.com, 172.105.56.64) is up but not yet decommissioned. TimedMediaHandlerneedsffmpegfor transcoding actual *uploaded* video files — not installed, not blocking since this wiki's real video content is all YouTube embeds (Phase 7), not uploads.- Rocky 9 kernel was bumped by
dnf updateduring initial setup but the box wasn't rebooted onto it at that point — confirm current running kernel matches installed before assuming this is closed. - TIFF and PS uploads (Phase 9.2) have no thumbnail/preview handler — upload-only, by design for now, open question to the client on whether that's sufficient.
- 169 of 637 wiki pages were left unmarked after the Phase 10.3 batch (the CSV rows the client did not mark
Y) — expected to remain a rolling/ongoing process, not a one-time close-out.