Homelab
Proof

U4 / Apps under test

BookStack vs Wiki.js: 200 pages, search and backups

Tested on BookStack (linuxserver image) 26.09.2

Test result

BookStack created 200 pages through its API in 57.98 s and found words inside page bodies; Wiki.js took 375.52 s and its default database search matched only titles, descriptions and paths, so a body-only word returned 0 hits. Wiki.js answered searches in about 3 ms against BookStack's roughly 240 ms, but it was searching far less.

Line drawing: an open book-shaped server module beside a stack of bound volumes on a shelf.

The surprise in this test is not speed. It is that the default search in Wiki.js 2.5.315 does not look inside pages. Everything else is a trade-off between two reasonable wikis, so start there if search matters to you.

What was created

Each wiki was filled through its HTTP API with 200 generated pages: 10 books (BookStack) or path folders (Wiki.js) of 20 pages, each about 3 KB of Markdown with eight sections and a unique marker word in the body (zq0123marker for page 123). BookStack used an API token, which the test inserted into its database from the shell instead of creating it in the UI. Wiki.js was finished through its setup endpoint and then used its GraphQL API with a login token.

200 pages created through each API: time, RAM before and after, and backup size by the documented method
WikiTotal seconds for 200 pagesPer page ms: median (min-max)Failed createsRAM before content (MiB, app + database)RAM after content (MiB)Backup output
BookStack 26.09.2 + MariaDB 11.857.98290.9 (256.6-354.9)0164.9185.0mariadb-dump 4423 kB (385 kB gzipped); the documented tar command in /app/www 221 bytes (it archives symlinks only in this image); with symlinks followed 1 kB; /config volume 1022 kB
Wiki.js 2.5.315 + PostgreSQL 16375.521880.2 (1738.1-2025.5)0164.1184.5pg_dump -F c 225 kB (170 kB as gzipped SQL)

Pages: about 3 KB of generated Markdown each, 10 books or folders of 20. Requests are sequential over loopback. RAM is the sum of the app and database containers, median of 12 samples. VM 4 vCPU, 3 GB. Backups were created but not restored in this test.

The 6.5-times gap in creation time is the biggest number here. Wiki.js took a median 1,880.2 ms per page, in a narrow range of 1,738.1 to 2,025.5 ms, so it is a steady cost per page, not a few slow requests. Why was not investigated, so the test cannot say whether it is rendering, indexing or something else, and a bulk import by a different route may behave differently. BookStack’s default API limit of 180 requests a minute (the API_REQUESTS_PER_MIN line in .env.example.complete in the BookStack repository) would have rejected the loop, so the test raised it to 100,000.

Four queries, 20 calls each, after all 200 pages existed:

Search latency over HTTP, milliseconds: median (min-max) of 20 calls, after 200 pages
QueryBookStack 26.09.2 + MariaDB 11.8Wiki.js 2.5.315 + PostgreSQL 16
title, one page ("Page 0123")255.6 (224.7-266.5), 200 hits2.8 (2.5-5.8), 1 hits
title, all pages ("Page")240.9 (221.1-264.9), 200 hits4.2 (3.3-7.9), 100 hits
body-only rare word (zq0123marker)218.1 (214.5-253.9), 1 hits2.8 (2.3-12.0), 0 hits
body-only common word (quebec)234.0 (224.0-259.9), 200 hits2.4 (2.1-2.8), 0 hits

BookStack: GET /api/search. Wiki.js: GraphQL pages.search with the default database search engine.

Read the hit counts before the timings. A title query returned the right page on Wiki.js (1 hit). The word that appears only in a page body returned nothing on Wiki.js (0 hits) and one page on BookStack (1 hit). The reason is in the code Wiki.js ships: the default db search engine in the image runs ILIKE on title, description and path only (server/modules/search/db/engine.js, read inside the 2.5.315 image). Wiki.js lets an administrator pick other search engines; none was configured, so this says nothing about them. The title query “Page” returned 100 hits on Wiki.js although all 200 titles contain it; the test did not look into why (a result cap is one possibility). BookStack’s docs say normal searches cover names, descriptions and bodies, and that with several words only one has to match, which is why “Page 0123” returned all 200 pages (ranked, not filtered) while Wiki.js returned the one exact title.

On timings: Wiki.js’s 2.4 to 4.2 ms medians come from a simple pattern match on short columns, and BookStack’s 218.1 to 255.6 ms medians come from searching text. Comparing them as if they did the same work would be misleading.

RAM, and backups

RAM is the app container plus its database, median of 12 samples before the pages and 30 s after. BookStack with MariaDB went from 164.9 to 185.0 MiB and Wiki.js with PostgreSQL from 164.1 to 184.5 MiB, so on this test the two ended within 1 MiB of each other. That is one run: the Wiki.js app container’s “before” samples included a peak of 222.5 MiB, so it was still settling when measured.

