Self-Hosted Internet Speed Monitor: Track Your ISP Without a Third-Party Service
Run Speedtest Tracker in Docker to log your real download, upload, and latency every hour, then pair it with Uptime Kuma so you have proof when your ISP says the line is fine.
Your internet feels slow at 8pm. You run a browser speed test, it comes back fine, and by the time you call support the problem is gone. The support script asks you to reboot the router, you do, nothing changes, and the ticket closes.
The fix is not a better speed test. It is a continuous one. A small container that measures download, upload, and latency on a schedule, stores every result, and lets you point at three weeks of data instead of one screenshot. It costs nothing to run beyond the hardware you already have, and it takes about 45 minutes to set up. If Docker is new to you, start with Docker Compose for Beginners and come back. Everything else in my monitoring stack is listed on the stack page.
This guide includes Amazon affiliate links. If you buy through them the site earns a commission at no extra cost to you.
What you are building
Three pieces, and only the first is mandatory:
Speedtest Tracker runs an Ookla speed test on a cron schedule, stores the result in SQLite, and charts the history. This is the core.
Uptime Kuma covers what a speed test cannot: short drops. A 90 second outage at 3am will never land inside an hourly speed test, but a ping monitor at 60 second intervals catches it. If you do not have it running yet, the Uptime Kuma setup guide takes about ten minutes.
LibreSpeed is optional and answers a different question: is the slow part your ISP or your own network? It runs a speed test server inside your LAN so you can measure machine to machine.
Pick the host first
This is the step people skip, and it is the one that decides whether your data is worth anything.
The host must be wired. A speed test from a Wi-Fi machine measures your Wi-Fi. If your access point negotiates 400Mbps on a 500Mbps line, every result you log is capped by the radio and you will spend a month arguing with your ISP about a problem inside your own house.
The host needs enough CPU to saturate the line. Modern speed tests are CPU bound at gigabit speeds. A Raspberry Pi 4 will struggle past roughly 700Mbps. An N100 mini PC handles gigabit without breaking a sweat.
If you already have an always-on Docker host plugged into your switch, use it. If you are building a dedicated one:
- A Raspberry Pi 5 (8GB) is fine for connections up to about 500Mbps and idles under 5W.
- A Beelink S12 Pro (N100, 16GB) handles gigabit comfortably and gives you room to run the rest of your monitoring stack on the same box.
- Whatever you pick, run a real Cat6 patch cable to the router or switch. Cat5e is fine for gigabit, but if you are already buying cable there is no reason not to buy the one that survives a 2.5GbE upgrade.
I run mine on the same N100 box that hosts Uptime Kuma and Grafana. One machine, one Compose file, one place to look.
Step 1: Create the directory and generate the app key
mkdir -p /opt/speedtest/config
cd /opt/speedtest
Speedtest Tracker is a Laravel application and needs an encryption key. Generate one:
echo "base64:$(openssl rand -base64 32)"
Copy the entire output, including the base64: prefix. If you change this key later, the container loses access to its stored settings and you will be reconfiguring notifications from scratch.
Step 2: Write the Compose file
Create /opt/speedtest/docker-compose.yml:
services:
speedtest-tracker:
image: ghcr.io/alexjustesen/speedtest-tracker:latest
container_name: speedtest-tracker
restart: unless-stopped
ports:
- "8765:80"
environment:
PUID: 1000
PGID: 1000
TZ: America/New_York
APP_KEY: "base64:PASTE_YOUR_KEY_HERE"
APP_URL: "http://192.168.1.50:8765"
DB_CONNECTION: sqlite
SPEEDTEST_SCHEDULE: "0 * * * *"
PRUNE_RESULTS_OLDER_THAN: 90
volumes:
- ./config:/config
A few of these matter more than the rest.
SPEEDTEST_SCHEDULE: "0 * * * *" runs a test at the top of every hour. That is the right cadence for your first two weeks, when you are trying to establish whether the problem is real and when it happens. After that, drop to "0 */6 * * *" unless you are actively building a case.
PRUNE_RESULTS_OLDER_THAN: 90 deletes results older than 90 days. Without it the SQLite database grows forever. Ninety days is enough history to show a seasonal pattern and small enough to back up casually.
APP_URL should match however you actually reach the dashboard. If you put it behind a reverse proxy later, update this to the HTTPS URL and restart, or the charts will load their assets from the wrong origin.
TZ is not cosmetic here. Your schedule fires in this timezone and your chart timestamps are labeled in it. Getting this wrong is how you end up telling support the problem happens at 3am when it actually happens at 10pm.
Step 3: Start it and run a first test
cd /opt/speedtest
docker compose up -d
docker compose logs -f speedtest-tracker
First start takes 30 to 60 seconds while it builds the SQLite schema. Then open http://YOUR_SERVER_IP:8765.
Default login is admin@example.com with password password. Change it immediately under the user menu. Yes, it is a local dashboard. Change it anyway.
Click Run Speedtest in the UI and watch the result land. If it fails, the usual cause is that the container cannot resolve DNS. Check with:
docker exec speedtest-tracker ping -c 2 speedtest.net
If that fails but the host can ping fine, your Docker daemon DNS is misconfigured. This is common on hosts running Pi-hole or AdGuard Home where the container inherits a resolver it cannot reach.
Step 4: Pin a fixed test server
This is the difference between a dataset and a pile of numbers.
By default, Speedtest Tracker picks the “best” server for each run. That means a result at 2pm and a result at 3pm may have hit different endpoints in different cities, and a 200Mbps swing tells you nothing about your ISP.
Find a nearby server ID from the dashboard’s server selector, or query the list directly:
docker exec speedtest-tracker /usr/bin/speedtest --servers
Then pin it in your Compose file:
SPEEDTEST_SERVERS: "12345"
Restart with docker compose up -d. Every test from now on hits the same endpoint.
Pick a server that is close but not owned by your own ISP if you can avoid it. ISP-hosted test servers frequently measure the path to your ISP’s edge rather than the path to the internet, which is exactly the segment that is not the problem.
You can list two or three IDs separated by commas as a fallback. It will use the first reachable one, which protects you against a single server going offline and quietly zeroing out a week of data.
Step 5: Set up notifications
A monitor you have to remember to look at is a monitor you will stop looking at.
Speedtest Tracker supports threshold alerts. In the dashboard, go to Settings > Notifications, add a channel, and set absolute thresholds under Settings > Thresholds:
- Download below 60% of your plan speed
- Upload below 60% of your plan speed
- Ping above 60ms
Set them loose to start. If you set the download threshold at 95% of your advertised speed you will get an alert every night, learn to ignore the channel, and miss the real event when it happens.
Email is the simplest channel and works with the same free SMTP setup used everywhere else in the lab. The homelab email notifications guide covers generating a Gmail app password and pointing services at it, and the same credentials work here.
Step 6: Catch the short outages with Uptime Kuma
Hourly speed tests miss short drops entirely. Add a ping monitor to cover the gap.
In Uptime Kuma, create a new monitor:
- Monitor Type: Ping
- Hostname:
1.1.1.1 - Heartbeat Interval: 60 seconds
- Retries: 2
Then add a second one against 8.8.8.8. Two targets on different networks means a single provider having a bad day does not read as your line going down.
Now you have both halves of the picture: Uptime Kuma tells you the connection dropped for four minutes at 2:47am, and Speedtest Tracker tells you throughput was already degraded for the two hours before it. That correlation is the thing that makes a support ticket land. For alerting rules that do not turn into noise, the advanced Uptime Kuma alerting guide covers grouping and maintenance windows.
Step 7: Measure your own LAN separately
Before you blame the ISP, rule out your own network. Add LibreSpeed as a second service in the same Compose file:
librespeed:
image: ghcr.io/librespeed/speedtest:latest
container_name: librespeed
restart: unless-stopped
ports:
- "8766:8080"
environment:
MODE: standalone
TELEMETRY: "false"
Open http://YOUR_SERVER_IP:8766 from a laptop on the same network and run a test. You are now measuring the path between two machines inside your house, with no ISP involved.
What the result tells you:
| LibreSpeed result | Speedtest Tracker result | Where the problem is |
|---|---|---|
| Near line rate (900Mbps+) | Slow | Your ISP or the modem |
| Slow on Wi-Fi, fast wired | Slow | Your access point or radio congestion |
| Slow even wired | Slow | Cabling, a bad switch port, or a duplex mismatch |
| Near line rate | Near plan speed | Nothing is wrong right now, keep collecting |
The third row is worth dwelling on. A single crimped Cat5e run behind a wall plate will cap a machine at 100Mbps and look exactly like an ISP problem from the inside. LibreSpeed finds that in 30 seconds.
Step 8: Expose it without opening a port
Do not port forward this. It is an unauthenticated-by-default dashboard with a known login, and there is no reason for it to be on the public internet.
Two sane options:
Reverse proxy on your LAN or via Tailscale. Add a proxy host in Nginx Proxy Manager pointing at YOUR_SERVER_IP:8765, request a certificate, and reach it at speedtest.yourdomain.com. The NPM setup guide walks through this, and the HTTPS with Let’s Encrypt guide covers the certificate side.
Tailscale only. Install Tailscale on the host and reach the dashboard at the tailnet address from anywhere. No certificate, no proxy, no exposed surface. The Tailscale setup guide covers it, and the reasoning behind not forwarding ports at all is in why I don’t open ports.
If you update APP_URL to the new HTTPS address, restart the container so the dashboard generates the right asset URLs.
What this actually costs
| Item | Cost |
|---|---|
| Speedtest Tracker | Free, open source |
| LibreSpeed | Free, open source |
| Uptime Kuma | Free, open source |
| Power (running on an existing host) | $0 additional |
| Power (dedicated Pi 5, ~5W idle) | Roughly $5 to $7 per year |
| Bandwidth (hourly gigabit tests) | 400GB to 700GB per month |
That last row is the only one that can bite you. On an uncapped fiber or cable plan it is irrelevant. On a 1.2TB cap, hourly gigabit testing burns a third to a half of your monthly allowance doing nothing useful. Drop to every six hours and it falls under 100GB.
Reading the data
After two weeks you will have something worth interpreting.
A flat line at your plan speed with occasional dips is a healthy connection. Ignore single bad results. Every speed test occasionally hits a congested server.
A daily pattern is the most common real finding. Throughput drops between 7pm and 11pm and recovers overnight. That is node congestion in your neighborhood, it is a real problem, and it is one your ISP can fix by splitting the node. It is also the one they will not act on without evidence.
A step change on a specific date usually means something changed on their end: a firmware push, a profile change, a plan migration. Being able to name the date is what turns “it feels slower” into a ticket someone works.
High and rising latency with normal throughput is bufferbloat, not a speed problem, and no amount of complaining will fix it. That one is solved with SQM on your router.
When you file the ticket, export a CSV from the dashboard and attach it. Lead with the pattern, not the worst single result: “hourly tests from a wired host against server 12345 show download averaging 180Mbps between 7pm and 11pm against a 500Mbps plan, over 21 consecutive days.” That sentence gets a technician dispatched. A screenshot does not.
Keeping it running
Speedtest Tracker updates regularly. Pull and restart:
cd /opt/speedtest
docker compose pull
docker compose up -d
If you already run automated updates, this container is a safe candidate to include. The homelab automation guide covers scheduled updates alongside backups and monitoring.
Back up /opt/speedtest/config. That directory holds the SQLite database with all your history, and history is the entire point of the exercise. Losing it means starting your evidence gathering from zero on the day you actually need it.
The whole setup is one Compose file and a wired Ethernet cable. What you get out of it is the ability to answer “is my internet actually bad, or does it just feel that way” with a chart instead of a shrug. That answer is worth 45 minutes, and it is worth considerably more than that the first time it saves you from paying for a plan upgrade that would not have fixed anything.