Commits
Co-authored-by: thehale <47901316+thehale@users.noreply.github.com>
The container was failing to start on Ubuntu with Permission denied when
trying to launch crond, because the container runs as the nginx user
(non-root) and dcron requires root privileges.
Key Decisions:
- Supercronic over a sleep loop to preserve full cron expression support
- Supercronic over sudo because sudo defeats non-root security model
- Grouped crontab setup with supercronic startup for better code organization
Supercronic was chosen because it's designed specifically for containers,
runs without root, handles signals gracefully, and adds only ~2MB to image.
Fix 'installation not allowed to Create organization package' error
by granting the workflow explicit permission to write packages.
The GITHUB_TOKEN requires explicit permissions to push to GHCR.
Adding 'packages: write' at the job level allows the workflow to
authenticate and push Docker images to the GitHub Container Registry.
Update remaining short docker options to full-length equivalents:
- docker build -t -> --tag
- docker logs -f -> --follow
- docker-compose logs -f -> --follow
- -v -> --volume (in Data Persistence section)
All docker commands in documentation now use full-length options
for better clarity and consistency.
Documentation changes:
- Change docker run short options to full-length equivalents
- detach, publish, volume, env flags now use full names
- Update version tag format in docs
Docker Compose:
- Remove deprecated version declaration
- Remove redundant HOME environment variable
- Add badges for Docker pulls, license, and social links
- Update image size from ~25MB to ~35MB (more accurate)
- Add inline docker-compose.yml example instead of referencing file
- Point to thehale/speedtest Docker Hub image
- Add Available Images section documenting Docker Hub and GHCR
- Rename Building section to Building from Source
Restructure script to use a publish_to_registry function that accepts
registry, repo, and tag as parameters. Add command-line options to
publish selectively:
Usage:
bin/publish.sh # Publish to both registries (default)
bin/publish.sh --docker # Publish only to Docker Hub
bin/publish.sh --github # Publish only to GitHub Container Registry
bin/publish.sh v1.0.0 # Publish specific tag to both
This allows local testing against a single registry without needing
credentials for both. CI workflow continues to publish to both.
Remove explicit tag extraction step since publish.sh already detects
the current git tag using git describe. The workflow triggers on tag
pushes, so the checkout is already at the tag.
Simpler workflow, same functionality.
Update GitHub Actions workflow to use docker login commands directly
(instead of login-action) for consistency with local development.
Changes:
- Use shell-based docker login (echo token | docker login)
- Invoke bin/publish.sh instead of inline commands
- Consistent behavior between CI and local usage
Now both CI and local use identical authentication and publishing logic.
Create bin/publish.sh script and GitHub Actions workflow to automatically
publish Docker images when git tags are pushed.
Changes:
- bin/publish.sh: Local publishing script for Docker Hub and GHCR
- .github/workflows/docker-image.yml: GitHub Actions workflow
- Triggers on git tag pushes
- Publishes both 'latest' and version tags
- Pushes to both Docker Hub and GitHub Container Registry
Publishing workflow:
1. Create and push a git tag: git tag v1.0.0 && git push origin v1.0.0
2. GitHub Actions automatically builds and publishes
3. Images available at:
- docker.io/thehale/speedtest:v1.0.0
- docker.io/thehale/speedtest:latest
- ghcr.io/thehale/speedtest:v1.0.0
- ghcr.io/thehale/speedtest:latest
Reduce frequency from every 30 minutes to every 5 hours to avoid
excessive speedtest consumption and provide more reasonable default
for long-term monitoring.
Changes:
- .env.example: */30 * * * * → 0 */5 * * *
- docker-compose.yml: */30 * * * * → 0 */5 * * *
- README.md: Update examples and add every 5 hours to list
- AGENTS.md: Update default value in docs
Users who want more frequent testing can still customize via CRON_SCHEDULE.
- Add Mozilla Public License 2.0
- Add crontab.guru link for cron expression help
- Add link to Ookla Speedtest CLI website
- Remove outdated Python image comparison from architecture
Prevent speedtest result files (CSV) from being committed to git.
Create bin/ci.sh to automate pre-commit verification:
- Builds Docker image
- Starts test container
- Verifies nginx responds
- Checks HTML content
- Validates dark theme support
- Checks for errors in logs
- Automatically cleans up test resources
Create AGENTS.md with development workflow instructions:
- Mandatory CI script execution before commits
- Project architecture overview
- Common troubleshooting tips
- Environment variable reference
- Testing procedures
This ensures no broken commits reach the repository.
The nginx-unprivileged image runs as nginx user (uid 101), causing
permission issues when trying to install packages or write to /home.
Key fixes:
- Add USER root before package installation RUN commands
- Change HOME from /home/speedtest to /tmp (writable by any user)
- Set USER nginx only at the end for runtime
- Update docker-compose.yml HOME environment variable
Build and runtime verified:
- docker build completes successfully
- Container starts without permission errors
- nginx serves HTML on port 8080
- curl returns valid HTML response
Replace manual nginx unprivileged configuration with the official
nginxinc/nginx-unprivileged image which handles non-root execution
properly out of the box.
Benefits:
- No need for complex nginx configuration hacks
- Proper temp directory handling
- Officially maintained by NGINX Inc
- Removes need for UID/GID configuration in docker-compose
Changes:
- Dockerfile: Use nginxinc/nginx-unprivileged:alpine-slim
- Removed manual nginx temp dir configuration
- Simplified docker-compose.yml (removed user directive)
- Updated .env.example and README
- Updated image size estimate in docs
Nginx needs writable temp directories for client body, proxy, fastcgi,
etc. By default these are in /var/cache/nginx which is not writable
by non-root users.
Changes:
- Create /tmp/nginx with 777 permissions
- Add temp path directives to nginx.conf pointing to /tmp/nginx
- Fixes 'Permission denied' errors when running as non-root UID
Move HTML output from /usr/share/nginx/html (root-owned) to /app/html
(user-writable with 777 permissions). Update nginx config to serve
from the new location.
This allows the container to run as any UID/GID without permission
errors when writing the generated HTML file.
Since we now use /home/speedtest consistently, remove the fallback
to /tmp that was added for backward compatibility.
Replace /tmp with /home/speedtest as the container's home directory.
This is cleaner and follows normal Unix conventions instead of using
/tmp as a home directory.
Changes:
- Dockerfile: ENV HOME=/home/speedtest
- docker-compose.yml: Update HOME environment variable
- Directory created with 777 permissions so any UID can access it
Non-root users cannot write to /var/log. Move all log files to /home/joseph
(/tmp by default) so the container works when running with custom
UID/GID permissions.
Changes:
- speedtest.log: /var/log/ → /home/joseph/
- cron.log: /var/log/ → /home/joseph/
- Added fallback for speedtest-cli.json copy (handles both root and non-root cases)
Move ENV HOME=/tmp to earlier in Dockerfile so it can be used
in subsequent RUN commands. Replace hardcoded /tmp paths with
/home/joseph variable for consistency and flexibility.
Allow container to run with specific UID/GID to match host user
permissions, preventing file ownership issues with mounted volumes.
Key changes:
- Add UID/GID environment variables to docker-compose.yml
- Modify Dockerfile to support arbitrary user IDs (chmod 777 on dirs)
- Update nginx config to run without root (disable user directive)
- Copy speedtest license to user's home at runtime
- Use /tmp for HOME directory when running as non-root
- Document usage in README with id -u/id -g examples
Default remains 1000:1000 but can be overridden via:
UID=1000 GID=1000 docker-compose up -d
Call generate_html.sh before starting nginx to ensure a valid
HTML page is ready to serve immediately, preventing nginx default
page or errors on first boot. Uses existing placeholder page when
no test data is available yet.
Each summary box now has a colored background matching the
corresponding line in the chart:
- Green for download
- Blue for upload
- Red for ping
- Gray for count
Removed the Plotly legend since colors are now self-evident
from the stat boxes, decluttering the visualization.
Uses light-dark() for proper contrast in both themes.
Uses CSS light-dark() function and prefers-color-scheme media query
to automatically switch between light and dark themes based on the
user's browser/system preference.
Key decisions:
- CSS light-dark() over manual media queries (cleaner, modern syntax)
- Page reload on theme change (simplest way to regenerate Plotly chart)
- Plotly chart colors updated to match CSS theme
Changes:
- :root { color-scheme: light dark } enables browser theme support
- Body, container, and stat boxes use light-dark() for colors
- Plotly layout detects dark mode and sets appropriate colors
- Theme change listener triggers page refresh
Reduce image size from 648MB to ~25MB by eliminating Python runtime.
Key Decisions:
- Use bash instead of Python (dcron, jq, bc for data processing)
- Generate static HTML files instead of Flask dynamic responses
- Plotly.js loaded from CDN renders CSV data client-side
- No web server process needed beyond nginx
Trade-offs:
- Slightly less sophisticated than pandas for stats
- Requires dcron for cron functionality in Alpine
- HTML regeneration after each speedtest adds ~50ms overhead
Create a simple Docker container that runs Ookla Speedtest CLI on a
cron schedule and serves interactive graphs of historical results.
Key Decisions:
- Flask for web server (lightweight, simple to implement)
- Plotly for interactive graphs (built-in hover, zoom, no JavaScript coding needed)
- CSV for data persistence (human-readable, portable, no database needed)
- Cron inside container for scheduling (simpler than external scheduler)
- Volume mount at /data for easy data backup/restore
Architecture:
- speedtest_runner.py: Executes speedtest and logs to CSV
- web_server.py: Flask app serving Plotly visualizations
- entrypoint.sh: Orchestrates cron and web server startup
The container was failing to start on Ubuntu with Permission denied when
trying to launch crond, because the container runs as the nginx user
(non-root) and dcron requires root privileges.
Key Decisions:
- Supercronic over a sleep loop to preserve full cron expression support
- Supercronic over sudo because sudo defeats non-root security model
- Grouped crontab setup with supercronic startup for better code organization
Supercronic was chosen because it's designed specifically for containers,
runs without root, handles signals gracefully, and adds only ~2MB to image.
Fix 'installation not allowed to Create organization package' error
by granting the workflow explicit permission to write packages.
The GITHUB_TOKEN requires explicit permissions to push to GHCR.
Adding 'packages: write' at the job level allows the workflow to
authenticate and push Docker images to the GitHub Container Registry.
Update remaining short docker options to full-length equivalents:
- docker build -t -> --tag
- docker logs -f -> --follow
- docker-compose logs -f -> --follow
- -v -> --volume (in Data Persistence section)
All docker commands in documentation now use full-length options
for better clarity and consistency.
- Add badges for Docker pulls, license, and social links
- Update image size from ~25MB to ~35MB (more accurate)
- Add inline docker-compose.yml example instead of referencing file
- Point to thehale/speedtest Docker Hub image
- Add Available Images section documenting Docker Hub and GHCR
- Rename Building section to Building from Source
Restructure script to use a publish_to_registry function that accepts
registry, repo, and tag as parameters. Add command-line options to
publish selectively:
Usage:
bin/publish.sh # Publish to both registries (default)
bin/publish.sh --docker # Publish only to Docker Hub
bin/publish.sh --github # Publish only to GitHub Container Registry
bin/publish.sh v1.0.0 # Publish specific tag to both
This allows local testing against a single registry without needing
credentials for both. CI workflow continues to publish to both.
Update GitHub Actions workflow to use docker login commands directly
(instead of login-action) for consistency with local development.
Changes:
- Use shell-based docker login (echo token | docker login)
- Invoke bin/publish.sh instead of inline commands
- Consistent behavior between CI and local usage
Now both CI and local use identical authentication and publishing logic.
Create bin/publish.sh script and GitHub Actions workflow to automatically
publish Docker images when git tags are pushed.
Changes:
- bin/publish.sh: Local publishing script for Docker Hub and GHCR
- .github/workflows/docker-image.yml: GitHub Actions workflow
- Triggers on git tag pushes
- Publishes both 'latest' and version tags
- Pushes to both Docker Hub and GitHub Container Registry
Publishing workflow:
1. Create and push a git tag: git tag v1.0.0 && git push origin v1.0.0
2. GitHub Actions automatically builds and publishes
3. Images available at:
- docker.io/thehale/speedtest:v1.0.0
- docker.io/thehale/speedtest:latest
- ghcr.io/thehale/speedtest:v1.0.0
- ghcr.io/thehale/speedtest:latest
Reduce frequency from every 30 minutes to every 5 hours to avoid
excessive speedtest consumption and provide more reasonable default
for long-term monitoring.
Changes:
- .env.example: */30 * * * * → 0 */5 * * *
- docker-compose.yml: */30 * * * * → 0 */5 * * *
- README.md: Update examples and add every 5 hours to list
- AGENTS.md: Update default value in docs
Users who want more frequent testing can still customize via CRON_SCHEDULE.
Create bin/ci.sh to automate pre-commit verification:
- Builds Docker image
- Starts test container
- Verifies nginx responds
- Checks HTML content
- Validates dark theme support
- Checks for errors in logs
- Automatically cleans up test resources
Create AGENTS.md with development workflow instructions:
- Mandatory CI script execution before commits
- Project architecture overview
- Common troubleshooting tips
- Environment variable reference
- Testing procedures
This ensures no broken commits reach the repository.
The nginx-unprivileged image runs as nginx user (uid 101), causing
permission issues when trying to install packages or write to /home.
Key fixes:
- Add USER root before package installation RUN commands
- Change HOME from /home/speedtest to /tmp (writable by any user)
- Set USER nginx only at the end for runtime
- Update docker-compose.yml HOME environment variable
Build and runtime verified:
- docker build completes successfully
- Container starts without permission errors
- nginx serves HTML on port 8080
- curl returns valid HTML response
Replace manual nginx unprivileged configuration with the official
nginxinc/nginx-unprivileged image which handles non-root execution
properly out of the box.
Benefits:
- No need for complex nginx configuration hacks
- Proper temp directory handling
- Officially maintained by NGINX Inc
- Removes need for UID/GID configuration in docker-compose
Changes:
- Dockerfile: Use nginxinc/nginx-unprivileged:alpine-slim
- Removed manual nginx temp dir configuration
- Simplified docker-compose.yml (removed user directive)
- Updated .env.example and README
- Updated image size estimate in docs
Nginx needs writable temp directories for client body, proxy, fastcgi,
etc. By default these are in /var/cache/nginx which is not writable
by non-root users.
Changes:
- Create /tmp/nginx with 777 permissions
- Add temp path directives to nginx.conf pointing to /tmp/nginx
- Fixes 'Permission denied' errors when running as non-root UID
Replace /tmp with /home/speedtest as the container's home directory.
This is cleaner and follows normal Unix conventions instead of using
/tmp as a home directory.
Changes:
- Dockerfile: ENV HOME=/home/speedtest
- docker-compose.yml: Update HOME environment variable
- Directory created with 777 permissions so any UID can access it
Non-root users cannot write to /var/log. Move all log files to /home/joseph
(/tmp by default) so the container works when running with custom
UID/GID permissions.
Changes:
- speedtest.log: /var/log/ → /home/joseph/
- cron.log: /var/log/ → /home/joseph/
- Added fallback for speedtest-cli.json copy (handles both root and non-root cases)
Allow container to run with specific UID/GID to match host user
permissions, preventing file ownership issues with mounted volumes.
Key changes:
- Add UID/GID environment variables to docker-compose.yml
- Modify Dockerfile to support arbitrary user IDs (chmod 777 on dirs)
- Update nginx config to run without root (disable user directive)
- Copy speedtest license to user's home at runtime
- Use /tmp for HOME directory when running as non-root
- Document usage in README with id -u/id -g examples
Default remains 1000:1000 but can be overridden via:
UID=1000 GID=1000 docker-compose up -d
Each summary box now has a colored background matching the
corresponding line in the chart:
- Green for download
- Blue for upload
- Red for ping
- Gray for count
Removed the Plotly legend since colors are now self-evident
from the stat boxes, decluttering the visualization.
Uses light-dark() for proper contrast in both themes.
Uses CSS light-dark() function and prefers-color-scheme media query
to automatically switch between light and dark themes based on the
user's browser/system preference.
Key decisions:
- CSS light-dark() over manual media queries (cleaner, modern syntax)
- Page reload on theme change (simplest way to regenerate Plotly chart)
- Plotly chart colors updated to match CSS theme
Changes:
- :root { color-scheme: light dark } enables browser theme support
- Body, container, and stat boxes use light-dark() for colors
- Plotly layout detects dark mode and sets appropriate colors
- Theme change listener triggers page refresh
Reduce image size from 648MB to ~25MB by eliminating Python runtime.
Key Decisions:
- Use bash instead of Python (dcron, jq, bc for data processing)
- Generate static HTML files instead of Flask dynamic responses
- Plotly.js loaded from CDN renders CSV data client-side
- No web server process needed beyond nginx
Trade-offs:
- Slightly less sophisticated than pandas for stats
- Requires dcron for cron functionality in Alpine
- HTML regeneration after each speedtest adds ~50ms overhead
Create a simple Docker container that runs Ookla Speedtest CLI on a
cron schedule and serves interactive graphs of historical results.
Key Decisions:
- Flask for web server (lightweight, simple to implement)
- Plotly for interactive graphs (built-in hover, zoom, no JavaScript coding needed)
- CSV for data persistence (human-readable, portable, no database needed)
- Cron inside container for scheduling (simpler than external scheduler)
- Volume mount at /data for easy data backup/restore
Architecture:
- speedtest_runner.py: Executes speedtest and logs to CSV
- web_server.py: Flask app serving Plotly visualizations
- entrypoint.sh: Orchestrates cron and web server startup