Compare commits

...

7 Commits

Author SHA1 Message Date
Goyban 32b997d2c3 added MySpeed 2026-09-25 15:45:26 +02:00
Goyban 595c20a0a1 added Immich 2026-09-23 15:12:08 +02:00
Goyban 9355beaee2 added LibreSpeed 2026-09-23 15:11:31 +02:00
Goyban 8ff5428bb1 Added simple http server 2026-09-16 10:34:30 +02:00
Goyban f1f184c36e add gitea, gluetun and heimdall to the service table 2026-09-07 10:42:08 +02:00
Goyban 3c2f03d130 added Heimdall
Documents ALLOW_INTERNAL_REQUESTS=false, which blocks enhanced-app
lookups to private IPs by default and makes every LAN tile fail silently.
Also that the data dir holds the API keys of every enhanced app.
2026-09-07 10:41:55 +02:00
Goyban 18e468c88f added gluetun with qBittorrent and pyload-ng
VPN kill switch via shared network namespace, TorGuard WireGuard through
gluetun's custom provider. compose.yaml is the maintained version;
compose.personal.yaml is the variant actually running, kept as-is.

Verified against gluetun's own settings summary: SOCKSPROXY/SOCKSPROXY_PORT
are not real variables and the SOCKS5 server never starts; HTTPPROXY_PORT is
deprecated; pyload-ng listens on 8000, not 8880.

