Repository navigation
Configuration
Two places: config/speedtest.toml for everything, and environment variables
that override it. Environment always wins, so a container can be retuned
without rebuilding an image.
Unknown keys in the TOML file are a hard error rather than being ignored —
a typo must not leave you believing a setting took effect. The one exception is
site_name: a blank value falls back to the default rather than rendering an
empty heading, because refusing to boot over a cosmetic string would be worse
than the mistake.
A profile decides which stages run, how large each transfer is, and how the
engine interprets the results. Select one with profile = "..." or
SPEEDTEST_PROFILE.
| Profile | For |
|---|---|
lan-1g |
1 GbE clients |
lan-2.5g |
2.5 GbE clients |
lan-10g |
10 GbE clients |
quick |
Fast smoke test; used by the e2e suite |
e2e-packetloss |
Packet-loss e2e, paired with docker-compose.e2e.yml
|
Measurement entries mirror the engine's own MeasurementConfig exactly,
camelCase included, so they can be read against its documentation:
measurements = [
{ type = "latency", numPackets = 20 },
{ type = "download", bytes = 25000000, count = 4 },
{ type = "upload", bytes = 25000000, count = 4 },
{ type = "packetLoss", numPackets = 1000, batchSize = 10,
batchWaitTime = 10, responsesWaitTime = 3000 },
]Four engine behaviours constrain the numbers, and all four are easy to get wrong in a way that produces plausible but wrong output.
1. Every listed stage will run. The engine stops issuing further rounds of
a direction once one request exceeds bandwidthFinishRequestDuration
(1000 ms). Almost nothing on a LAN reaches that, so the whole list executes.
Keep lists short and transfers large.
2. Transfers must be slow enough to count as "load".
loaded_request_min_duration filters which transfer sizes contribute
loaded-latency samples. The engine's default is 250 ms. A 250 MB download at
10 Gbps takes 200 ms — under the default, nothing qualifies, loaded latency
stays 0, and because 0 is falsy the engine then returns no quality ratings at
all. There is no error; the section is simply empty.
Set it below the duration of the profile's smallest non-warm-up transfer — in both directions. A unit test checks this against each shipped profile's nominal link speed and fails the build if a profile drifts. It has already caught a real mistake.
3. Pings need room inside a transfer. loaded_latency_throttle is the
minimum gap between loaded-latency pings; the engine's 400 ms default yields at
most one sample inside a short LAN transfer, and jitter needs two.
4. Do not go above ~250 MB. The engine reads every response body through
r.text(), decoding the whole payload into a JavaScript string. Past roughly
250 MB that costs more than it buys.
| Key | Meaning |
|---|---|
description |
Shown in the profile picker and the page footer |
measurements |
The stage list (above) |
estimated_server_time |
Fallback when a response carries no usable server-timing. 0.0 is right for this backend |
measure_download_loaded_latency |
Measure latency during download |
measure_upload_loaded_latency |
Measure latency during upload |
loaded_request_min_duration |
See sizing note 2 |
loaded_latency_throttle |
See sizing note 3 |
nominal_bps |
The link speed these transfer sizes were chosen for |
auto_selectable |
Whether Auto in the picker may choose this profile |
profile (or SPEEDTEST_PROFILE) is the default, not a fixed
setting for everyone. A picker in the page lets a client choose another; the
choice is remembered per browser and recorded with every stored run, so
mixed-profile history stays honest.
Auto measures the link with one short transfer and then picks the largest
auto_selectable profile whose nominal_bps is within a factor of two of what
it measured. The factor of two is deliberate: a single stream reaches only part
of a fast link, so requiring the full nominal rate would always choose the
smaller profile.
This matters more than it looks. The profiles differ in transfer size, and a size chosen for the wrong link measures the wrong thing — 25 MB on 10 GbE finishes in about 20 ms, which is mostly request overhead rather than throughput.
Mark only real link profiles auto_selectable. quick and e2e-packetloss
are sized for loopback and would badly under-measure any real client.
Changing the profile does not start a run. It clears the figures on screen — they were measured under the old profile, and leaving them under the new label would misattribute them — and waits for Retest.
All under [server].
| Key | Default | Meaning |
|---|---|---|
site_name |
LAN Speed Test |
Page heading and browser tab title |
bind |
0.0.0.0:8080 |
Plain-HTTP listen address |
max_transfer_bytes |
2 GiB | Hard per-request ceiling. Startup fails if a profile exceeds it |
download_chunk_bytes |
4 MiB | Shared payload buffer, sliced per request |
static_dir |
static |
Built front-end assets |
tls_bind |
0.0.0.0:443 |
HTTPS listen address |
tls_cert_file |
unset | PEM chain. Setting exactly one of the pair is a startup error |
tls_key_file |
unset | PEM private key |
history_db |
data/history.db |
SQLite file. Empty disables history entirely |
history_backup_dir |
"" |
Where the daily pass leaves a snapshot of the history database. Empty takes none |
autostart |
true |
Measure as soon as the page loads |
metrics |
false |
Serve /metrics — see History and Metrics
|
retain_runs_days |
0 |
Delete runs older than this; 0 keeps forever |
retain_samples_days |
0 |
Drop the sample blob, keep the run; 0 keeps forever |
trusted_proxies |
[] |
CIDR blocks whose X-Forwarded-For may be believed. Empty means the connection's peer decides, which is the right default with no proxy in front |
Off by default. See Client Identity for what it does, what it deliberately will not do, and why the range restriction is not optional.
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Look up client hostnames at all |
resolver |
"" |
host:port. Empty reads the first nameserver from /etc/resolv.conf
|
ranges |
RFC 1918, 100.64.0.0/10, fc00::/7
|
The only addresses ever looked up |
timeout_ms |
500 |
Per-query timeout |
ttl_secs |
21600 |
How long a name — or a remembered miss — is trusted |
Every CIDR and the resolver address are parsed at startup, so a typo is a refusal to boot rather than a feature that silently never works.
The page measures as soon as it loads. That is what makes it useful as a bookmark, and it is on by default.
It is also several hundred megabytes, which is the last thing you want during the video call that made you suspicious of the network. So it can be turned off, three ways, most specific first:
| How | Scope |
|---|---|
?autostart=0 on the URL |
This visit only, never remembered |
| The Auto-start toggle beside Retest | This browser, remembered |
server.autostart / SPEEDTEST_AUTOSTART
|
The deployment's default |
The URL override is deliberately not remembered: a link someone sends you should not silently reconfigure your browser.
SPEEDTEST_AUTOSTART reads negatives as well as 0 — off, false and no
all work, because "off" is a more natural thing to write for a switch called
autostart, and silently ignoring it would leave the deployment measuring on
every page load while the operator believed otherwise.
[turn]
enabled = false
uri = "" # host:port, no scheme
user = ""
pass = "" # supply via SPEEDTEST_TURN_PASSWhen enabled is false the packet-loss stage is stripped from the profile sent
to the browser, so the engine does not stall waiting on a relay that is not
there. When true, all three of uri/user/pass must be set — startup fails
otherwise, because a half-configured relay makes the engine fetch credentials
from Cloudflare instead.
See TURN and Packet Loss.
| Variable | Overrides |
|---|---|
SPEEDTEST_CONFIG |
Path to the TOML file |
SPEEDTEST_PROFILE |
profile |
SPEEDTEST_SITE_NAME |
server.site_name |
SPEEDTEST_BIND |
server.bind |
SPEEDTEST_STATIC_DIR |
server.static_dir |
SPEEDTEST_TLS_BIND |
server.tls_bind |
SPEEDTEST_TLS_CERT_FILE |
server.tls_cert_file |
SPEEDTEST_TLS_KEY_FILE |
server.tls_key_file |
SPEEDTEST_HISTORY_DB |
server.history_db; an empty value disables history |
SPEEDTEST_HISTORY_BACKUP_DIR |
server.history_backup_dir; an empty value turns snapshots off |
SPEEDTEST_AUTOSTART |
server.autostart |
SPEEDTEST_METRICS |
server.metrics |
SPEEDTEST_RETAIN_RUNS_DAYS |
server.retain_runs_days |
SPEEDTEST_RETAIN_SAMPLES_DAYS |
server.retain_samples_days |
SPEEDTEST_TRUSTED_PROXIES |
server.trusted_proxies (comma-separated) |
SPEEDTEST_REVERSE_DNS |
server.reverse_dns.enabled (1/true/yes) |
SPEEDTEST_DNS_RESOLVER |
server.reverse_dns.resolver |
SPEEDTEST_TURN_ENABLED |
turn.enabled |
SPEEDTEST_TURN_URI |
turn.uri |
SPEEDTEST_TURN_USER |
turn.user |
SPEEDTEST_TURN_PASS |
turn.pass |
SPEEDTEST_LOG |
Log filter (info, debug, …) |
The heading and tab title come from the server, not from the built bundle, so one image can serve several installations and renaming one needs a restart rather than a rebuild. Two of these on the same LAN are otherwise indistinguishable in a browser tab.
echo 'SPEEDTEST_SITE_NAME=Rack Room Speed Test' >> .env
docker compose up -dThe name goes into a compose env_file, which has no quoting, so keep it to
one line and avoid #.
Both windows are off by default (0 = keep forever). Quietly deleting a
homelab's measurement history because a default said so is not a behaviour
anyone should have to discover and opt out of. See
History and Metrics for what the two
windows do and why there are two.
Off by default. Name a directory and the daily maintenance pass leaves a
consistent copy of the history database there as history-backup.db:
[server]
history_backup_dir = "/srv/speedtest-backups"Point it somewhere the service can write, and not at the directory holding
the database itself. Two misconfigurations refuse to start rather than
silently taking no backups: a destination where the snapshot would be written
over the live database, and a snapshot directory named while history_db is
empty — history off, nothing to copy. The directory is created at startup, so a
path that cannot be created is a refusal to boot rather than a failure
discovered at the first prune, a day later.
If the database lives in a mounted volume, the snapshot directory needs its own mount, or the copy lands inside the container and disappears with it. See History and Metrics.
See also: Quick Start · Engine Contract · History and Metrics · Client Identity · Troubleshooting
Getting it running
Using it
Working on it