Nextcloud vs Seafile: upload, download, RAM and disk
Tested on Nextcloud (apache image) 33.0.10
Test result
On this test box Seafile CE 14.0.9 was faster with many small files (median 77.5 s up and 12.8 s down for 1,000 files, against 113.0 s and 53.8 s for Nextcloud 33.0.10), while Nextcloud had the lower median for one 512 MiB upload (2.0 s against 3.5 s, but the ranges overlap: 1.8 to 4.1 s against 3.5 to 4.1 s) and idled in about half the RAM (282 MiB against 554 MiB). Disk growth was within 1 % between them.

Both servers were given the same generated data over their real HTTP APIs, one after the other, in the same VM: 1,000 small files and one 512 MiB file, three times with different random content each time. The question was how long it takes, how much RAM and CPU the server spends, and how much disk the data ends up using.
The dataset
Each of the 3 reps was 1,000 files of 1 KiB to 64 KiB (about 33.5 to 33.9 MB per rep) in 20 folders, plus one 512 MiB (536,870,912 bytes) file, all random bytes. Random data does not compress and cannot be deduplicated, so this is a worst case for storage tricks and says nothing about documents or photos that do compress. Total logical data over the 3 reps: 1,712 MB.
Nextcloud was driven by WebDAV (MKCOL, PUT, GET with basic auth); Seafile by its /api2 token API (one library per rep, upload link, multipart POST with relative_path, download link). Four parallel workers handled the small files, as a sync client would; the large file moved in a single request. Seafile’s API needs a new upload link or download link for each file, and those extra requests are inside the timed phases. Nextcloud ran first and Seafile second in the same VM; the order was not randomized, and the test did not measure whether it mattered. Every downloaded file was checked against its SHA-256: 0 errors and 0 mismatches in both servers.
Timings
| Measurement | Nextcloud 33.0.10 (Apache, MariaDB 11.8, Redis 7.4) | Seafile CE 14.0.9 (MariaDB 11.8, Redis 7.4) |
|---|---|---|
| Upload 1,000 small files, 4 parallel workers (s) | 113.0 (77.5 to 126.3) | 77.5 (69.0 to 85.8) |
| files per second | 8.8 (7.9 to 12.9) | 12.9 (11.7 to 14.5) |
| Upload one 512 MiB file (s) | 2.0 (1.8 to 4.1) | 3.5 (3.5 to 4.1) |
| MiB per second | 253 (125 to 278) | 145 (124 to 145) |
| Download the 1,000 small files, 4 parallel workers (s) | 53.8 (53.3 to 64.0) | 12.8 (12.0 to 13.4) |
| Download the 512 MiB file (s) | 1.1 (1.0 to 1.2) | 1.3 (1.0 to 1.4) |
| Errors and SHA-256 mismatches over all 3 reps (uploads and downloads) | 0 | 0 |
Median of 3 runs with the range in parentheses. Each rep used a different random dataset (33.5 to 33.9 MB of small files plus one 512 MiB file), so no server could deduplicate between reps. Client and server in one VM over loopback, 4 vCPU, 4 GB. Nextcloud: WebDAV with basic auth. Seafile (one upload link and one download link requested per file, inside the timed phase): /api2 token, upload-api multipart.
- Small files: Seafile uploaded at a median of 12.9 files/s against 8.8 for Nextcloud, and downloaded about four times faster (12.8 s against 53.8 s). The test did not look into why.
- One large file: Nextcloud’s median upload was lower (2.0 s against 3.5 s), but its three runs were 4.1, 2.0 and 1.8 s and Seafile’s 3.5, 3.5 and 4.1 s, so in one of three runs Nextcloud was slower than every Seafile run; the two were level on download (1.1 s and 1.3 s). Loopback is much faster than any home network, so these numbers show server-side overhead, not what a user would wait.
- The Nextcloud small-file upload took 126.3, 113.0 and 77.5 s in reps 1 to 3, and every Nextcloud phase was slowest in rep 1, which looks like warm-up; Seafile’s went the other way (69.0, 77.5, 85.8 s). The host was shared with other jobs (load average 1.90 at the start), but the test did not establish what caused the spread, so read the medians as rough.
RAM, CPU and disk
| Measurement | Nextcloud 33.0.10 (Apache, MariaDB 11.8, Redis 7.4) | Seafile CE 14.0.9 (MariaDB 11.8, Redis 7.4) |
|---|---|---|
| RAM idle before the test, median (MiB) | 282 | 554 |
| RAM during small-file upload, median / peak (MiB) | 467 / 673 | 885 / 1058 |
| CPU during small-file upload, mean / peak (% of one vCPU, all containers) | 260 / 359 | 116 / 220 |
| RAM during large-file upload, median / peak (MiB) | 416 / 427 | 908 / 973 |
| CPU during large-file upload, mean / peak (% of one vCPU, all containers) | 101 / 210 | 72 / 114 |
| RAM during small-file download, median / peak (MiB) | 592 / 678 | 914 / 972 |
| CPU during small-file download, mean / peak (% of one vCPU, all containers) | 386 / 396 | 329 / 347 |
| RAM during large-file download, median / peak (MiB) | 419 / 422 | 875 / 875 |
| CPU during large-file download, mean / peak (% of one vCPU, all containers) | 136 / 170 | 122 / 122 |
| RAM idle 30 s after the last download, median (MiB) | 420 | 970 |
docker stats, page cache excluded, summed over the stack's containers, samples about 2 s apart (docker stats --no-stream takes about 2 s per call), both during phases and at idle, so peaks are maxima of those samples. 100 % CPU is one vCPU. Samples pooled over the 3 reps; the 1 to 4 s large-file phases have 0 to 2 samples each.
| Server | Container | Idle median (MiB) | Run median (MiB) | Run peak (MiB) | Peak CPU (% of one vCPU) |
|---|---|---|---|---|---|
| Nextcloud 33.0.10 | app | 152 | 392 | 530 | 387 |
| Nextcloud 33.0.10 | db | 126 | 132 | 136 | 25 |
| Nextcloud 33.0.10 | redis | 3 | 7 | 13 | 7 |
| Seafile CE 14.0.9 | db | 128 | 136 | 138 | 59 |
| Seafile CE 14.0.9 | redis | 4 | 4 | 6 | 3 |
| Seafile CE 14.0.9 | seafile | 422 | 748 | 917 | 289 |
Whole-run samples include the idle gaps between phases, so the median is lower than the peaks.
- Idle before the test, Nextcloud’s three containers used a median of 282 MiB and Seafile’s 554 MiB. Peaks during the work were 673 MiB (Nextcloud, small-file upload) and 1,058 MiB (Seafile, same phase). The stack tables at the end show lower idle figures (255 and 468 MiB) from a separate run with a different settling time; the two were not reconciled.
- After the last download Seafile still held a median of 970 MiB and Nextcloud 420 MiB 30 s later, so neither handed memory back quickly. The test did not run long enough to say whether they would.
- Nextcloud’s app container reached 387 % CPU (almost 4 vCPUs) at its peak, mostly during the small-file phases, while Seafile’s main container peaked at 289 %. The large-file phases are 1 to 4 s long, so their RAM and CPU rows rest on 0 to 2 samples each:
docker stats --no-streamsamples are about 2 s apart, not 1 s, and every peak is the maximum of those samples.
| Server | Before | After rep 1 | After rep 2 | After rep 3 | Growth |
|---|---|---|---|---|---|
| Nextcloud 33.0.10 | 1,013 MB | 1,594 MB | 2,183 MB | 2,759 MB | 1,745 MB |
| Seafile CE 14.0.9 | 188 MB | 771 MB | 1,345 MB | 1,920 MB | 1,732 MB |
Logical data uploaded: 1,712 MB (3 datasets). Measured with du -sb over the stack's Docker volumes.
Both stacks grew by about the size of the data (1,745 MB for Nextcloud and 1,732 MB for Seafile, against 1,712 MB uploaded). The “before” figure is the empty install: 1,013 MB for Nextcloud (the application tree lives in a volume) and 188 MB for Seafile.