BookStack book page titled Book 1 listing pages Page 0000 to Page 0006, each with the start of its generated text
Wiki.js page titled Page 0001 with a table of contents of Sections 0 to 7 and generated body text under Section 0

Backups were taken with each project’s documented method; nothing was restored.

  • BookStack: the documented method is a database dump plus an archive of .env, public/uploads, storage/uploads and themes. The dump was 4,423 kB (385 kB gzipped). The documented tar command, run in the app folder of the linuxserver image, produced a 221-byte archive because those paths are symlinks into the /config volume; with symlinks followed it was 1 kB here, since the test uploaded no images or attachments, and the whole /config volume was 1,022 kB. In this image the files to back up appear to be the /config volume plus the dump, but no restore was tried, so that is unconfirmed.
  • Wiki.js: the project’s transfer guide uses pg_dump -F c, which was 225 kB (170 kB as gzipped plain SQL). That guide covers only PostgreSQL on Docker, and says an installation cannot be converted from another database engine.

Which one

On this evidence, if people will search the text of pages, BookStack’s default does it and Wiki.js’s does not. If you want a path-based tree and a rich editor and are willing to configure a different search engine, Wiki.js may still suit you; this test did not try. This paragraph is opinion; the measured parts are the tables above.

Steps

The compose files are the lab stacks; BookStack’s has two extra environment variables (APP_URL set to the screenshot tunnel’s port and the higher API rate limit).

lab/tests/wiki/files/bookstack.yml

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
# lab/stacks/bookstack/compose.yml with APP_URL set to the host port the screenshot tunnel uses (BookStack redirects to APP_URL), plus API_REQUESTS_PER_MIN (BookStack's default is 180 requests a minute, which a 200-page loop would hit).
x-lab: {title: "BookStack", version: "26.09.2", ready: "http://127.0.0.1:8080/login", ready_ok: "200", ui: "http://127.0.0.1:8080/login", ready_timeout: 300}
services:
  bookstack:
    image: lscr.io/linuxserver/bookstack:26.09.2
    restart: unless-stopped
    depends_on: [db]
    environment:
      PUID: "1000"
      PGID: "1000"
      APP_URL: http://127.0.0.1:18391
      APP_KEY: "base64:bGFiLW9ubHktbm90LWEtc2VjcmV0LWxhYi1vbmx5ISE="
      DB_HOST: db
      DB_PORT: "3306"
      DB_USERNAME: bookstack
      DB_PASSWORD: lab-only-not-a-secret
      DB_DATABASE: bookstack
      API_REQUESTS_PER_MIN: "100000"
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - bookstack:/config
  db:
    image: mariadb:11.8
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: lab-only-not-a-secret
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: lab-only-not-a-secret
    volumes:
      - db:/var/lib/mysql
volumes:
  bookstack:
  db:
lab/tests/wiki/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# wiki: BookStack 26.09.2 (MariaDB 11.8) vs Wiki.js 2.5.315 (PostgreSQL 16): 200 pages through the API, search timings, RAM, backups.
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=wiki; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
TOOLS=("$@"); [ ${#TOOLS[@]} -eq 0 ] && TOOLS=(bookstack wikijs)
[ $# -eq 0 ] && rm -rf "$OUT"; mkdir -p "$RAW"   # with explicit tool names, only those are re-run
one() {
  local tool="$1"
  start_vm d-$T-$tool
  vm scp-to "$([ $tool = bookstack ] && echo "$D/files/bookstack.yml" || echo "$ROOT/lab/stacks/$tool/compose.yml")" compose.yml >/dev/null
  vm scp-to "$D/wikitest.py" wikitest.py >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
  cpull '~' stack
  gx 'docker compose -p stack up -d 2>&1 | tail -1'
  local tok=""
  if [ $tool = bookstack ]; then
    until [ "$(gx 'curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/login')" = 200 ]; do sleep 3; done
    sleep 15
    gx "bash -s" < "$D/bs-token.sh" > "$RAW/bookstack-token.txt" 2>&1
    tok="labtokenid0123456789abcdefghijklmno:labsecret0123456789abcdefghijklmnop"
    n=0; until [ "$(gx "curl -s -o /dev/null -w %{http_code} -H 'Authorization: Token $tok' http://127.0.0.1:8080/api/books")" = 200 ]; do n=$((n+1)); [ $n -gt 40 ] && { echo "bookstack API never returned 200" > "$RAW/bookstack-FAILED.txt"; return 1; }; sleep 5; done
  else
    until [ "$(gx 'curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/')" != 000 ]; do sleep 3; done
    gx 'curl -s -X POST http://127.0.0.1:8080/finalize -H "content-type: application/json" -d "{\"adminEmail\":\"admin@lab.internal\",\"adminPassword\":\"lab-only-not-a-secret\",\"adminPasswordConfirm\":\"lab-only-not-a-secret\",\"siteUrl\":\"http://127.0.0.1:8080\",\"telemetry\":false}"' > "$RAW/wikijs-finalize.txt" 2>&1
    sleep 25
  fi
  sleep 60
  local names; names=$([ $tool = bookstack ] && echo "stack-bookstack-1 stack-db-1" || echo "stack-wiki-1 stack-db-1")
  gx "python3 sample.py $names" > "$RAW/$tool-ram-before.json"
  gx "python3 wikitest.py $tool $tok" > "$RAW/$tool-api.json" 2> "$RAW/$tool-api.err"
  sleep 30
  gx "python3 sample.py $names" > "$RAW/$tool-ram-after.json"
  # Backups, following each project's documented method
  if [ $tool = bookstack ]; then
    gx 'docker exec stack-db-1 mariadb-dump -u bookstack -plab-only-not-a-secret bookstack > /tmp/bs.sql 2>/dev/null; echo "dump_bytes $(stat -c %s /tmp/bs.sql)"; gzip -k /tmp/bs.sql; echo "dump_gz_bytes $(stat -c %s /tmp/bs.sql.gz)"
     docker exec stack-bookstack-1 sh -c "cd /app/www && tar -czf /tmp/files-docs.tgz .env public/uploads storage/uploads themes 2>&1 | tail -2; echo docs_command_archive_bytes \$(stat -c %s /tmp/files-docs.tgz); tar -czhf /tmp/files-follow.tgz .env public/uploads storage/uploads themes 2>&1 | tail -2; echo follow_symlinks_archive_bytes \$(stat -c %s /tmp/files-follow.tgz); ls -l .env public/uploads themes | head -5"' > "$RAW/bookstack-backup.txt" 2>&1
  else
    gx 'docker exec stack-db-1 pg_dump wiki -U wikijs -F c > /tmp/wiki.dump; echo "dump_c_bytes $(stat -c %s /tmp/wiki.dump)"; echo "sql_gz_bytes $(docker exec stack-db-1 pg_dump wiki -U wikijs | gzip | wc -c)"' > "$RAW/wikijs-backup.txt" 2>&1
  fi
  gx "sudo sh -c \"du -sb /var/lib/docker/volumes/stack_*/_data\"; docker inspect -f '{{.Name}} restarts={{.RestartCount}}' \$(docker ps -aq)" > "$RAW/$tool-disk.txt" 2>&1
  local S="$ROOT/public/shots/$T"
  if [ $tool = bookstack ]; then
    shot 8080 /login "$S/bookstack.png" '[{"fill":"input[name=email]","value":"admin@admin.com"},{"fill":"input[name=password]","value":"password"},{"press":"Enter"},{"wait":4000},{"goto":"http://127.0.0.1:18391/books/book-1"},{"wait":2000}]'
  else
    shot 8080 /login "$S/wikijs.png" '[{"wait":6000},{"fill":"input[autocomplete=username],input[type=email],input[type=text]","value":"admin@lab.internal"},{"fill":"input[type=password]","value":"lab-only-not-a-secret"},{"press":"Enter"},{"wait":5000},{"goto":"http://127.0.0.1:18391/book-1/page-0001"},{"wait":3000}]'
  fi
  mkdir -p "$RAW/$tool-env"; collect_env "$RAW/$tool-env"
}
for t in "${TOOLS[@]}"; do ( one "$t" ); done
python3 "$D/summarize.py" "$RAW" "$OUT"
lab/tests/wiki/wikitest.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: wikitest.py bookstack <token_id:secret> | wikijs. Creates 10 books/folders x 20 pages = 200 pages through the HTTP API
(about 3 KB of Markdown each), then times 4 search queries x 20 calls. Prints JSON."""
import json, random, statistics as st, sys, time, urllib.parse, urllib.request
tool = sys.argv[1]; B = "http://127.0.0.1:8080"; r = random.Random(11)
words = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu".split()
def page(i):
    body = "\n\n".join("## Section %d\n\n%s." % (s, " ".join(r.choice(words) for _ in range(60))) for s in range(8))
    return f"Page {i:04d}", f"Marker zq{i:04d}marker.\n\n" + body
def http(method, path, body=None, headers=None):
    h = {"content-type": "application/json", **(headers or {})}
    t = time.perf_counter()
    resp = urllib.request.urlopen(urllib.request.Request(B + path, data=json.dumps(body).encode() if body is not None else None, headers=h, method=method), timeout=120)
    d = resp.read(); return time.perf_counter() - t, json.loads(d) if d else None
out = {"tool": tool, "pages": 200}
create = []
if tool == "bookstack":
    H = {"Authorization": "Token " + sys.argv[2]}; ids = []
    for b in range(1, 11):
        _, d = http("POST", "/api/books", {"name": f"Book {b}"}, H); ids.append(d["id"])
    t_all = time.perf_counter()
    for i in range(200):
        title, md = page(i); t, d = http("POST", "/api/pages", {"book_id": ids[i // 20], "name": title, "markdown": md}, H); create.append(t * 1000)
    total = time.perf_counter() - t_all
    search = lambda q: http("GET", "/api/search?query=%s&count=20" % q, None, H)
else:
    H = {}
    _, d = http("POST", "/graphql", {"query": 'mutation { authentication { login(username: "admin@lab.internal", password: "lab-only-not-a-secret", strategy: "local") { jwt responseResult { succeeded message } } } }'})
    H = {"Authorization": "Bearer " + d["data"]["authentication"]["login"]["jwt"]}
    t_all = time.perf_counter(); fails = 0
    for i in range(200):
        title, md = page(i)
        q = 'mutation($c: String!, $t: String!, $p: String!) { pages { create(content: $c, description: "", editor: "markdown", isPublished: true, isPrivate: false, locale: "en", path: $p, tags: [], title: $t) { responseResult { succeeded message } } } }'
        t, d = http("POST", "/graphql", {"query": q, "variables": {"c": md, "t": title, "p": f"book-{i // 20 + 1}/page-{i:04d}"}}, H); create.append(t * 1000)
        if not d["data"]["pages"]["create"]["responseResult"]["succeeded"]: fails += 1
    total = time.perf_counter() - t_all; out["create_failures"] = fails
    def search(q):
        return http("POST", "/graphql", {"query": 'query($q: String!) { pages { search(query: $q) { totalHits results { id } } } }', "variables": {"q": urllib.parse.unquote(q)}}, H)
out["create_total_s"] = round(total, 2); out["create_ms"] = {"median": round(st.median(create), 1), "min": round(min(create), 1), "max": round(max(create), 1)}
sr = {}
for label, q in (("title, one page (\"Page 0123\")", "Page%200123"), ("title, all pages (\"Page\")", "Page"), ("body-only rare word (zq0123marker)", "zq0123marker"), ("body-only common word (quebec)", "quebec")):
    ts = []; hits = None
    for _ in range(20):
        t, d = search(q); ts.append(t * 1000)
        if hits is None: hits = (d["total"] if tool == "bookstack" else d["data"]["pages"]["search"]["totalHits"])
    sr[label] = {"median_ms": round(st.median(ts), 1), "min_ms": round(min(ts), 1), "max_ms": round(max(ts), 1), "hits": hits}
out["search"] = sr
if tool == "bookstack":
    out["rate_limit_header"] = urllib.request.urlopen(urllib.request.Request(B + "/api/books?count=1", headers=H)).headers.get("X-RateLimit-Limit")
print(json.dumps(out, indent=1))
lab/tests/wiki/bs-token.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# Runs in the VM: inserts an API token for the default BookStack admin (user 1) directly into MariaDB.
# BookStack stores the secret as a bcrypt hash, so one is generated with the app's own PHP.
SEC=labsecret0123456789abcdefghijklmnop
HASH=$(docker exec stack-bookstack-1 php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$SEC")
docker exec stack-db-1 mariadb -u bookstack -plab-only-not-a-secret bookstack -e "INSERT INTO api_tokens (name, token_id, secret, user_id, expires_at, created_at, updated_at) VALUES ('bench','labtokenid0123456789abcdefghijklmno','$HASH',1,'2099-01-01',NOW(),NOW());"
echo "inserted, hash prefix: ${HASH:0:4}"

What went wrong

The first run’s BookStack token was written with a mangled password hash (a shell expanded $2y inside the bcrypt string) and every API call returned HTTP 500; the token now goes in through a script file. Its first search queries also used a word that Wiki.js cannot match, which showed 0 hits and led to reading the search engine’s code; the queries were then redesigned to include title and body cases. An earlier version of all these runs had the guest drop its page cache every 3 s, which slowed disk-bound steps and raised RAM readings; it was removed and the test rerun, and every number on this page comes from the rerun (raw files dated 2026-11-02, 06:32 to 06:53 UTC). The BookStack screenshot was blank once because the app redirects to its configured APP_URL; the test now sets it to the tunnel port.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.