.gitignore: ignore wg*.conf and *.ovpn everywhere, because add gluetun/compose.yaml gluetun/compose.personal.yaml gluetun/.env.example gluetun/README.md .gitignore/init/** would
otherwise commit a WireGuard private key dropped in a service's init/ dir.
Un-ignore compose.personal.yaml narrowly, so compose.override.yaml stays out.
2026-09-07 10:41:40 +02:00
22 changed files with 1306 additions and 0 deletions
+11
View File
@@ -7,6 +7,10 @@
# ─── …except the files that define a stack ────────────────────────── # ─── …except the files that define a stack ──────────────────────────
!*/compose.yaml !*/compose.yaml
!*/docker-compose.yaml !*/docker-compose.yaml
# A service's alternative compose file, published as documentation. Narrow on
# purpose: `!*/compose.*.yaml` would also un-ignore compose.override.yaml,
# which is exactly where people keep local secrets.
!*/compose.personal.yaml
!*/.env.example !*/.env.example
!*/README.md !*/README.md
@@ -26,3 +30,10 @@
*.crt *.crt
secrets/ secrets/
TODO TODO
# VPN configs carry private keys. Named explicitly because the `!*/init/**`
# rule above would otherwise happily commit one dropped in a service's
# init/ directory -- which is exactly where this repo's own conventions
# tell you to put runtime config fragments.
wg*.conf
*.ovpn
+7
View File
@@ -42,7 +42,14 @@ handled separately — see [Exposing services](#exposing-services).
| [copyparty](copyparty/) | 3923 | File server built for big/resumable transfers, WebDAV, media index | | [copyparty](copyparty/) | 3923 | File server built for big/resumable transfers, WebDAV, media index |
| [filebrowser](filebrowser/) | 3672 | Web file manager for one directory tree (FileBrowser Quantum fork) | | [filebrowser](filebrowser/) | 3672 | Web file manager for one directory tree (FileBrowser Quantum fork) |
| [firefly](firefly/) | 8564 | Personal finance / double-entry ledger. Also 8566 for the data importer | | [firefly](firefly/) | 8564 | Personal finance / double-entry ledger. Also 8566 for the data importer |
| [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 | | [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 | | [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 | | [overleaf](overleaf/) | 8020 | LaTeX editor. Four containers: app, Mongo (replica set), Redis, init |
+38
View File
@@ -0,0 +1,38 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
#
# Only TEMPDISK is required -- everything else has a default in
# compose.yaml. (compose.personal.yaml has no defaults at all: it uses
# bare ${PUID} etc., which Compose turns into empty strings rather than
# failing, so with that file set all four.)
# ─── Identity ───────────────────────────────────────────────────────
# Owner of the downloads directory and the two config directories.
# id -u ; id -g
#PUID=1000
#PGID=1000
#TZ=Europe/Paris
# ─── Where downloads land ───────────────────────────────────────────
# The parent of downloads/torrents and downloads/pyload. Point this at
# the disk with room on it -- this is the one path that cannot have a
# sensible default.
TEMPDISK=/mnt/tank
# ─── Proxy authentication ───────────────────────────────────────────
# Both proxies are UNAUTHENTICATED unless these are set -- anyone who can
# reach ports 9980/9998 can route traffic through your VPN account. Set
# both (gluetun needs user and password together) and authentication turns
# on for the HTTP and SOCKS5 proxies at once.
#PROXY_USER=
#PROXY_PASSWORD=
# ─── Ports ──────────────────────────────────────────────────────────
#QBT_PORT=9880
#PYLOAD_PORT=8880
#SOCKS_PORT=9980
#HTTPPROXY_PORT=9998
# ─── NOT in this file ───────────────────────────────────────────────
# Your WireGuard credentials do NOT belong here. They live in wg0.conf
# next to compose.yaml, which is git-ignored (see the README). Do not
# move that file into init/ and do not paste its keys into .env.
+223
View File
@@ -0,0 +1,223 @@
# gluetun + qBittorrent + pyload-ng
A VPN container with two download clients living inside its network stack. The
point is the **kill switch**: qBittorrent and pyload-ng have no network of their
own, so if the tunnel drops they lose connectivity entirely rather than falling
back to your real address.
Also exposes gluetun's HTTP and SOCKS5 proxies, so other machines and browser
extensions (SwitchyOmega and friends) can route through the same tunnel without
running a VPN client themselves.
Configured for **TorGuard WireGuard** via gluetun's `custom` provider.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # TEMPDISK is the one you must set
# Get a WireGuard config from TorGuard's config generator, save it as
# wg0.conf NEXT TO THIS FILE. It is git-ignored. Do not put it in init/.
cp ~/Downloads/torguard-wg.conf ./wg0.conf
docker compose up -d
docker compose logs -f gluetun # watch for the public IP line
```
Verify the tunnel is actually carrying traffic before you seed anything:
```bash
docker compose exec gluetun wget -qO- https://ipinfo.io/ip # should be TorGuard's
curl -x http://localhost:9998 https://ipinfo.io/ip # same, via the HTTP proxy
```
## Ports
Every port is published on the **gluetun** service, because the other two
containers have no network namespace of their own.
| Host | Container | Purpose |
|------|-----------|---------|
| 9880 | 9880 | qBittorrent web UI |
| 8880 | 8000 | pyload-ng web UI |
| 9980 | 1080 | SOCKS5 proxy |
| 9998 | 8888 | HTTP proxy |
In `compose.personal.yaml` two of these differ: pyload is published `8880:8880`
against a container that listens on 8000, and the SOCKS5 server isn't running at
all. See the gotchas.
## Two compose files
**[`compose.yaml`](compose.yaml)** is the maintained one and what
`docker compose up` uses.
**[`compose.personal.yaml`](compose.personal.yaml)** is the older variant still
running on my own box. It works and its HTTP proxy is in daily use, so it's kept
as-is rather than tidied — but it has the quirks listed under gotchas below.
Compose won't load it unless you name it:
```bash
docker compose -f compose.personal.yaml up -d
```
What the maintained file changes:
| Change | Why |
|--------|-----|
| `SOCKS5_ENABLED` / `SOCKS5_LISTENING_ADDRESS` | `SOCKSPROXY*` are not gluetun variables; the SOCKS5 server never starts |
| `HTTPPROXY_LISTENING_ADDRESS` | `HTTPPROXY_PORT` works but logs a deprecation warning |
| `8880:8000` | pyload-ng listens on 8000; its port isn't settable by env var |
| `HTTPPROXY_USER` / `SOCKS5_USER` + passwords | opt-in proxy authentication, off unless set in `.env` |
| `qmcgaw/gluetun:v3.41.3` | pinned; this container holds the tunnel |
| `${PUID:-1000}`, `${TEMPDISK:?…}` | defaults where sensible, loud failure where a value can't be guessed |
| `condition: service_healthy` | gluetun ships a healthcheck; wait for the tunnel instead of racing it |
| `lscr.io/linuxserver/qbittorrent` | matches pyload-ng's registry; same image |
Same design, same ports, same volumes.
## Other VPN providers
Nothing here is TorGuard-specific except `wg0.conf`. gluetun natively supports
around twenty providers, and for most of them you delete the file mount and set
two or three variables instead:
```yaml
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
- SERVER_CITIES=Amsterdam
```
**Native WireGuard** — the easy path: AirVPN, FastestVPN, IVPN, Mullvad,
NordVPN, ProtonVPN, Surfshark, Windscribe.
**Native OpenVPN** — every supported provider, usually just `OPENVPN_USER` and
`OPENVPN_PASSWORD`: the list above plus Cyberghost, ExpressVPN, Giganews,
HideMyAss, IPVanish, Privado, Private Internet Access, PrivateVPN, PureVPN,
SlickVPN, TorGuard, VPNSecure, VPN Unlimited, VyprVPN.
**WireGuard via the custom provider** — Cyberghost, Private Internet Access,
PrivateVPN, PureVPN, TorGuard, VPN Unlimited and VyprVPN have no native
WireGuard support in gluetun, so they take the `wg0.conf` route this stack uses.
That last line is why this stack looks the way it does: **TorGuard *is* a
built-in provider (`VPN_SERVICE_PROVIDER=torguard`), but only for OpenVPN.**
Wanting TorGuard *and* WireGuard is precisely the case gluetun's own
documentation sends to the custom provider. It isn't a workaround — it's the
documented path.
Switching provider changes nothing else: the kill switch, the shared network
namespace and both proxies work the same way regardless of who terminates the
tunnel. Check your provider's page in
[the gluetun wiki](https://github.com/qdm12/gluetun-wiki) for its exact
variables.
## Why it looks like this
**`network_mode: "service:gluetun"` is the whole design.** qBittorrent and
pyload-ng don't get their own network stack — they share gluetun's. That's what
makes the kill switch absolute: there is no route out except the tunnel, so a
misconfiguration fails closed rather than leaking. The cost is that those
containers **cannot declare their own `ports:`**; Docker refuses it. Every port
they need is published on gluetun instead, which is why the list above looks
like it belongs to the wrong service.
**gluetun's firewall is on by default.** Confirmed in its startup summary
(`Firewall settings: Enabled: yes`) — you don't have to configure a kill switch,
you have to avoid breaking the one that's already there.
**`VPN_SERVICE_PROVIDER=custom` plus a mounted `wg0.conf`.** TorGuard isn't one
of gluetun's built-in providers, so rather than translating their config into
`WIREGUARD_PRIVATE_KEY` / `VPN_ENDPOINT_IP` environment variables, the whole
config file is mounted at `/gluetun/wireguard/wg0.conf` and gluetun parses it.
Verified working: gluetun reads the endpoint, interface address and keys
straight out of that file. It also keeps the private key out of `docker inspect`
and out of your shell history, which the environment-variable approach does not.
**`:latest` on all three images**, which is upstream's habit rather than a
decision. Worth pinning gluetun in particular — it's the one holding the tunnel.
## Security: the proxies are unauthenticated by default
Both proxies ship with no credentials, so **anyone who can reach ports 9998 or
9980 can route traffic through your VPN account**. On a trusted LAN that may be
exactly what you want — it's what makes them useful from a browser extension on
another machine. It is not something to expose beyond that, and not something to
forget you did.
`compose.yaml` has the variables wired already; set both in `.env` and
authentication switches on for both proxies at once:
```bash
PROXY_USER=someone
PROXY_PASSWORD=a-long-random-string
```
Leave them unset and behaviour is unchanged. gluetun needs the user *and*
password together — one alone does nothing.
Never port-forward these from your router. An open proxy on the public internet
is found within hours, and everything that follows is attributed to your VPN
account.
## Gotchas
- **`SOCKSPROXY` is not a gluetun variable.** Neither is `SOCKSPROXY_PORT`.
gluetun ignores both silently — no warning — so the SOCKS5 server never
starts and its published port maps to nothing. Its own settings summary is
the proof: `SOCKS5 proxy server settings: Enabled: no`. The real names are
`SOCKS5_ENABLED=on` and `SOCKS5_LISTENING_ADDRESS=:1080`. Fixed in
`compose.yaml`; still present in `compose.personal.yaml`, whose HTTP proxy
works fine and whose SOCKS port does not.
- **`HTTPPROXY_PORT` is deprecated but does work.** gluetun logs `WARN You are
using the old environment variable HTTPPROXY_PORT, please consider changing
it to HTTPPROXY_LISTENING_ADDRESS`, then brings the proxy up on 8888 anyway.
- **pyload-ng listens on 8000.** The LinuxServer image's web UI port is 8000
and is *not* settable by environment variable — only inside pyload's own
config in `./pyloadconfig`. So a `8880:8880` mapping reaches nothing unless
you already changed it there; `compose.yaml` publishes `8880:8000` instead.
(Click'n'Load on 9666 isn't published at all.)
- **`wg0.conf` must exist before the first `up`.** It's a bind mount of a
*file*; if the path is missing, Docker creates a **directory** there, gluetun
finds no config, and the error doesn't mention the mount. Same failure mode as
[overleaf's init script](../overleaf/README.md#running-it-on-openmediavault).
- **Never put `wg0.conf` in `init/`.** This repo's `.gitignore` un-ignores
`*/init/**` so that runtime config fragments get committed — which for a
WireGuard config means publishing your private key. `wg*.conf` and `*.ovpn`
are now explicitly ignored everywhere as a guard, but the habit is the real
protection.
- **Restarting gluetun cuts the other two off.** They share its namespace, so
when it goes away their network does too, and `depends_on` doesn't bring them
back. Restart the whole stack, not one service.
- **`TORRENTING_PORT=6881` is not published**, so incoming peer connections
don't arrive and you're limited to outgoing ones. Fixing that properly needs
port forwarding from TorGuard, then publishing the matching port on gluetun.
- **In `compose.personal.yaml` the variables have no defaults.** `${PUID}`,
`${PGID}`, `${TZ}` and `${TEMPDISK}` are bare, so Compose interpolates empty
strings and starts the containers anyway. `compose.yaml` uses
`${PUID:-1000}` and `${TEMPDISK:?…}`, which aborts with a readable message
instead.
- **Check for leaks after any change**, not just at setup. `docker compose exec
gluetun wget -qO- https://ipinfo.io/ip` is the ten-second version.
## Exposing it
**Don't.** This stack has no business being reachable from the internet: two
admin UIs with weak-by-default auth and two unauthenticated open proxies.
If you need the UIs remotely, reach them over your own VPN or a
[Cloudflare Tunnel](../cloudflare-tunnel/) with
[Authentik forward auth](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
in front — and leave the proxy ports off it entirely.
## Links
- gluetun (now at the passteque org): <https://github.com/passteque/gluetun>
- gluetun wiki: <https://github.com/qdm12/gluetun-wiki>
- TorGuard WireGuard + gluetun guide:
<https://www.torguard.net/support/articles/TorGuard-Software-Features/torguard-wireguard-gluetun-docker-setup.php>
- qBittorrent image: <https://docs.linuxserver.io/images/docker-qbittorrent/>
- pyload-ng image: <https://docs.linuxserver.io/images/docker-pyload-ng/>
+61
View File
@@ -0,0 +1,61 @@
services:
gluetun:
image: qmcgaw/gluetun
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
# Enable SOCKS5 proxy
- SOCKSPROXY=on
- SOCKSPROXY_PORT=1080
# Optional HTTP proxy
- HTTPPROXY=on
- HTTPPROXY_PORT=8888
ports:
- "9880:9880/tcp" # Qbittorrent Web UI
- "8880:8880/tcp" # PyLoad Web-UI
- "9980:1080/tcp" # Socks Proxy
- "9998:8888/tcp" # HTTP Proxy
volumes:
- /etc/localtime:/etc/localtime:ro
- ./wg0.conf:/gluetun/wireguard/wg0.conf
restart: unless-stopped
qbittorrent:
image: linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=9880
- TORRENTING_PORT=6881
volumes:
- ./config:/config
- '${TEMPDISK}/downloads/torrents:/downloads'
network_mode: "service:gluetun"
depends_on:
- gluetun
restart: unless-stopped
pyload-ng:
image: lscr.io/linuxserver/pyload-ng:latest
container_name: pyload-ng
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./pyloadconfig:/config
- ${TEMPDISK}/downloads/pyload:/downloads
network_mode: "service:gluetun"
depends_on:
- gluetun
restart: unless-stopped
+95
View File
@@ -0,0 +1,95 @@
# The maintained version of this stack -- this is what `docker compose up`
# uses. See compose.personal.yaml for the older variant still running on
# my own box, and the README for what differs between them.
services:
gluetun:
# Pinned. This container holds the tunnel; an unattended pull breaking it
# means the download clients lose all connectivity (by design).
image: qmcgaw/gluetun:v3.41.3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
# SOCKS5 proxy. The names matter: SOCKSPROXY / SOCKSPROXY_PORT are NOT
# gluetun variables -- it ignores them silently and the proxy never
# starts. Verified against gluetun's own settings summary.
- SOCKS5_ENABLED=on
- SOCKS5_LISTENING_ADDRESS=:1080
# HTTP proxy. HTTPPROXY_PORT still works but logs a deprecation warning;
# LISTENING_ADDRESS is the current name.
- HTTPPROXY=on
- HTTPPROXY_LISTENING_ADDRESS=:8888
# Proxy authentication. Empty means NO authentication -- anyone who can
# reach the published ports can use the VPN. Set both vars in .env to
# turn it on; leaving them unset keeps the current behaviour.
- HTTPPROXY_USER=${PROXY_USER:-}
- HTTPPROXY_PASSWORD=${PROXY_PASSWORD:-}
- SOCKS5_USER=${PROXY_USER:-}
- SOCKS5_PASSWORD=${PROXY_PASSWORD:-}
ports:
- "${QBT_PORT:-9880}:9880/tcp" # qBittorrent Web UI
# pyload-ng listens on 8000 inside the container and the port is not
# settable by environment variable -- only in pyload's own config.
- "${PYLOAD_PORT:-8880}:8000/tcp" # pyLoad Web UI
- "${SOCKS_PORT:-9980}:1080/tcp" # SOCKS5 proxy
- "${HTTPPROXY_PORT:-9998}:8888/tcp" # HTTP proxy
# Incoming BitTorrent peers. Needs a forwarded port from TorGuard, and
# the number here must match TORRENTING_PORT below.
#- "6881:6881/tcp"
#- "6881:6881/udp"
volumes:
- /etc/localtime:/etc/localtime:ro
# Bind mount of a FILE. It must exist before the first `up` or Docker
# creates a directory here and gluetun finds no config. Git-ignored.
- ./wg0.conf:/gluetun/wireguard/wg0.conf:ro
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
- WEBUI_PORT=9880
- TORRENTING_PORT=6881
volumes:
- ${CONFIG_PATH:-./config}:/config
- "${TEMPDISK:?set TEMPDISK in .env to the disk your downloads live on}/downloads/torrents:/downloads"
# No network of its own: everything goes through the tunnel, and there is
# no route out if the tunnel drops. This is also why it cannot declare
# its own `ports:` -- they live on gluetun above.
network_mode: "service:gluetun"
depends_on:
gluetun:
# gluetun ships a healthcheck; wait for the tunnel rather than racing it.
condition: service_healthy
restart: unless-stopped
pyload-ng:
image: lscr.io/linuxserver/pyload-ng:latest
container_name: pyload-ng
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
volumes:
- ${PYLOAD_CONFIG_PATH:-./pyloadconfig}:/config
- ${TEMPDISK}/downloads/pyload:/downloads
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
+29
View File
@@ -0,0 +1,29 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
#
# compose.yaml currently writes these as bare ${PUID} / ${PGID} / ${TZ},
# with no defaults, so a clone without a .env starts the container with
# empty values instead of failing. Set all three -- or add `:-` defaults
# to compose.yaml, which is what the rest of this repo does.
# ─── Identity ───────────────────────────────────────────────────────
# Must own the data directory, or Heimdall can't write its database.
# id -u ; id -g
PUID=1000
PGID=1000
TZ=Europe/Paris
# ─── Paths ──────────────────────────────────────────────────────────
# Mounted at /config. Holds the SQLite database, uploaded icons, the
# nginx config -- and the API keys of every "enhanced" app you add.
# Back it up; never commit it.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
#PORT=4080
#HTTPS_PORT=4043
# ─── Reaching your own LAN ──────────────────────────────────────────
# NOT set in compose.yaml, and the one you will most likely need.
# Heimdall blocks enhanced-app lookups to private/reserved IPs by
# default -- which is every service in your house. See the README.
#ALLOW_INTERNAL_REQUESTS=true
+96
View File
@@ -0,0 +1,96 @@
# Heimdall
A start page for everything else in this repo. Tiles that link to your services,
and for some of them an "enhanced" tile that polls the app's API and shows
something useful on the face of it — disk free, active downloads, unread count.
Actively maintained, despite its age: v2.8.3 landed in September 2026.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # PUID/PGID/TZ have no defaults in compose.yaml
docker compose up -d
```
Open `http://<host>:4080`. There is no login and no setup wizard — you get an
empty dashboard, and everything is configured by clicking around. Read the
security note below before you put anything on it.
## Ports
| Host | Container | Purpose |
|------|-----------|---------|
| 4080 | 80 | Web UI |
| 4043 | 443 | Heimdall's own HTTPS listener — see gotchas |
## Why it looks like this
**The LinuxServer image**, same reasoning as [jellyfin](../jellyfin/README.md):
`PUID`/`PGID`/`TZ`, `/config`, s6, one rebuild cadence. For a service this small
the win is that it behaves like everything else on the box.
**`./data` mounted at `/config`.** The names disagree because the repo's
convention is `data/` on the host and the image's convention is `/config` in the
container. Whatever it's called, it is the entire service: the SQLite database,
uploaded icons, the nginx config, and the API keys of every enhanced tile.
**Nothing else.** No database container, no cache, no environment variables
beyond the LinuxServer three. This is one of the genuinely simple stacks — which
is why the interesting content below is about what Heimdall *doesn't* tell you.
## Gotchas
- **Heimdall blocks requests to your own LAN by default.** This is the one that
wastes an afternoon. Enhanced tiles — the ones that show live data — make a
server-side request to the app's API, and the image ships
`ALLOW_INTERNAL_REQUESTS=false`, which refuses lookups to private or reserved
IP addresses. Every service in your house is on a private address, so every
enhanced tile fails, and the tile just sits there looking broken. Add
`ALLOW_INTERNAL_REQUESTS=true` to the environment block. It is not in
`compose.yaml` today.
- **There is no authentication out of the box.** Anyone who reaches port 4080
gets a map of your entire infrastructure, with working links. Heimdall can
password-protect itself, or you can put basic auth in front via the image's
nginx:
```bash
docker exec -it heimdall htpasswd -c /config/nginx/.htpasswd <username>
```
then uncomment the basic auth lines in `/config/nginx/site-confs/default.conf`
and restart. Better still, put [Authentik forward
auth](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
in front — a dashboard is the textbook case for it, since it has no accounts
worth integrating and every client is a browser.
- **Enhanced tiles store API keys in the data directory.** That's a Sonarr key,
a Nextcloud token, whatever you connected. `data/` is git-ignored by this
repo's deny-by-default rule — verified — but it means the directory is a
credential store, not just cosmetics. Back it up accordingly, and don't paste
it into a gist when asking for help.
- **Port 4043 is Heimdall's own TLS**, with a self-signed certificate. In this
repo TLS terminates at the proxy, so the mapping is redundant — drop the line
unless you're reaching it directly and are willing to click through the
warning.
- **`${PUID}`, `${PGID}` and `${TZ}` are bare.** No `:-1000` defaults, so a
clone with no `.env` gets empty strings and starts anyway — confirmed:
Compose emits `The "PUID" variable is not set. Defaulting to a blank string.`
and carries on. The rest of this repo writes `${PUID:-1000}`.
- **`:latest`.** Heimdall ships real releases (`2.8.3`, `v2.8.3-ls363`); pin one
if you'd rather not be surprised.
- **Back up `data/`.** Rebuilding a dashboard by hand is an hour you won't enjoy,
and the icons you uploaded aren't anywhere else.
## Exposing it
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the normal route; use
port 4080 and ignore 4043.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — **strongly
recommended if this leaves the LAN.** Forward auth is the right fit: no
accounts to map, browser-only clients, and it stops the dashboard being a
directory of your network for anyone who finds it.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine, it's a small HTML page.
## Links
- Upstream: <https://github.com/linuxserver/Heimdall>
- Image: <https://docs.linuxserver.io/images/docker-heimdall/>
+14
View File
@@ -0,0 +1,14 @@
services:
heimdall:
image: lscr.io/linuxserver/heimdall:latest
container_name: heimdall
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./data:/config
ports:
- 4080:80
- 4043:443
restart: unless-stopped
+20
View File
@@ -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
+122
View File
@@ -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>
+14
View File
@@ -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
+20
View File
@@ -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;
}
}
}
+40
View File
@@ -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
+121
View File
@@ -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>
+76
View File
@@ -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:
+50
View File
@@ -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
+93
View File
@@ -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>
+19
View File
@@ -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)
+30
View File
@@ -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.
+103
View File
@@ -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>
+24
View File
@@ -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