Nextcloud cache and cron settings in this test
The caching docs recommend APCu locally and Redis for distributed caching and file locking, and say a memcache is not strictly required. The compose file here sets REDIS_HOST, and occ config:system:get reported \OC\Memcache\APCu for memcache.local and \OC\Memcache\Redis for memcache.distributed and memcache.locking, with APCu and OPcache loaded and a PHP memory limit of 512M.
The background jobs docs call cron the recommended method, with cron.php run every 5 minutes, and describe AJAX as the default. This stack has no cron container, and occ config:app:get core backgroundjobs_mode returned no value, so the test ran with the default and no scheduled cron. A cron container would add some load on a real install that these numbers do not include.
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
services:
db:
image: mariadb:11.8
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_ROOT_PASSWORD: lab-only-not-a-secret
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: lab-only-not-a-secret
volumes:
- db:/var/lib/mysql
redis:
image: redis:7.4-alpine
restart: unless-stopped
app:
image: nextcloud:33.0.10-apache
restart: unless-stopped
depends_on: [db, redis]
ports:
- "127.0.0.1:8080:80"
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: lab-only-not-a-secret
REDIS_HOST: redis
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: lab-only-not-a-secret
NEXTCLOUD_TRUSTED_DOMAINS: "127.0.0.1 localhost"
volumes:
- nextcloud:/var/www/html
volumes:
db:
nextcloud:This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
services:
db:
image: mariadb:11.8
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: lab-only-not-a-secret
MYSQL_LOG_CONSOLE: "true"
MARIADB_AUTO_UPGRADE: "1"
volumes:
- db:/var/lib/mysql
redis:
image: redis:7.4-alpine
restart: unless-stopped
seafile:
image: seafileltd/seafile-mc:14.0.9
restart: unless-stopped
depends_on: [db, redis]
ports:
- "127.0.0.1:8080:80"
environment:
SEAFILE_MYSQL_DB_HOST: db
SEAFILE_MYSQL_DB_PORT: "3306"
SEAFILE_MYSQL_DB_USER: seafile
SEAFILE_MYSQL_DB_PASSWORD: lab-only-not-a-secret
INIT_SEAFILE_MYSQL_ROOT_PASSWORD: lab-only-not-a-secret
SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
TIME_ZONE: Etc/UTC
INIT_SEAFILE_ADMIN_EMAIL: admin@lab.internal
INIT_SEAFILE_ADMIN_PASSWORD: lab-only-not-a-secret
SEAFILE_SERVER_HOSTNAME: 127.0.0.1:8080
SEAFILE_SERVER_PROTOCOL: http
JWT_PRIVATE_KEY: lab-only-not-a-secret-lab-only-not-a-secret
REDIS_HOST: redis
REDIS_PORT: "6379"
CACHE_PROVIDER: redis
volumes:
- seafile:/shared
volumes:
db:
seafile:| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| Nextcloud | 33.0.10 | 0.55% | 2575.5 MB | 63.8 s | |
| Seafile CE | 14.0.9 | 0.96% | 2668.6 MB | 45.8 s |
Reproduce it
python3 gen.py # 3 random datasets in ~/data
python3 sync_bench.py nextcloud out.json nextcloud # WebDAV; then the same with: seafile out.json seafile
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. sync_bench.py nextcloud|seafile <out.json> <project>
Uploads the generated datasets (r1..r3) over the real HTTP API and downloads them again, timing each phase.
Nextcloud: WebDAV (MKCOL, PUT, GET) with basic auth. Seafile: /api2 auth token, library per rep, upload-api multipart POST, download link GET."""
import io, sys, os, json, time, hashlib, http.client, base64, urllib.parse, subprocess, threading, uuid
kind, outp, proj = sys.argv[1:4]; WORKERS = 4
HOST, PORT = "127.0.0.1", 8080; PW = "lab-only-not-a-secret"; root = os.path.expanduser("~/data"); man = json.load(open(f"{root}/manifest.json"))
def conn(host=HOST, port=PORT): return http.client.HTTPConnection(host, port, timeout=600)
def disk():
r = subprocess.run(f"sudo sh -c 'du -sb /var/lib/docker/volumes/{proj}_*' | awk '{{s+=$1}} END{{print s}}'", shell=True, capture_output=True, text=True); return int(r.stdout.strip())
class Reader: # streams prefix + file + suffix without loading the file
def __init__(s, pre, path, post): s.parts = [io.BytesIO(pre), open(path, "rb"), io.BytesIO(post)]; s.i = 0; s.len = len(pre) + os.path.getsize(path) + len(post)
def read(s, n=1 << 20):
while s.i < len(s.parts):
b = s.parts[s.i].read(n)
if b: return b
s.i += 1
return b""
class NC:
def __init__(s): s.auth = {"Authorization": "Basic " + base64.b64encode(f"admin:{PW}".encode()).decode()}; s.base = "/remote.php/dav/files/admin"
def prep(s, rep): c = conn(); c.request("MKCOL", f"{s.base}/r{rep}", headers=s.auth); r = c.getresponse(); r.read(); assert r.status in (201, 405), r.status; s.dirs = set()
def mkdirs(s, rep, dirs): # sequential MKCOL, counted in the timed upload phase
c = conn()
for p in ["small"] + [f"small/{d}" for d in dirs]:
c.request("MKCOL", f"{s.base}/r{rep}/{p}", headers=s.auth); r = c.getresponse(); r.read(); assert r.status in (201, 405), (p, r.status)
def put(s, c, rep, rel, path):
sz = os.path.getsize(path)
with open(path, "rb") as f:
c.request("PUT", f"{s.base}/r{rep}/{rel}", body=f, headers={**s.auth, "Content-Length": str(sz)}); r = c.getresponse(); r.read(); assert r.status in (201, 204), r.status
def get(s, c, rep, rel):
c.request("GET", f"{s.base}/r{rep}/{rel}", headers=s.auth); r = c.getresponse(); h = hashlib.sha256(); n = 0
assert r.status == 200, r.status
while True:
b = r.read(1 << 20)
if not b: break
h.update(b); n += len(b)
return h.hexdigest(), n
class SF:
def __init__(s):
c = conn(); c.request("POST", "/api2/auth-token/", urllib.parse.urlencode({"username": "admin@lab.internal", "password": PW}), {"Content-Type": "application/x-www-form-urlencoded"})
r = c.getresponse(); s.tok = json.loads(r.read())["token"]; s.auth = {"Authorization": "Token " + s.tok}; s.repos = {}
def api(s, method, path, body=None):
c = conn(); h = dict(s.auth)
if body: h["Content-Type"] = "application/x-www-form-urlencoded"; body = urllib.parse.urlencode(body)
c.request(method, path, body, h); r = c.getresponse(); t = r.read(); assert r.status < 300, (path, r.status, t[:200]); return json.loads(t) if t else None
def prep(s, rep):
r = s.api("POST", "/api2/repos/", {"name": f"r{rep}"}); s.repos[rep] = r["repo_id"]
def mkdirs(s, rep, dirs):
s.api("POST", f"/api2/repos/{s.repos[rep]}/dir/?p=/small", {"operation": "mkdir", "create_parents": "true"})
for d in dirs: s.api("POST", f"/api2/repos/{s.repos[rep]}/dir/?p=/small/{d}", {"operation": "mkdir"})
def link(s, rep): return urllib.parse.urlparse(s.api("GET", f"/api2/repos/{s.repos[rep]}/upload-link/?p=/")).path
def put(s, c, rep, rel, path):
d, name = os.path.split(rel); b = uuid.uuid4().hex; sz = os.path.getsize(path)
pre = (f'--{b}\r\nContent-Disposition: form-data; name="parent_dir"\r\n\r\n/\r\n--{b}\r\nContent-Disposition: form-data; name="relative_path"\r\n\r\n{d}\r\n'
f'--{b}\r\nContent-Disposition: form-data; name="replace"\r\n\r\n1\r\n--{b}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()
post = f"\r\n--{b}--\r\n".encode(); rd = Reader(pre, path, post)
c2 = conn(); c2.request("POST", s.link(rep) + "?ret-json=1", body=rd, headers={"Content-Type": f"multipart/form-data; boundary={b}", "Content-Length": str(rd.len)}); r = c2.getresponse(); t = r.read(); assert r.status == 200, (r.status, t[:200]); c2.close()
def get(s, c, rep, rel):
u = s.api("GET", f"/api2/repos/{s.repos[rep]}/file/?p=/{urllib.parse.quote(rel)}&reuse=1"); p = urllib.parse.urlparse(u).path
c2 = conn(); c2.request("GET", p); r = c2.getresponse(); h = hashlib.sha256(); n = 0; assert r.status == 200, r.status
while True:
b = r.read(1 << 20)
if not b: break
h.update(b); n += len(b)
c2.close(); return h.hexdigest(), n
srv = NC() if kind == "nextcloud" else SF()
def timed(fn):
t0 = time.time(); p0 = time.perf_counter(); err = fn(); return {"t_start": t0, "t_end": time.time(), "secs": time.perf_counter() - p0, "errors": err}
def workers(items, fn):
errs = [0]; lk = threading.Lock(); it = iter(items)
def w():
c = conn()
while True:
with lk: x = next(it, None)
if x is None: return
try: fn(c, x)
except Exception as e:
errs[0] += 1; print("ERR", x, repr(e)[:200], file=sys.stderr)
c = conn()
th = [threading.Thread(target=w) for _ in range(WORKERS)]; [t.start() for t in th]; [t.join() for t in th]; return errs[0]
out = {"kind": kind, "workers_small": WORKERS, "reps": [], "disk_before": disk(), "t0": time.time()}
for rep in range(1, int(os.environ.get("REPS", 3)) + 1):
m = man[f"r{rep}"]; res = {"rep": rep, "small_files": len(m["small"]), "small_bytes": m["small_bytes"], "large_bytes": m["large"][1]}
srv.prep(rep)
dirs = sorted({os.path.basename(os.path.dirname(x[0])) for x in m["small"]})
def up_small(): srv.mkdirs(rep, dirs); return workers(m["small"], lambda c, x: srv.put(c, rep, x[0], f"{root}/r{rep}/{x[0]}"))
res["up_small"] = timed(up_small)
res["up_large"] = timed(lambda: (srv.put(conn(), rep, "large.bin", f"{root}/r{rep}/large.bin"), 0)[1])
res["disk_after_upload"] = disk()
bad = [0]
def dl_small():
def f(c, x):
h, n = srv.get(c, rep, x[0])
if h != x[2] or n != x[1]: bad[0] += 1
return workers(m["small"], f)
res["dl_small"] = timed(dl_small); res["dl_small"]["hash_mismatches"] = bad[0]
def dl_large():
h, n = srv.get(conn(), rep, "large.bin"); return 0 if (h == m["large"][2] and n == m["large"][1]) else 1
res["dl_large"] = timed(dl_large)
out["reps"].append(res); print("rep", rep, {k: (round(v["secs"], 1) if isinstance(v, dict) else v) for k, v in res.items() if k.startswith(("up", "dl"))}, flush=True)
json.dump(out, open(outp, "w"), indent=1)
out["t_end"] = time.time(); json.dump(out, open(outp, "w"), indent=1)What went wrong
The first screenshot attempts for Nextcloud were blocked by welcome dialogs. The Nextcloud picture comes from a separate short run (shot.sh) that uploads only the first dataset and disables the firstrunwizard app first; nothing else about that install differs from the measured one. Parallel MKCOL calls for the same folder returned HTTP 423 in a trial run, so folders are now created one by one before the workers start, and that sequential step is inside the timed upload.
What this test did not cover
Sources and how this was made
Show the full file
#!/usr/bin/env bash
# file-sync: Nextcloud 33 vs Seafile CE 14 on the same VM, one after the other. Generated data over the real HTTP APIs
# (WebDAV for Nextcloud, /api2 + upload-api for Seafile): 3 datasets of 1,000 small files + one 512 MiB file each.
set -uo pipefail
source "$(dirname "$0")/../_e/lib.sh"
T=file-sync; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"
rm -rf "$OUT"; mkdir -p "$RAW" "$ROOT/public/shots/$T"
start_vm e-$T || exit 1
collect_env "$RAW/.."
vm scp-to "$D/gen.py" gen.py; vm scp-to "$D/sync_bench.py" sync_bench.py; vm scp-to "$ROOT/lab/tests/_e/sampler.py" sampler.py
gx 'python3 gen.py' | tee "$RAW/dataset.json"
gx 'cd ~/data && sha256sum manifest.json | cut -c1-16; du -sb ~/data | cut -f1' > "$RAW/dataset-size.txt"
for K in nextcloud seafile; do
echo "=== $K"
gx "mkdir -p ~/$K"; vm scp-to "$ROOT/lab/stacks/$K/compose.yml" "$K/compose.yml"
gx "cd ~/$K && docker compose -p $K pull -q 2>&1 | tail -1; docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' | sort" > "$RAW/$K-images.txt" 2>&1
S=$(now_ms)
gx "cd ~/$K && docker compose -p $K up -d 2>&1 | tail -1"
if [ $K = nextcloud ]; then RDY='curl -fs http://127.0.0.1:8080/status.php | grep -q "\"installed\":true"'; else RDY='[ "$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/accounts/login/)" = 200 ] && curl -s http://127.0.0.1:8080/api2/ping/ | grep -q pong'; fi
gx "for i in \$(seq 1 300); do $RDY && exit 0; sleep 2; done; exit 1"; echo "ready_ms=$(( $(now_ms) - S ))" > "$RAW/$K-ready.txt"
gx 'sleep 60'
if [ $K = nextcloud ]; then
gx 'cd ~/nextcloud && docker compose -p nextcloud exec -T -u www-data app php occ config:system:get memcache.local; docker compose -p nextcloud exec -T -u www-data app php occ config:system:get memcache.distributed; docker compose -p nextcloud exec -T -u www-data app php occ config:system:get memcache.locking; docker compose -p nextcloud exec -T -u www-data app php occ config:app:get core backgroundjobs_mode; docker compose -p nextcloud exec -T -u www-data app php occ status; docker compose -p nextcloud exec -T app php -r "echo \"apcu:\".(extension_loaded(\"apcu\")?\"loaded\":\"missing\").\" opcache:\".(extension_loaded(\"Zend OPcache\")?\"loaded\":\"missing\").\" memory_limit:\".ini_get(\"memory_limit\").\"\n\";"' > "$RAW/nextcloud-config.txt" 2>&1
fi
gx "timeout 40 python3 sampler.py /tmp/idle.csv 2 || true; cat /tmp/idle.csv" > "$RAW/$K-mem-idle-before.csv"
gx 'nohup python3 sampler.py /tmp/up.csv 1 >/dev/null 2>&1 & echo $! > /tmp/sampler.pid'
gx "python3 sync_bench.py $K /tmp/$K.json $K" 2>&1 | tee "$RAW/$K-bench.log"
gx 'kill $(cat /tmp/sampler.pid); sleep 2; cat /tmp/up.csv' > "$RAW/$K-mem-during.csv"; gx "cat /tmp/$K.json" > "$RAW/$K-bench.json"
gx "sleep 30; timeout 40 python3 sampler.py /tmp/idle2.csv 2 || true; cat /tmp/idle2.csv" > "$RAW/$K-mem-idle-after.csv"
gx "sudo sh -c 'du -sb /var/lib/docker/volumes/${K}_*'" > "$RAW/$K-volumes-after.txt"
tunnel 18092 8080
( cd "$D" && node shot.mjs $K http://127.0.0.1:18092 "$ROOT/public/shots/$T/$K.png" ) > "$RAW/$K-shot.txt" 2>&1
untunnel
gx "cd ~/$K && docker compose -p $K down -v >/dev/null 2>&1; docker volume prune -f >/dev/null; true"
done
python3 "$D/summarize.py" "$RAW" "$OUT"How this page was made: the commands above were run by an AI agent on the test box on November 1, 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.