Compare commits
4 Commits
f1f184c36e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 32b997d2c3 | |||
| 595c20a0a1 | |||
| 9355beaee2 | |||
| 8ff5428bb1 |
@@ -45,7 +45,11 @@ handled separately — see [Exposing services](#exposing-services).
|
||||
| [gitea](gitea/) | 3000 | Self-hosted Git with issues and PRs. Also 222 for SSH clone |
|
||||
| [gluetun](gluetun/) | 9880 | VPN kill switch with qBittorrent + pyload-ng inside it, plus HTTP/SOCKS proxies |
|
||||
| [heimdall](heimdall/) | 4080 | Dashboard / start page for the other services |
|
||||
| [httpserver](httpserver/) | 8083 | Bare nginx file share. Plain HTTP on purpose — works with very old clients |
|
||||
| [immich](immich/) | 2283 | Photo and video library with local ML search. Four containers, Postgres + VectorChord |
|
||||
| [librespeed](librespeed/) | 3011 | Self-hosted speed test. Measures the path to your box, not your ISP |
|
||||
| [jellyfin](jellyfin/) | 8096 | Media streaming. Also 7359/udp for auto-discovery |
|
||||
| [myspeed](myspeed/) | 5216 | Scheduled speed tests with history graphs. Records what librespeed measures on demand |
|
||||
| [openwebui](openwebui/) | 3033 | LLM chat UI. One `OWUI_TAG` switches between CPU, CUDA and bundled-Ollama |
|
||||
| [overleaf](overleaf/) | 8020 | LaTeX editor. Four containers: app, Mongo (replica set), Redis, init |
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
||||
|
||||
# ─── What gets served ───────────────────────────────────────────────
|
||||
# The stack serves ${TEMPDISK}/Temp as the web root -- the "/Temp"
|
||||
# suffix is in compose.yaml, so point this at the parent.
|
||||
#
|
||||
# Written bare as ${TEMPDISK} in compose.yaml, with no default: unset, it
|
||||
# interpolates to an empty string and nginx ends up serving /Temp from the
|
||||
# host root. Set it.
|
||||
TEMPDISK=/mnt/tank
|
||||
|
||||
# ─── Identity ───────────────────────────────────────────────────────
|
||||
# Present for consistency with the other stacks, but NOTE: this is the
|
||||
# official nginx image, not LinuxServer. It has no PUID/PGID handling
|
||||
# anywhere in its entrypoint -- these are read by nobody. Its workers run
|
||||
# as uid 101 (`nginx`) regardless, and only need read access.
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
# TZ affects log timestamps only.
|
||||
TZ=Europe/Paris
|
||||
@@ -0,0 +1,122 @@
|
||||
# httpserver
|
||||
|
||||
A directory of files over plain HTTP, and nothing else. No accounts, no
|
||||
database, no JavaScript — nginx with `autoindex on` pointed at a scratch
|
||||
directory. Read-only: it is for handing files out, not taking them in.
|
||||
|
||||
The point is **reach**, not features. This is the share that works from a
|
||||
Windows 98 machine, a games console browser, a printer's built-in fetcher, or
|
||||
`wget` on a router. [copyparty](../copyparty/) and [filebrowser](../filebrowser/)
|
||||
are nicer for humans on modern machines; neither will talk to a client from 1999.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env # TEMPDISK -- the parent of the Temp/ you want served
|
||||
mkdir -p "$TEMPDISK/Temp"
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://<host>:8083`.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Proto | Purpose |
|
||||
|------|-------|---------|
|
||||
| 8083 | http | Directory listing and downloads |
|
||||
|
||||
No HTTPS, deliberately. See below.
|
||||
|
||||
## Why it looks like this
|
||||
|
||||
**Plain HTTP is the feature, not an oversight.** Windows 98 cannot do TLS 1.2,
|
||||
SNI, or any cipher suite a modern server will negotiate. The moment this goes
|
||||
behind an HTTPS-terminating proxy it stops working for exactly the clients it
|
||||
exists for. Verified: an `HTTP/1.0` request with **no `Host:` header** — which
|
||||
is what ancient clients send — returns `200 OK`, because the single server block
|
||||
is the default server.
|
||||
|
||||
**The generated listing is deliberately primitive.** nginx's `autoindex` emits
|
||||
`<html><head><title>` then `<h1>`, `<hr>`, `<pre>` and `<a href>`. No CSS, no
|
||||
JavaScript, not even a table. That renders in IE5, Netscape 4, Lynx, and
|
||||
everything since. Responses carry `Content-Length`, `Last-Modified` and
|
||||
`Accept-Ranges: bytes`, so resume works — which matters more on a flaky old
|
||||
machine than a modern one.
|
||||
|
||||
**`include mime.types` is there despite the minimalism**, because it makes weak
|
||||
clients behave *better* and costs nothing. Mounting a config over
|
||||
`/etc/nginx/nginx.conf` replaces the whole file, including nginx's default
|
||||
`types` block, leaving only a tiny built-in table. Measured, before and after:
|
||||
|
||||
| File | Without the include | With it |
|
||||
|------|--------------------|---------|
|
||||
| `photo.jpg` | `image/jpeg` | `image/jpeg` |
|
||||
| `page.html` | `text/html` | `text/html` |
|
||||
| `photo.png` | **`text/plain`** | `image/png` |
|
||||
| `archive.zip` | **`text/plain`** | `application/zip` |
|
||||
| `setup.exe` | **`text/plain`** | `application/octet-stream` |
|
||||
| `manual.pdf` | **`text/plain`** | `application/pdf` |
|
||||
| `song.mp3` | **`text/plain`** | `audio/mpeg` |
|
||||
|
||||
Only html, gif and jpeg survive without it. A modern browser usually shrugs and
|
||||
downloads anyway; **IE5 on Windows 98 tries to *display* a `text/plain`
|
||||
response**, so a 700 MB zip becomes a screen of garbage instead of a Save
|
||||
dialog. The directive is parsed once at startup — no per-request cost, and the
|
||||
file already ships in the image.
|
||||
|
||||
**No upload path.** nginx can accept WebDAV `PUT`, and an earlier version of
|
||||
this config did — but `autoindex` has no upload form, so a browser can never use
|
||||
it. Writes would only work from a DAV client: `curl -T`, or Windows Explorer's
|
||||
*Map network drive*. A real upload button needs a CGI or an app behind nginx,
|
||||
which is the complexity this stack exists to avoid. Use
|
||||
[copyparty](../copyparty/) or [filebrowser](../filebrowser/) when you need
|
||||
people to send files *to* you.
|
||||
|
||||
**The config lives in `init/`** because this repo's `.gitignore` ignores
|
||||
everything inside a service directory except a short list, and `init/**` is on
|
||||
it. A config in any other subdirectory is silently *not committed*, and then the
|
||||
bind mount has nothing to mount — Docker creates a **directory** at that path and
|
||||
nginx won't start. Same failure mode as
|
||||
[overleaf's init script](../overleaf/README.md#running-it-on-openmediavault).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`PUID` and `PGID` do nothing.** This is the official nginx image, not
|
||||
LinuxServer; there is no PUID/PGID handling anywhere in its entrypoint. They
|
||||
are accepted and read by nobody — the same trap documented in
|
||||
[audiobookshelf](../audiobookshelf/README.md). `TZ` affects log timestamps at
|
||||
most. It costs nothing here because the share is read-only: the workers run as
|
||||
uid 101 and only ever need read access.
|
||||
- **`${TEMPDISK}` has no default.** Unset, Compose interpolates an empty string
|
||||
and nginx serves `/Temp` from the host root rather than failing. The repo's
|
||||
`${VAR:?message}` pattern exists for exactly this.
|
||||
- **Keep filenames ASCII for old clients.** `autoindex` emits filenames as raw
|
||||
bytes; Windows 98 is CP1252, so accented names mangle.
|
||||
- **`:latest` is unpinned.** If server weight matters, `nginx:alpine-slim` is
|
||||
**8 MB** compressed against **63 MB** for `nginx:latest`, with identical
|
||||
behaviour for static serving.
|
||||
- **Anyone who reaches the port sees everything.** `autoindex` publishes a
|
||||
browsable listing of the whole share with no authentication. Nothing is
|
||||
writable, so the risk is disclosure rather than tampering — but it is the
|
||||
whole directory, and that is the intended trade for a temporary share on a
|
||||
trusted network.
|
||||
|
||||
## Exposing it
|
||||
|
||||
**Think twice.** If you put this behind a proxy for remote access, you lose the
|
||||
old clients that justify it existing.
|
||||
|
||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — fine for modern
|
||||
clients; it will terminate TLS and break Windows 98.
|
||||
- [Authentik forward auth](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
|
||||
— the way to make it safe remotely, but it needs a browser that can complete
|
||||
the login, which rules out the same ancient clients.
|
||||
|
||||
Running two hostnames — one bare HTTP on the LAN, one proxied for outside — is
|
||||
the honest way to have both.
|
||||
|
||||
## Links
|
||||
|
||||
- nginx `autoindex`: <https://nginx.org/en/docs/http/ngx_http_autoindex_module.html>
|
||||
- Image: <https://hub.docker.com/_/nginx>
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
container_name: nginx-webserver
|
||||
environment:
|
||||
- PUID=${PUID}
|
||||
- PGID=${PGID}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- ./init/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ${TEMPDISK}/Temp:/usr/share/nginx/html
|
||||
ports:
|
||||
- 8083:80
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,20 @@
|
||||
events {}
|
||||
http {
|
||||
# Without these every file except .html/.gif/.jpg is served as text/plain,
|
||||
# from nginx's tiny built-in table. Old browsers then try to *display* a
|
||||
# .zip or .exe instead of offering to save it. Parsed once at startup --
|
||||
# no per-request cost, and mime.types already ships in the image.
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
autoindex on;
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
||||
#
|
||||
# Immich is the one stack here where .env is NOT optional: compose.yaml
|
||||
# has `env_file: .env` on two services, so Compose refuses to start
|
||||
# without the file existing. It is also the application's own config, not
|
||||
# just a few variables Compose interpolates.
|
||||
#
|
||||
# This mirrors upstream's example.env for the pinned release. When you
|
||||
# update compose.yaml, re-fetch this too:
|
||||
# curl -fLO https://github.com/immich-app/immich/releases/latest/download/example.env
|
||||
|
||||
# ─── Storage ────────────────────────────────────────────────────────
|
||||
# Your photos and videos. Mounted at /data inside the container -- do not
|
||||
# change the container side, only this. This is the directory to back up.
|
||||
UPLOAD_LOCATION=./library
|
||||
|
||||
# The Postgres data directory. Network shares (NFS/SMB) are NOT supported
|
||||
# here and will corrupt the database; it must be local storage.
|
||||
DB_DATA_LOCATION=./postgres
|
||||
|
||||
# ─── Version ────────────────────────────────────────────────────────
|
||||
# Pin the MAJOR. Immich ships breaking changes between majors and the
|
||||
# compose file changes with them, so leaving this unset (which resolves
|
||||
# to `release`) means an unattended `docker compose pull` can jump a
|
||||
# major and leave your compose file behind. See the README.
|
||||
IMMICH_VERSION=v3
|
||||
|
||||
# ─── Database ───────────────────────────────────────────────────────
|
||||
# Change this. Letters and digits only -- no special characters or
|
||||
# spaces, they break the connection string.
|
||||
# openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32
|
||||
DB_PASSWORD=
|
||||
|
||||
# ─── Optional ───────────────────────────────────────────────────────
|
||||
# Uncomment and set to a tz identifier if the server clock isn't right.
|
||||
#TZ=Europe/Paris
|
||||
|
||||
# ─── Leave these alone ──────────────────────────────────────────────
|
||||
DB_USERNAME=postgres
|
||||
DB_DATABASE_NAME=immich
|
||||
@@ -0,0 +1,121 @@
|
||||
# Immich
|
||||
|
||||
Self-hosted photo and video library. Phone backup that runs on your own disk,
|
||||
with the parts that make Google Photos worth using — face and object search,
|
||||
places, memories, shared albums — done locally by a machine-learning container.
|
||||
|
||||
The heaviest stack in this repo: four containers, a Postgres with vector
|
||||
extensions, and a model cache. Budget a few GB of RAM and expect the first
|
||||
import to work the CPU hard.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env # UPLOAD_LOCATION, DB_DATA_LOCATION, DB_PASSWORD
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://<host>:2283` and register. **The first account created is the
|
||||
admin** — there is no separate bootstrap step, so do this before the port is
|
||||
reachable by anyone else.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Proto | Purpose |
|
||||
|------|-------|---------|
|
||||
| 2283 | http | Web UI, mobile app API, everything |
|
||||
|
||||
Postgres and Valkey are reachable only on the stack's internal network.
|
||||
|
||||
## Why it looks like this
|
||||
|
||||
**This file is upstream's, verbatim, and should stay that way.** Immich ships a
|
||||
`docker-compose.yml` with each release, pins two of its images *by digest*, and
|
||||
changes the file between versions — the mount path, the Postgres extension
|
||||
build, added `shm_size`. Local edits turn every update into a three-way merge.
|
||||
The repo convention of `${VAR:-default}` is deliberately **not** applied here for
|
||||
the same reason: keeping it byte-identical to upstream means you can re-fetch and
|
||||
`diff` to see exactly what a release changed.
|
||||
|
||||
```bash
|
||||
curl -fLo /tmp/immich-new.yml \
|
||||
https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
diff -u compose.yaml /tmp/immich-new.yml
|
||||
```
|
||||
|
||||
Pinned at **v3.2.2** (released 2026-09-15).
|
||||
|
||||
**Four containers.** `immich-server` is the API and web UI. `immich-machine-learning`
|
||||
runs the CLIP and face-detection models — it is separate because it is the part
|
||||
you might want on a GPU, and because its model cache is a named volume rather
|
||||
than something you back up. `database` is Postgres with **VectorChord**, which
|
||||
is what makes similarity search possible; a stock Postgres image will not work.
|
||||
`redis` is Valkey, the Redis fork, used as the job queue.
|
||||
|
||||
**`env_file: .env` is a hard dependency.** Unlike every other stack here, where
|
||||
`.env` is optional because the compose file carries defaults, Compose will refuse
|
||||
to start this one if the file does not exist.
|
||||
|
||||
**Images are digest-pinned** for Valkey and Postgres
|
||||
(`@sha256:…`), so those two are byte-identical to what upstream tested against
|
||||
this release. The Immich images themselves float on `${IMMICH_VERSION:-release}`
|
||||
— which is why pinning `IMMICH_VERSION` in `.env` matters.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Pin `IMMICH_VERSION` to a major.** Unset, it resolves to `release`, so a
|
||||
routine `docker compose pull` can carry you across a major version while your
|
||||
compose file stays behind. Upstream's own `example.env` now sets
|
||||
`IMMICH_VERSION=v3` for exactly this reason.
|
||||
- **The container media path changed, and it is easy to miss.** Older compose
|
||||
files mount `${UPLOAD_LOCATION}:/usr/src/app/upload`; since **v1.140.0** it is
|
||||
`${UPLOAD_LOCATION}:/data`, because `IMMICH_MEDIA_LOCATION` now defaults to
|
||||
`/data`. Run a new image against an old compose file and the server looks for
|
||||
your library at an unmounted path — it appears empty, and anything written
|
||||
goes into the container and is lost when it is recreated. If you are adopting
|
||||
this file over an older one, that mount line is the one to check first.
|
||||
- **`DB_DATA_LOCATION` must be local storage.** Not NFS, not SMB, not a
|
||||
network share of any kind. Postgres on a network filesystem corrupts, and
|
||||
upstream says so in the example env.
|
||||
- **Upgrading Postgres extensions is a real migration.** The image here is
|
||||
`14-vectorchord0.4.3-pgvectors0.2.0`; moving from an older VectorChord build
|
||||
makes Immich alter the database on first boot, which "can take seconds to
|
||||
minutes". Back up `DB_DATA_LOCATION` before any version jump, and read
|
||||
[the upgrade notes](https://docs.immich.app/install/upgrading/).
|
||||
- **`shm_size: 128mb` on the database is load-bearing**, not decoration.
|
||||
Postgres uses shared memory for larger queries and Docker's 64 MB default is
|
||||
not enough here. Upstream's upgrade guide lists adding it as a required step.
|
||||
- **`DB_STORAGE_TYPE: 'HDD'` is commented out** in the database environment.
|
||||
Uncomment it if the Postgres data directory sits on a spinning disk — it tunes
|
||||
the planner's assumptions about random reads. Leave it alone on SSD or NVMe.
|
||||
- **Back up `UPLOAD_LOCATION` and `DB_DATA_LOCATION` together.** The files are
|
||||
the photos; the database holds every album, face cluster, share and edit.
|
||||
Either alone is most of a disaster.
|
||||
- **The first import is expensive.** Machine learning runs over the whole
|
||||
library once. On a low-power box expect it to saturate the CPU for hours —
|
||||
the same caveat as [copyparty](../copyparty/README.md) on an N100, and worth
|
||||
scheduling rather than discovering.
|
||||
|
||||
## Exposing it
|
||||
|
||||
The mobile apps talk to the same port as the browser, so whatever you put in
|
||||
front has to work for both.
|
||||
|
||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the normal route.
|
||||
Raise the upload body limit or large videos fail on the way in.
|
||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — **the ~100 MB body cap is exactly
|
||||
the wrong limit for this.** A phone's 4K video will exceed it. Read
|
||||
[the limits](../docs/cloudflare-tunnel.md) first.
|
||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Immich speaks OIDC
|
||||
natively, so use that rather than forward auth, which would break every mobile
|
||||
client. Keep a local admin account until the flow is proven.
|
||||
|
||||
## Links
|
||||
|
||||
- Install guide: <https://docs.immich.app/install/docker-compose>
|
||||
- Release compose file: <https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml>
|
||||
- Environment variables: <https://docs.immich.app/install/environment-variables>
|
||||
- Upgrading: <https://docs.immich.app/install/upgrading/>
|
||||
- Hardware-accelerated ML: <https://docs.immich.app/features/ml-hardware-acceleration>
|
||||
- Compose builder: <https://immich.app/docker-compose-builder>
|
||||
@@ -0,0 +1,76 @@
|
||||
#
|
||||
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
|
||||
#
|
||||
# Make sure to use the docker-compose.yml of the current release:
|
||||
#
|
||||
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
#
|
||||
# The compose file on main may not be compatible with the latest release.
|
||||
|
||||
name: immich
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
|
||||
- ${UPLOAD_LOCATION}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '2283:2283'
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
||||
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
||||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
||||
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
||||
# file: hwaccel.ml.yml
|
||||
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:70739f85ad2ee01a726a965584a0f94895f01b0c60b3cc8b0aeef11eaa6888cf
|
||||
healthcheck:
|
||||
test: redis-cli ping | grep -q PONG || exit 1
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||||
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
|
||||
# DB_STORAGE_TYPE: 'HDD'
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||
shm_size: 128mb
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
||||
#
|
||||
# NOTE: compose.yaml currently sets these values literally in its
|
||||
# `environment:` block, so nothing here is read yet. Switch a line to
|
||||
# ${VAR} in compose.yaml to start using it -- worth doing for the two
|
||||
# marked SECRET below, which should never sit in a committed file.
|
||||
|
||||
# ─── Basics ─────────────────────────────────────────────────────────
|
||||
#TITLE=LibreSpeed
|
||||
#TAGLINE=
|
||||
# Port on the host; the container always listens on WEBPORT (8080).
|
||||
#PORT=3011
|
||||
|
||||
# ─── Telemetry: off by default ──────────────────────────────────────
|
||||
# Turn this on and results are stored -- which needs a database AND a
|
||||
# volume, neither of which compose.yaml has today. See the README.
|
||||
#TELEMETRY=false
|
||||
|
||||
# SECRET. Protects the stats page at /results/stats.php. Required once
|
||||
# telemetry is on, or anyone can read every result.
|
||||
#PASSWORD=
|
||||
|
||||
# Shown in the privacy policy as the contact for deletion requests.
|
||||
# Required for a production install with telemetry. NOTE: the old EMAIL
|
||||
# variable is deprecated -- this is the current name.
|
||||
#GDPR_EMAIL=you@example.com
|
||||
|
||||
# Telemetry logs client IPs unless you turn this on.
|
||||
#REDACT_IP_ADDRESSES=false
|
||||
#ENABLE_ID_OBFUSCATION=false
|
||||
# 2-byte hex, e.g. 0x1234abcd. Random if unset -- which means IDs change
|
||||
# every restart.
|
||||
#OBFUSCATION_SALT=
|
||||
|
||||
# ─── Database (only when TELEMETRY=true) ────────────────────────────
|
||||
# sqlite needs /database mounted or results vanish on recreate.
|
||||
#DB_TYPE=sqlite
|
||||
#DB_NAME=
|
||||
#DB_HOSTNAME=
|
||||
#DB_PORT=
|
||||
#DB_USERNAME=
|
||||
#DB_PASSWORD=
|
||||
|
||||
# ─── ISP name and distance ──────────────────────────────────────────
|
||||
# SECRET. From ipinfo.io. Without it there is no ISP name, and DISTANCE
|
||||
# does nothing.
|
||||
#IPINFO_APIKEY=
|
||||
#DISABLE_IPINFO=false
|
||||
# km, mi, or empty to hide it. Needs IPINFO_APIKEY.
|
||||
#DISTANCE=km
|
||||
@@ -0,0 +1,93 @@
|
||||
# LibreSpeed
|
||||
|
||||
A speed test you host yourself. One container, no Flash, no Java, no account —
|
||||
open the page and it measures the link between the browser and *your* server.
|
||||
|
||||
That last part is the whole point and the whole catch: this does not tell you
|
||||
how fast your internet is. It tells you how fast the path to this box is, which
|
||||
is the more useful number when you are debugging your own LAN, your VPN, or a
|
||||
neighbour's Wi-Fi — and a meaningless one if you point it through a CDN.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://<host>:3011`. There is nothing to configure and no `.env` needed
|
||||
for the default read-only setup — see [`.env.example`](.env.example) for what
|
||||
becomes relevant once you turn telemetry on.
|
||||
|
||||
## Ports
|
||||
|
||||
| Host | Container | Purpose |
|
||||
|------|-----------|---------|
|
||||
| 3011 | 8080 | Web UI and the test endpoints |
|
||||
|
||||
The container port follows `WEBPORT`, which defaults to 8080.
|
||||
|
||||
## Why it looks like this
|
||||
|
||||
**`MODE: standalone`** means one container is both the page and the test
|
||||
endpoint. The alternatives matter only at scale: `backend` is a bare test point,
|
||||
`frontend` serves a UI that talks to a list of remote backends, and `dual` is
|
||||
both. For a single box, standalone is the whole story.
|
||||
|
||||
**No volumes, because telemetry is off.** With `TELEMETRY` unset the container
|
||||
stores nothing and is entirely disposable — `docker compose down` costs you
|
||||
nothing. The moment you enable it, that stops being true; see the gotchas.
|
||||
|
||||
**Most of the environment block is commented out on purpose.** It is upstream's
|
||||
menu of options kept close to hand, which is more useful in a file you revisit
|
||||
once a year than a link to documentation.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Turning on telemetry needs a volume that isn't there.** `TELEMETRY: "true"`
|
||||
stores results in sqlite by default, inside the container, at `/database`.
|
||||
With no volume mounted the results survive restarts but vanish the moment the
|
||||
container is recreated — which is to say, on the next `docker compose pull`.
|
||||
Add `- ./data:/database` before enabling it.
|
||||
- **Telemetry also wants `PASSWORD`**, which protects the stats page at
|
||||
`/results/stats.php`. Without it, anyone who finds the URL can read every
|
||||
result you've recorded, including client IPs.
|
||||
- **`EMAIL` is deprecated — the current name is `GDPR_EMAIL`.** It is the
|
||||
address shown in the privacy policy for deletion requests, and upstream
|
||||
considers it required for a production install with telemetry on. The
|
||||
commented block in `compose.yaml` still uses the old name.
|
||||
- **Results log client IP addresses by default.** `REDACT_IP_ADDRESSES: "true"`
|
||||
turns that off. Worth thinking about before you hand the URL to guests.
|
||||
- **`DISTANCE` does nothing without `IPINFO_APIKEY`**, and that key is a
|
||||
credential — it belongs in `.env` and a `${IPINFO_APIKEY}` reference, never in
|
||||
the committed compose file.
|
||||
- **A slow result may be your server, not your network.** The test works by
|
||||
generating and swallowing garbage data, so on a low-power host the CPU can cap
|
||||
the number before the link does. If you see a suspiciously round ceiling,
|
||||
check `docker stats` while a test runs — the same trap as
|
||||
[copyparty on an N100](../copyparty/README.md).
|
||||
- **`:latest` is unpinned.** Note the Docker tags drop the `v` that the git tags
|
||||
carry: the release is `v6.3.0`, the image tag is **`6.3.0`** (also `6.3`, `6`,
|
||||
and `-alpine` variants of each). Pinning `v6.3.0` fails with a manifest error
|
||||
that looks like a network problem.
|
||||
- **`restart: always` rather than `unless-stopped`**, so a deliberate stop does
|
||||
not survive a reboot. The rest of this repo uses the latter.
|
||||
|
||||
## Exposing it
|
||||
|
||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — works, but understand
|
||||
what you are then measuring: the path to the proxy, shaped by whatever the
|
||||
proxy does. For a LAN test, reach it directly by IP and skip the proxy.
|
||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — **no.** This is a tool whose job
|
||||
is to move as many gigabytes as it can for as long as you let it. Putting it
|
||||
behind a tunnel produces a number that measures Cloudflare, while pushing
|
||||
bulk transfer through a service not meant to carry it.
|
||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — forward auth fits
|
||||
fine; it is a browser-only app with no accounts of its own. Sensible if this
|
||||
faces the internet, though a public speed test is also a free bandwidth sink
|
||||
for whoever finds it.
|
||||
|
||||
## Links
|
||||
|
||||
- Upstream: <https://github.com/librespeed/speedtest>
|
||||
- Docker options: <https://github.com/librespeed/speedtest/blob/master/doc_docker.md>
|
||||
- Image: <https://github.com/librespeed/speedtest/pkgs/container/speedtest>
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
speedtest:
|
||||
container_name: speedtest
|
||||
image: ghcr.io/librespeed/speedtest:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: standalone
|
||||
TITLE: "Gooyban's Speed Meter"
|
||||
#TELEMETRY: "false"
|
||||
#ENABLE_ID_OBFUSCATION: "false"
|
||||
#REDACT_IP_ADDRESSES: "false"
|
||||
#PASSWORD:
|
||||
#GDPR_EMAIL::
|
||||
#DISABLE_IPINFO: "false"
|
||||
#IPINFO_APIKEY: "your api key"
|
||||
#DISTANCE: "km"
|
||||
#WEBPORT: 8080
|
||||
ports:
|
||||
- "3011:8080" # webport mapping (host:container)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
||||
# Everything here has a default in compose.yaml, so a fresh clone runs
|
||||
# with no .env at all.
|
||||
|
||||
# ─── Version ────────────────────────────────────────────────────────
|
||||
# Defaults to `latest`, which is worth knowing about: upstream's last
|
||||
# tagged release is v1.0.9 from May 2024, and `latest` is that same image.
|
||||
# It will move if and when a new release is tagged. Active development
|
||||
# ships separately as `development`, rebuilt Aug 2026.
|
||||
#
|
||||
# latest follows releases; today that is the May 2024 build
|
||||
# 1.0.9 the same image, named explicitly
|
||||
# development current build
|
||||
#
|
||||
#MYSPEED_TAG=latest
|
||||
|
||||
# ─── Paths ──────────────────────────────────────────────────────────
|
||||
# The SQLite history database and every setting -- schedule, retention,
|
||||
# provider, alerts. Months of measurements live here and nothing in it
|
||||
# regenerates. This is the whole backup.
|
||||
#DATA_PATH=./data
|
||||
|
||||
# ─── Ports ──────────────────────────────────────────────────────────
|
||||
#PORT=5216
|
||||
|
||||
# ─── Timezone ───────────────────────────────────────────────────────
|
||||
# There is no TZ variable to set: MySpeed takes no environment
|
||||
# configuration at all, and the image documents none. Since tests run on
|
||||
# cron expressions, container time decides when they fire -- UTC unless
|
||||
# you uncomment the two clock mounts in compose.yaml.
|
||||
@@ -0,0 +1,103 @@
|
||||
# MySpeed
|
||||
|
||||
Speed test *history*. It runs a test on a cron schedule, keeps the results for as
|
||||
long as you tell it to, and draws the graph — so you can answer "is it slow right
|
||||
now, or has it been slow every evening for a fortnight?"
|
||||
|
||||
Pairs with [librespeed](../librespeed/) rather than competing with it: that one
|
||||
is an on-demand test you open when you want a number, this one is the unattended
|
||||
recorder. MySpeed can even use LibreSpeed as its measurement backend — it offers
|
||||
**Ookla, LibreSpeed and Cloudflare** as test providers, so the instance next door
|
||||
can be the thing it measures against.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env # optional -- every value has a default
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open `http://<host>:5216`. Everything — schedule, retention, provider, alerts —
|
||||
is configured in the web UI, not in this file.
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Proto | Purpose |
|
||||
|------|-------|---------|
|
||||
| 5216 | http | Web UI and API |
|
||||
|
||||
## Why it looks like this
|
||||
|
||||
**A bind mount instead of upstream's named volume.** The official install is
|
||||
`docker run -v myspeed:/myspeed/data`, a Docker-managed volume. Here it is
|
||||
`${DATA_PATH:-./data}`, so the history database sits next to the compose file
|
||||
where you can see it and a backup job can find it without knowing Docker's
|
||||
layout. Same reasoning as [jellyfin](../jellyfin/README.md)'s `/config`.
|
||||
|
||||
**MySpeed itself takes no environment configuration** — no `PUID`, no `TZ`, no
|
||||
options. State and settings both live in the SQLite database in that one
|
||||
directory, which makes the stack trivially portable and the backup story simple:
|
||||
copy the directory. The variables in `.env.example` are therefore Compose-level
|
||||
only: the image tag, the host port, and where that directory lives.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`latest` is a build from May 2024.** The project is alive — the repository
|
||||
was pushed to in September 2026 — but the last tagged release is `v1.0.9` and
|
||||
Docker Hub's `latest` still points at that same image:
|
||||
|
||||
| Tag | Pushed | Size | Digest |
|
||||
|-----|--------|------|--------|
|
||||
| `development` | 2026-08-02 | 102 MB | `sha256:043c2f00…` |
|
||||
| `latest` | 2024-05-21 | 59 MB | `sha256:3a3e774b…` |
|
||||
| `1.0.9` | 2024-05-21 | 59 MB | `sha256:3a3e774b…` |
|
||||
|
||||
So `latest` here does not mean recent — it means "the newest *release*", and
|
||||
there hasn't been one since May 2024. It will start moving again if upstream
|
||||
tags one. Until then, "update to the newest version" means
|
||||
`MYSPEED_TAG=development`, which is a choice to make knowingly rather than a
|
||||
`docker compose pull` away.
|
||||
- **Scheduled tests run on container time.** The whole feature is cron
|
||||
expressions, and the image documents no `TZ` variable — so "run at 03:00" is
|
||||
03:00 **UTC** unless you tell the container otherwise. The two clock mounts are
|
||||
in `compose.yaml`, commented out; uncomment them to follow the host instead.
|
||||
They are off by default because turning them on shifts every existing schedule
|
||||
by your UTC offset. Same class of trap as the cron container in
|
||||
[firefly](../firefly/README.md).
|
||||
- **A speed test measures the path from *this box*.** If MySpeed runs on the NAS,
|
||||
the graph is the NAS's connectivity — which is usually what you want, but it is
|
||||
not your laptop's Wi-Fi. And a low-power host can cap the result on CPU before
|
||||
the link saturates; see [librespeed](../librespeed/README.md) for the same
|
||||
caveat.
|
||||
- **Back up the data directory.** Months of measurements and every setting are in
|
||||
there, and nothing regenerates.
|
||||
|
||||
## What else it does
|
||||
|
||||
Worth knowing about before you go looking for another tool:
|
||||
|
||||
- **Health checks with alerting** — email, Signal, WhatsApp or Telegram when a
|
||||
test fails or the line goes down.
|
||||
- **Prometheus and Grafana export**, if you would rather graph this beside
|
||||
everything else.
|
||||
- **Multiple servers on one instance**, so a single dashboard can watch more than
|
||||
one site.
|
||||
- **Configurable retention**, from a few days to forever.
|
||||
|
||||
## Exposing it
|
||||
|
||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — fine; the UI is just a
|
||||
dashboard, and the tests run server-side regardless of how you reach it.
|
||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for the dashboard, unlike
|
||||
[librespeed](../librespeed/README.md): the measurements do not travel through
|
||||
the tunnel, only the page does.
|
||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — forward auth suits
|
||||
it: browser-only, no accounts of its own. MySpeed has no authentication
|
||||
built in, so anyone who reaches the port can change the schedule and read the
|
||||
history.
|
||||
|
||||
## Links
|
||||
|
||||
- Upstream: <https://github.com/gnmyt/MySpeed>
|
||||
- Website and docs: <https://myspeed.dev> · <https://docs.myspeed.dev/setup/linux>
|
||||
- Image: <https://hub.docker.com/r/germannewsmaker/myspeed>
|
||||
@@ -0,0 +1,24 @@
|
||||
name: myspeed
|
||||
|
||||
services:
|
||||
myspeed:
|
||||
# Note what `latest` means here: upstream's last tagged release is
|
||||
# v1.0.9 from May 2024, and `latest` still points at that same image.
|
||||
# Active development ships as `development` (rebuilt Aug 2026), so
|
||||
# set MYSPEED_TAG=development in .env if you want the current build.
|
||||
image: germannewsmaker/myspeed:${MYSPEED_TAG:-latest}
|
||||
container_name: myspeed
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-5216}:5216"
|
||||
|
||||
volumes:
|
||||
# History database and every setting MySpeed has. This directory
|
||||
# is the entire backup -- nothing in it regenerates.
|
||||
- ${DATA_PATH:-./data}:/myspeed/data
|
||||
# MySpeed schedules tests with cron expressions and reads no TZ
|
||||
# variable, so container time decides when they run -- UTC by
|
||||
# default. Uncomment to follow the host clock instead.
|
||||
#- /etc/localtime:/etc/localtime:ro
|
||||
#- /etc/timezone:/etc/timezone:ro
|
||||
Reference in New Issue
Block a user