Dark Mode for Kiwix-Serve via Apache Reverse Proxy
Dark Mode for Kiwix-Serve via Apache Reverse Proxy
kiwix-serve 3.7.0 · Apache 2.4 · Catppuccin Mocha palette
Kiwix-serve does not include built-in theme support. This guide shows how to inject dark-mode CSS at the Apache reverse-proxy layer with mod_substitute, while avoiding changes to ZIM article content.
Table of Contents
- Architecture Overview
- The Problem
- Prerequisites
- Basic Reverse Proxy Setup
- Understanding Kiwix-Serve’s Page Structure
- The gzip Trap
- Scoping Injection with LocationMatch
- The Iframe Background Bleed-Through Problem
- Selector Scoping Strategy
- The Complete CSS
- Full Apache Configuration
- Verification
- Toggling Dark Mode On/Off
- Adapting to Other Color Palettes
- Troubleshooting
Architecture Overview
Browser ──HTTPS──▶ Apache 2.4 (mod_ssl + mod_proxy + mod_substitute)
│
│ mod_substitute injects <style> into </head>
│ only on / , /viewer , /nojs
│
▼
kiwix-serve :8300 (serves ZIM files)
│
├── / Landing page (book library)
├── /viewer Viewer shell (toolbar + iframe)
├── /nojs No-JavaScript fallback
└── /wiki/… ZIM article content (loaded inside iframe)
The key detail: the Kiwix viewer loads article content inside an <iframe>. CSS injected into the parent page cannot cross the iframe boundary into ZIM articles, but it can affect the iframe element itself.
The Problem
Kiwix-serve 3.7.0 (libkiwix 14.0.0) currently has:
- No
--dark-modeflag - No theme configuration
- No CSS customization endpoint
- A hardcoded light UI
If you prefer dark mode, the default library and toolbar can feel harsh in low-light conditions.
Prerequisites
Enable required Apache modules:
sudo a2enmod ssl rewrite proxy proxy_http headers substitute deflate filter
sudo systemctl restart apache2
| Module | Purpose |
|---|---|
mod_proxy / mod_proxy_http | Reverse proxy to kiwix-serve |
mod_ssl | HTTPS termination |
mod_headers | Strip Accept-Encoding before proxying |
mod_substitute | Inject <style> into HTML responses |
mod_deflate | Re-compress responses after substitution |
mod_filter | Required by mod_substitute content-type filtering |
Basic Reverse Proxy Setup
Start with a minimal proxy before adding dark mode:
<VirtualHost *:443>
ServerName mirror.example.com
SSLEngine on
SSLCertificateFile /path/to/fullchain.pem
SSLCertificateKeyFile /path/to/privkey.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8300/
ProxyPassReverse / http://127.0.0.1:8300/
</VirtualHost>
Confirm this works first at https://mirror.example.com/.
Understanding Kiwix-Serve’s Page Structure
Kiwix-serve presents multiple page types with different HTML:
1. Landing Page (/)
<body>
<div class="kiwixNav"> <!-- top navigation bar -->
<div class="kiwixHomeBody"> <!-- book grid -->
<div class="book__wrapper"> <!-- card -->
<div class="kiwixfooter"> <!-- footer -->
</body>
2. Viewer Page (/viewer)
<body>
<div id="kiwixtoolbarwrapper"> <!-- toolbar wrapper -->
<div id="kiwixtoolbar"> <!-- controls -->
<iframe id="content_iframe"> <!-- ZIM content -->
</body>
Important: Articles are loaded inside this iframe from paths like /wikipedia_en/Article_Name. Parent CSS cannot style the iframe document, but it can style the iframe element.
3. No-JS Fallback (/nojs)
A simplified library UI with many of the same classes as /.
4. ZIM Content (/wikipedia_en/..., /stackoverflow_en/..., etc.)
This is source article HTML from ZIM files. Do not inject your dark CSS here.
The gzip Trap
This is the most common mod_substitute failure mode.
The Problem
When browsers send Accept-Encoding: gzip, kiwix-serve returns gzip-compressed HTML. mod_substitute scans raw response bytes for </head>. In compressed output, that literal string does not exist, so the substitution silently does nothing.
Why curl Seems to Work
A basic curl request often gets uncompressed output, so substitution appears fine in tests while failing in browsers.
The Fix
Strip Accept-Encoding before proxying, then re-compress in Apache:
RequestHeader unset Accept-Encoding
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType SUBSTITUTE text/html
Flow:
Browser ──[Accept-Encoding: gzip]──▶ Apache
Apache strips Accept-Encoding ──▶ kiwix-serve
kiwix-serve returns uncompressed ──▶ Apache
mod_substitute injects <style> ──▶ mod_deflate compresses
Apache ──[Content-Encoding: gzip]──▶ Browser
Only apply this to the pages you modify; avoid forcing uncompressed responses for all paths.
Scoping Injection with LocationMatch
Inject only into these paths:
| Path | Regex Match | Page |
|---|---|---|
/ | ^/$ | Landing page |
/viewer | ^/viewer$ | Viewer shell |
/nojs | ^/nojs$ | No-JS fallback |
Combined pattern: ^/(viewer|nojs)?$
<LocationMatch "^/(viewer|nojs)?$">
RequestHeader unset Accept-Encoding
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType SUBSTITUTE text/html
Substitute "s|</head>|<style>/* dark mode CSS here */</style></head>|i"
</LocationMatch>
Everything else (/wikipedia_en/..., /skin/..., etc.) remains untouched.
The Iframe Background Bleed-Through Problem
Even with URL scoping, one subtle viewer issue remains.
What Happens
If you darken the viewer page <body>, that background can show around transparent iframe regions, producing dark bleed-through behind light article content.
The Fix
- Do not set viewer body background.
- Apply dark body background only on landing pages via
body:has(.kiwixNav). - Force iframe element white:
#content_iframe {
background: #fff !important;
color-scheme: light;
}
- Set
color-schemeappropriately:
body:has(#kiwixtoolbarwrapper) { color-scheme: dark; }
#content_iframe { color-scheme: light; }
Selector Scoping Strategy
Some Kiwix classes are generic (.modal, .fadeOut, .noResults). Scope those selectors under Kiwix-specific parents for safety.
| Selector | Risk | Scoping |
|---|---|---|
.kiwixNav, .kiwixSearch | Low | Direct |
.kiwixHomeBody, .kiwixfooter | Low | Direct |
#kiwixtoolbar, #content_iframe | Low | Direct |
.book__wrapper, .book__title | Medium | .kiwixHomeBody .book__* |
.fadeOut | High | .kiwixHomeBody .fadeOut |
.noResults | Medium | .kiwixHomeBody .noResults |
.modal, .modal-heading | High | body:has(.kiwixNav) .modal |
.loader-spinner | Medium | .kiwixHomeBody~.loader .loader-spinner |
The Complete CSS
The full CSS below is human-readable. The Apache Substitute directive uses a minified one-line version.
Wrapped in @media (prefers-color-scheme: dark), so it only applies to users in dark mode.
@media (prefers-color-scheme: dark) {
body:has(.kiwixNav) {
background: #1e1e2e !important;
color: #cdd6f4 !important;
color-scheme: dark;
}
body:has(#kiwixtoolbarwrapper) { color-scheme: dark; }
#content_iframe {
background: #fff !important;
color-scheme: light;
}
.kiwixNav { background: #181825 !important; }
.kiwixNav__kiwixFilter { background: #313244 !important; color: #cdd6f4 !important; }
.kiwixNav__select { background: #45475a !important; border-color: #585b70 !important; }
.kiwixNav__select::after { background: #45475a !important; color: #cdd6f4 !important; }
.kiwixSearch { background: #313244 !important; color: #cdd6f4 !important; border-color: #585b70 !important; }
.kiwixButton { background: #45475a !important; color: #cdd6f4 !important; border-color: #585b70 !important; }
.kiwixHomeBody { background: #1e1e2e !important; }
.kiwixHomeBody__results { color: #cdd6f4 !important; }
.kiwixHomeBody .book__wrapper { background: #313244 !important; color: #cdd6f4 !important; border-color: #45475a !important; }
.kiwixHomeBody .book__header,
.kiwixHomeBody .book__title { color: #cdd6f4 !important; }
.kiwixHomeBody .book__description { color: #a6adc8 !important; }
.kiwixHomeBody .book__download { background: #89b4fa !important; }
.kiwixHomeBody .book__download > span { color: #1e1e2e !important; }
.kiwixHomeBody .book__download:hover { background: #b4befe !important; }
.kiwixHomeBody .book__languageTag { background: #45475a !important; color: #cdd6f4 !important; }
.kiwixHomeBody .book__tags,
.kiwixHomeBody .book__meta { color: #a6adc8 !important; }
.kiwixHomeBody .fadeOut {
background: linear-gradient(180deg, rgba(30,30,46,0) 0%, #1e1e2e 100%) !important;
}
.kiwixfooter { color: #a6adc8 !important; }
.kiwixfooter a { color: #89b4fa !important; }
.kiwixHomeBody ~ .loader .loader-spinner {
border-color: #313244 !important;
border-top-color: #89b4fa !important;
}
.kiwixHomeBody .noResults { color: #cdd6f4 !important; }
.kiwixHomeBody .noResults > a { color: #89b4fa !important; }
body:has(.kiwixNav) .modal { background: #313244 !important; color: #cdd6f4 !important; border-color: #45475a !important; }
body:has(.kiwixNav) .modal-heading { background: #181825 !important; border-color: #45475a !important; }
body:has(.kiwixNav) .modal-title { color: #cdd6f4 !important; }
#kiwixtoolbar { background: #181825 !important; border-bottom-color: #45475a !important; }
.kiwix #kiwixtoolbar button,
.kiwix #kiwixtoolbar input[type="submit"] {
background: #313244 !important;
color: #cdd6f4 !important;
border-color: #585b70 !important;
}
.kiwix #kiwixtoolbar #kiwixsearchform input[type="text"] {
background: #313244 !important;
color: #cdd6f4 !important;
border-color: #585b70 !important;
}
}
Color Palette: Catppuccin Mocha
| Role | Hex | Used For |
|---|---|---|
| Base | #1e1e2e | Page backgrounds |
| Mantle | #181825 | Navbar, toolbar, modal headers |
| Surface0 | #313244 | Cards, inputs, search boxes, modals |
| Surface1 | #45475a | Buttons, selects, language tags |
| Overlay0 | #585b70 | Borders |
| Text | #cdd6f4 | Primary text |
| Subtext0 | #a6adc8 | Descriptions, metadata, footer |
| Blue | #89b4fa | Links, download buttons, spinner accents |
| Lavender | #b4befe | Hover states |
Full Apache Configuration
# mirror.example.com — Kiwix multi-mirror with dark mode
<VirtualHost *:80>
ServerName mirror.example.com
RewriteEngine On
RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=302,L]
</VirtualHost>
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName mirror.example.com
SSLEngine on
SSLCertificateFile /path/to/fullchain.pem
SSLCertificateKeyFile /path/to/privkey.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8300/
ProxyPassReverse / http://127.0.0.1:8300/
<LocationMatch "^/(viewer|nojs)?$">
RequestHeader unset Accept-Encoding
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType SUBSTITUTE text/html
Substitute "s|</head>|<style>@media(prefers-color-scheme:dark){body:has(.kiwixNav){background:#1e1e2e!important;color:#cdd6f4!important;color-scheme:dark}body:has(#kiwixtoolbarwrapper){color-scheme:dark}#content_iframe{background:#fff!important;color-scheme:light}.kiwixNav{background:#181825!important}.kiwixNav__kiwixFilter{background:#313244!important;color:#cdd6f4!important}.kiwixNav__select{background:#45475a!important;border-color:#585b70!important}.kiwixNav__select::after{background:#45475a!important;color:#cdd6f4!important}.kiwixSearch{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwixButton{background:#45475a!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwixHomeBody{background:#1e1e2e!important}.kiwixHomeBody__results{color:#cdd6f4!important}.kiwixHomeBody .book__wrapper{background:#313244!important;color:#cdd6f4!important;border-color:#45475a!important}.kiwixHomeBody .book__header,.kiwixHomeBody .book__title{color:#cdd6f4!important}.kiwixHomeBody .book__description{color:#a6adc8!important}.kiwixHomeBody .book__download{background:#89b4fa!important}.kiwixHomeBody .book__download>span{color:#1e1e2e!important}.kiwixHomeBody .book__download:hover{background:#b4befe!important}.kiwixHomeBody .book__languageTag{background:#45475a!important;color:#cdd6f4!important}.kiwixHomeBody .book__tags,.kiwixHomeBody .book__meta{color:#a6adc8!important}.kiwixHomeBody .fadeOut{background:linear-gradient(180deg,rgba(30,30,46,0) 0%,#1e1e2e 100%)!important}.kiwixfooter{color:#a6adc8!important}.kiwixfooter a{color:#89b4fa!important}.kiwixHomeBody~.loader .loader-spinner{border-color:#313244!important;border-top-color:#89b4fa!important}.kiwixHomeBody .noResults{color:#cdd6f4!important}.kiwixHomeBody .noResults>a{color:#89b4fa!important}body:has(.kiwixNav) .modal{background:#313244!important;color:#cdd6f4!important;border-color:#45475a!important}body:has(.kiwixNav) .modal-heading{background:#181825!important;border-color:#45475a!important}body:has(.kiwixNav) .modal-title{color:#cdd6f4!important}#kiwixtoolbar{background:#181825!important;border-bottom-color:#45475a!important}.kiwix #kiwixtoolbar button,.kiwix #kiwixtoolbar input[type=\"submit\"]{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}.kiwix #kiwixtoolbar #kiwixsearchform input[type=\"text\"]{background:#313244!important;color:#cdd6f4!important;border-color:#585b70!important}}</style></head>|i"
</LocationMatch>
</VirtualHost>
</IfModule>
Validate and reload:
sudo apache2ctl configtest && sudo systemctl reload apache2
Verification
1. Check Injection Counts
curl -sk https://mirror.example.com/ | grep -c 'prefers-color-scheme'
curl -sk https://mirror.example.com/viewer | grep -c 'prefers-color-scheme'
curl -sk https://mirror.example.com/wikipedia_en/ | grep -c 'prefers-color-scheme'
Expected output: 1, 1, 0.
2. Visual Verification (Playwright)
docker run --rm \
--add-host=mirror.example.com:YOUR_SERVER_IP \
-v /tmp/screenshots:/screenshots \
mcr.microsoft.com/playwright:v1.49.1-noble \
bash -c '
cd /tmp && npm init -y >/dev/null 2>&1
npm i [email protected] >/dev/null 2>&1
NODE_PATH=/tmp/node_modules node -e "
const { chromium } = require(\"playwright\");
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({
colorScheme: \"dark\",
viewport: { width: 1280, height: 800 },
ignoreHTTPSErrors: true
});
const page = await ctx.newPage();
await page.goto(\"https://mirror.example.com/\", { waitUntil: \"domcontentloaded\" });
await page.waitForTimeout(3000);
await page.screenshot({ path: \"/screenshots/landing.png\" });
await browser.close();
})();
"
'
3. Pixel Check
from PIL import Image
img = Image.open('/tmp/screenshots/landing.png')
r, g, b = img.getpixel((640, 400))[:3]
assert max(r, g, b) < 80, f"Expected dark pixel, got RGB({r},{g},{b})"
Toggling Dark Mode On/Off
Disable
sudo sed -i '/<LocationMatch "^\/(viewer|nojs)?\$">/,/<\/LocationMatch>/s/^/#DM# /' \
/etc/apache2/sites-available/mirror.example.com.conf
sudo apache2ctl configtest && sudo systemctl reload apache2
Re-enable
sudo sed -i 's/^#DM# //' /etc/apache2/sites-available/mirror.example.com.conf
sudo apache2ctl configtest && sudo systemctl reload apache2
Adapting to Other Color Palettes
Replace hex values using this mapping:
| Variable | Catppuccin Mocha |
|---|---|
| Page background | #1e1e2e |
| Navbar / toolbar | #181825 |
| Cards / inputs | #313244 |
| Buttons / selects | #45475a |
| Borders | #585b70 |
| Primary text | #cdd6f4 |
| Secondary text | #a6adc8 |
| Accent / links | #89b4fa |
| Hover accent | #b4befe |
Popular alternatives: Dracula, Nord, Gruvbox, and Tokyo Night.
Troubleshooting
CSS works with curl, not browser
Likely gzip handling. Verify:
mod_headersis enabledRequestHeader unset Accept-Encodingis inside the correct<LocationMatch>- Apache was reloaded
Dark background leaks around article content
Do not set viewer body background. Use:
body:has(#kiwixtoolbarwrapper) { color-scheme: dark }#content_iframe { background: #fff !important; color-scheme: light }
Apache syntax error on Substitute
Escape internal quotes as \", especially selectors like input[type=\"text\"].
CSS appears in HTML but visuals are unchanged
Likely browser cache. Hard-refresh (Ctrl+Shift+R) or test in an incognito window.
LocationMatch not matching /
^/(viewer|nojs)?$ matches:
//viewer/nojs
URL fragments are not sent to the server, so /viewer#hash still matches /viewer at the HTTP layer.
mod_substitute silently does nothing
Check module load and filters:
apachectl -M | grep substitute
Also ensure response Content-Type is text/html.
Compatibility
| Component | Tested Version |
|---|---|
| kiwix-serve | 3.7.0 (libkiwix 14.0.0, libzim 9.2.3) |
| Apache | 2.4.66 (Debian) |
| Browser | Chromium 131, Firefox 134 |
If a future Kiwix release changes class names/markup, inspect fresh HTML (curl -s http://127.0.0.1:8300/) and adjust selectors.