Kiwix — Self-Hosted Offline Wikipedia¶
Serving a full offline copy of Wikipedia from majorlab via Kiwix, live at wiki.majorshouse.com.
TL;DR¶
- ZIM archive (
wikipedia_en_all_maxi_2026-02.zim, 124GB) was already seeding on majorhome via Transmission. - Copied to majorlab with
rsyncrather than NFS-mounted — keeps Kiwix working even if majorhome is down or its torrent client is mid-reseed, and avoids standing up a new NFS export just for one static file. - Served via the official
ghcr.io/kiwix/kiwix-serveDocker image, bound to localhost, reverse-proxied through the host Caddy instance. - Hit a real gotcha: a stale, undocumented DNS record already existed for the same subdomain name — see below before you pick a hostname for a similar deployment.
Setup¶
1. Get the ZIM onto the serving host.
rsync -avh --progress majorhome:/majorstorage/torrents/wikipedia_en_all_maxi_2026-02.zim /home/kiwix/data/
At 124GB, this took ~19 minutes over gigabit LAN (~100MB/s sustained). Ran it under nohup ... & since it outlives a single SSH session's practical polling window.
Why copy instead of NFS-mount: the ZIM changes rarely (Wikipedia dumps are periodic, not continuous), so the dedup savings from mounting over NFS don't outweigh the operational cost of standing up an NFS server + export + firewall rule, and a copy means Kiwix has zero runtime dependency on majorhome's uptime.
2. Run the container.
docker run -d --name kiwix --restart unless-stopped \
-p 127.0.0.1:8095:8080 \
-v /home/kiwix/data:/data \
ghcr.io/kiwix/kiwix-serve:latest \
wikipedia_en_all_maxi_2026-02.zim
Bound to 127.0.0.1 only — matches the pattern used for every other Caddy-fronted service on majorlab (searxng, grafana, n8n), so the only public entry point is through Caddy/TLS, never the raw container port.
3. Caddy entry (/etc/caddy/Caddyfile):
sudo caddy validate --config /etc/caddy/Caddyfile before reloading — cheap insurance against a syntax typo taking down every other site block in the file.
4. DNS. Added an A record for wiki → majorlab's public IP via the Cloudflare API (same token/zone used by dmarc_update.yml), DNS-only (not proxied) — matches majorshouse.com's existing pattern where no subdomain runs through Cloudflare's proxy.
Keeping it current — no live sync, but a safe auto-update script¶
Kiwix doesn't sync live against Wikipedia — there's no incremental feed. Wikipedia ZIM dumps are periodic full-archive snapshots published to download.kiwix.org, and for the English all_maxi variant that's roughly every 6 months (2025-08, 2026-02 and 2026-08 as of this writing). Kiwix has experimental work on incremental ZIM updates, but it isn't production-ready.
The practical answer is a periodic re-download-and-swap, run as a cron job on majorlab (/opt/kiwix/update-zim.sh, weekly, Sun 4 AM via /etc/cron.d/kiwix-zim-update):
- Scrape
download.kiwix.org's directory listing for the latestwikipedia_en_all_maxi_YYYY-MM.zimfilename. - Compare against what's currently deployed. If no newer dump exists, log and exit — this is the common case, since the check is cheap (an HTML listing fetch) but the dump itself is huge and infrequent.
- If a newer dump exists: ask the mirror how big it actually is (
curl -sIL, readcontent-length), require that many GiB free plus 10GiB headroom, and abort cleanly — old ZIM untouched — if there isn't room. Derive this from the real size, not a hardcoded floor — see the sizing note below for what a guessed threshold cost us. - Download to a staging directory, not directly into the live data dir.
- Sanity-check the download size (>50GB floor — catches a truncated/failed transfer before it gets anywhere near production).
- Move the verified file into the data dir, recreate the
kiwixcontainer pointed at the new filename, wait, then HTTP health-check it. - Only delete the old ZIM after the new container passes its health check. If the health check fails, the script exits with an error and leaves the old ZIM in place — a failed swap never leaves the site down.
This ordering (stage → verify → swap → health-check → then delete old) is the important part: at every failure point, the previous known-good ZIM is still there and still what's being served.
This design got its first real exercise on 2026-09-13, and the ordering held — but the threshold was wrong. The pre-flight refused to start and left the site untouched on the old dump, which is the correct shape of failure. It was refusing for the wrong reason, though: see the sizing note below. After the data dir was relocated and the check was rewritten to use the dump's real size, the same script ran clean end to end: 127GB fetched in ~19 minutes at ~110MB/s, exact-size verification passed, swap and health check green, old ZIM deleted only afterwards. The jump was 2026-02 → 2026-08: 18,982,214 → 21,673,012 articles (+2.69M) and 8,217,604 → 8,469,385 media items.
Disk sizing note — put the data dir on your biggest filesystem, not your system disk. The pre-flight check needs room for old + new simultaneously, so the real requirement is ~2× a dump, not 1×. That is the price of the safe swap ordering above, and it is worth paying — but it means a filesystem that merely fits the current dump will fail the moment a bigger one ships.
This bit us on majorlab, and the way it bit us is the interesting part. The data dir originally lived at /opt/kiwix/data on the 465GB system disk. On 2026-09-13 the weekly cron found the 2026-08 dump and aborted: only 135GB free, need at least 140GB.
That abort was a false negative. The 140GB was a hardcoded guess written months earlier — "a maxi dump plus buffer" — when dumps were ~115GiB. The 2026-08 dump is 118.67GiB, and there were 135GiB free: it would have downloaded fine, with ~16GiB to spare. Roughly 21GiB of invented padding rejected a transfer that would have completed. A threshold you guessed is a threshold that fails in both directions — too low and it lets a doomed download start, too high and it blocks a viable one while reporting what looks like a real disk problem.
So the fix is two independent changes, and it is worth being clear about which does what:
- Derive the requirement from the dump's actual size (below). This is what fixes the false negative.
- Move the data dir to the biggest filesystem anyway. This was still the right call, just not for the reason the error message suggested: 16GiB of transient margin during a swap is not a margin on a disk that also carries
/var, and the dumps grow every cycle (2026-02→2026-08was 115.47 → 118.67GiB). Relocating also freed 116GB on/(71% → 46% used), since a ~120GiB archive was the single largest thing on the system disk.
Fixed by moving the data to a near-empty 938GB NVMe: DATA_DIR=/home/kiwix/data, STAGING_DIR=/home/kiwix/staging (the script stays at /opt/kiwix/update-zim.sh — only its two path variables changed). Relocating also freed 116GB on / (71% → 46% used), since a 124GB archive was the single largest thing on the system disk.
Two things worth copying from how this went:
- Copy, verify, then delete — don't
mvacross filesystems. A cross-devicemvis a copy followed by an unlink with no window to check the result. Copying left the original untouched until the relocated container was confirmed serving, so rollback was never more than recreating the container against the old path. - Verify by reading real content, not by getting a 200. kiwix-serve returns a perfectly healthy landing page from a ZIM whose content clusters it has never touched. The check that actually proves the archive survived the copy is fetching named articles and confirming their titles and prose — e.g.
/content/<book>/A/Linux,/A/Debian,/A/Wikipedia, each returning a few hundred KB of real text. Note the book name in that path is the ZIM filename stem (wikipedia_en_all_maxi_2026-02), not the<name>in the catalog metadata (wikipedia_en_all) — using the latter returns a confusing 404 that looks like data loss and isn't. Ask the server for the right path via/catalog/v2/entriesrather than guessing.
Asking the mirror for the real size¶
REMOTE_BYTES=$(curl -sIL --max-time 60 "$LISTING_URL$LATEST" 2>/dev/null \
| grep -i '^content-length:' | tail -1 | tr -dc '0-9')
if [ -n "$REMOTE_BYTES" ] && [ "$REMOTE_BYTES" -gt 50000000000 ] 2>/dev/null; then
NEED_GB=$(( REMOTE_BYTES / 1073741824 + HEADROOM_GB ))
else
REMOTE_BYTES=""; NEED_GB="$MIN_FREE_GB" # fall back to the static floor
fi
Two things in there are load-bearing:
tail -1, not head -1. download.kiwix.org redirects twice before the file — to lb.download.kiwix.org, then to a
regional mirror — and the first 301 response carries its own content-length: 162, describing the redirect body. A
naive head -1 returns 162, every free-space check passes forever, and the guard silently stops existing. This is a
worse outcome than the original hardcoded floor, because it looks like it is working.
The -gt 50000000000 sanity gate. A maxi dump is never under 50GB, so anything smaller means the parse grabbed a
redirect body or garbage — fall back to the static floor rather than trusting it. This is not theoretical: request a
filename that doesn't exist and the chain still returns a 162-byte redirect body, which this gate correctly rejects.
Knowing the exact size also upgrades the post-download check. The original test was >50GB, which passes a badly
truncated transfer; with the advertised length in hand, require an exact match and keep the floor only as the fallback
path:
if [ -n "$REMOTE_BYTES" ]; then
[ "$SIZE_BYTES" -eq "$REMOTE_BYTES" ] || { log "ERROR: size mismatch"; rm -f "$STAGING_DIR/$LATEST.part"; exit 1; }
elif [ "$SIZE_BYTES" -lt 50000000000 ]; then
...
fi
If you are running SELinux (majorlab is Fedora, Enforcing): label the new directory
container_file_t(semanage fcontext -a -t container_file_t "/home/kiwix(/.*)?"thenrestorecon -R). On this host Docker's SELinux support is off —docker infoSecurityOptions lists onlyseccompandcgroupns— so the mount worked regardless, but labelling it correctly costs nothing and avoids a silent breakage if that is ever turned on.