Self-hosted internet speed test, distributed as a single ~25 MB Docker image.
Browser-side measurements use the open-source
@cloudflare/speedtest library
against an HTTP server in this repo. History is persisted to SQLite with
shareable per-test URLs (UUIDv7).
Measures download throughput, upload throughput, idle latency, jitter, and loaded latency.
The image is published to the Forgejo container registry at
git.fiber.house/seitz/myspeed. If the package is private, log in once
with a Forgejo access token (Settings → Applications → Generate New
Token, scope: read:package):
echo $FORGEJO_TOKEN | docker login git.fiber.house -u seitz --password-stdinThen with compose (recommended — uses a named Docker volume so the
distroless nonroot user can write without any chown ceremony):
docker compose up -dOr with docker run and a named volume:
docker run -d --restart unless-stopped \
-p 8080:8080 \
-v speedtest-data:/data \
git.fiber.house/seitz/myspeed:latestOpen http://localhost:8080.
Want a bind mount instead? Use
-v $PWD/data:/dataand pre-chown the host directory:mkdir -p data && sudo chown 65532:65532 data. The image runs as UID 65532 (distrolessnonroot) and a host-owned bind mount inherits the host's permissions, so the container can't write to it without that one-time chown.
All config is via environment variables; defaults are sane for a homelab.
| Var | Default | Purpose |
|---|---|---|
SPEEDTEST_LISTEN |
:8080 |
Bind address |
SPEEDTEST_DB_PATH |
/data/speedtest.db |
SQLite file path |
SPEEDTEST_MAX_DOWN_BYTES |
1073741824 |
Per-request download cap (1 GiB) |
SPEEDTEST_MAX_UP_BYTES |
1073741824 |
Per-request upload cap (1 GiB) |
SPEEDTEST_IGNORE_FORWARDED |
false |
Set true only when binary is directly internet-exposed |
SPEEDTEST_ADMIN_TOKEN |
(unset) | If set, requests with Authorization: Bearer <token> see client_ip in API responses |
SPEEDTEST_LOG_LEVEL |
info |
debug/info/warn/error |
Behind a reverse proxy (Caddy/Traefik/nginx in homelab): default config is
correct. The leftmost X-Forwarded-For value becomes the recorded client IP.
Direct internet exposure (no proxy): set
SPEEDTEST_IGNORE_FORWARDED=true so callers can't spoof their IP via
X-Forwarded-For.
Privacy: API responses omit client_ip by default. Only requests
bearing the admin bearer token see IPs. This protects visitor IPs from
casually leaking through shareable result URLs.
GET /— SPAGET /__down?bytes=N— streams N zero bytesPOST /__up— accepts and discards bodyGET /__ping— 204 withServer-Timing: cfRequestDuration;dur=0GET /__meta— JSON{clientIp, hostname, version}for the SPAGET /__trace—text/plaincf-trace format for@cloudflare/speedtestGET /healthz— DB-aware livenessPOST /api/results— submit a finished test summaryGET /api/results?limit=N&since=RFC3339— list, newest firstGET /api/results/:public_id— single testDELETE /api/results/:public_id— deleteGET /api/results.csv— CSV export
# Frontend dev server (proxies to backend on :8080):
cd web && pnpm install && pnpm dev
# In a second terminal:
cd web && pnpm build && cp -r dist ../internal/server/dist
SPEEDTEST_DB_PATH=/tmp/speedtest-dev.db go run ./cmd/speedtestOr just rebuild the Docker image: docker compose up -d --build.
See docs/superpowers/specs/2026-05-01-speedtest-clone-design.md.
MIT — see LICENSE.
This project would not exist without @cloudflare/speedtest (MIT, © Cloudflare),
which provides the in-browser measurement engine. We use it as a library
dependency; we do not include any Cloudflare branding.