Compare commits

..

11 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
Goyban 04e84fde7a docs: explain the proxy target address in the forward auth example 2026-09-03 14:27:22 +02:00
Goyban 2f678693bf added Gitea 2026-09-03 14:26:31 +02:00
Goyban cd1e3f836d docs: fill in the Authentik SSO guide
Forward auth with the Caddyfile used in front of Firefly III, the native
OIDC endpoint reference, group mapping, and the gotchas collected while
setting up jellyfin and audiobookshelf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:11:17 +02:00
Goyban 50f9aff43a Document Authentik forwards auth for apps without SSO support, like Firefly iii 2026-09-01 10:02:28 +02:00
27 changed files with 1719 additions and 19 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 |
+198 -16
View File
@@ -1,6 +1,8 @@
# Single sign-on with Authentik # Single sign-on with Authentik
TODO: fill in as you go. Outline below so the shape is decided in advance. One login for everything self-hosted here, and one place to revoke it. The
services themselves stay unaware — nothing in any `compose.yaml` in this repo
mentions Authentik.
## Two ways to protect a service ## Two ways to protect a service
@@ -16,30 +18,210 @@ fallback.
## Native OIDC — the shape ## Native OIDC — the shape
TODO: provider + application setup in Authentik, then the four values every Use this wherever the app supports it. You get real accounts, real logout, and
app asks for: clients that aren't browsers keep working.
- Issuer / discovery URL ### In Authentik
- Client ID
- Client secret → `.env`, never committed
- Redirect URI
## Forward auth — the shape **Applications → Create**, provider type **OAuth2/OpenID Connect**. Three things
to note as you go:
TODO: the Caddy snippet, and which services you apply it to. - the application **slug** — it appears in half the URLs below
- the **client ID** and **client secret**
- pick a **signing key**, or the app can't validate the token it gets back
Then add the **redirect URIs**, which is where this usually goes wrong — see the
gotchas.
### The four values every app asks for
| Value | Where it comes from |
|-------|--------------------|
| Issuer / discovery URL | `https://authentik.example.com/application/o/<slug>/` |
| Client ID | Authentik, on the provider |
| Client secret | Authentik → the service's `.env`, never committed |
| Redirect URI | The app tells you — don't guess |
Most apps take the issuer URL and discover the rest themselves; Audiobookshelf
calls this **Auto-populate**. When one wants the endpoints spelled out
individually:
| Endpoint | Path |
|----------|------|
| OpenID configuration | `/application/o/<slug>/.well-known/openid-configuration` |
| Authorization | `/application/o/authorize/` |
| Token | `/application/o/token/` |
| User info | `/application/o/userinfo/` |
| End session | `/application/o/<slug>/end-session/` |
| JWKS | `/application/o/<slug>/jwks/` |
Note which paths carry the slug and which don't: authorize, token and userinfo
are shared across every application, while discovery, JWKS and logout are
per-application. Putting the slug where it doesn't belong gives a 404 that looks
like the provider is broken.
Worked examples in this repo:
[audiobookshelf](../audiobookshelf/README.md#single-sign-on-with-authentik) and
[jellyfin](../jellyfin/README.md#single-sign-on-with-authentik).
## Forward auth — putting an app with no SSO behind Authentik
This is the fallback, and it's what most self-hosted apps need. The app itself
learns nothing and changes nothing: **Caddy asks Authentik whether to allow the
request before it ever reaches the container.** An app with no accounts at all,
or one with its own login you can't replace, ends up behind a real SSO login
page either way.
### In Authentik
1. **Providers → Create → Proxy Provider**, mode **Forward auth (single
application)**. The external host is the public URL — `https://firefly.domain.com`.
2. **Applications → Create**, and bind it to that provider.
3. **Outposts** — add the application to an outpost. The built-in *embedded
outpost* is the one already listening on port **9000**, which is what the
Caddyfile below points at.
Step 3 is the one that gets forgotten. Provider and application exist, the
Caddyfile is right, and every request still fails because the outpost was never
told the application is its responsibility.
### In Caddy
```caddyfile
firefly.domain.com {
# directive execution order is only as stated if enclosed with route.
route {
# always forward outpost path to actual outpost
reverse_proxy /outpost.goauthentik.io/* http://127.0.0.1:9000
# forward authentication to outpost
forward_auth http://127.0.0.1:9000 {
uri /outpost.goauthentik.io/auth/caddy
# capitalization of the headers is important, otherwise they will be empty
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
# optional, in this config trust all private ranges, should probably be set to the outposts IP
trusted_proxies private_ranges
}
# actual site configuration below, for example
reverse_proxy 10.10.0.2:8564
}
}
```
Only the last line changes per service: swap the address and port, and the
hostname at the top. Everything between is identical for every app.
`10.10.0.2` there is a **private address, not a public one** — in this setup a
WireGuard host, so Caddy reaches the app over the tunnel rather than across the
internet. Whatever address your proxy uses to reach the service goes there: a
LAN IP, a WireGuard or Tailscale peer, or a container name if Caddy runs in the
same Compose project. The only requirement is that Caddy can reach it and the
outside world can't.
### Why each piece is there
**`route { }` is not optional.** Caddy normally executes directives in its own
fixed order, not the order you wrote them. Without `route`, the final
`reverse_proxy` can run before `forward_auth`, and the app is served to
unauthenticated visitors. The config *looks* correct and protects nothing —
which is the worst possible failure mode for this feature, so wrap it and check
in a private window.
**The `/outpost.goauthentik.io/*` line comes first** because the login flow
itself lives on that path. If it fell through to the app, the browser would be
sent to a URL the app doesn't serve and the redirect loop would never resolve.
**`copy_headers` capitalization is load-bearing** — get it wrong and the headers
arrive empty rather than missing, so an app that reads them sees a blank
username instead of an error.
**`127.0.0.1:9000` assumes Caddy and Authentik are on the same host.** If Caddy
runs on a VPS and Authentik on the NAS, this is the line to change — and
`trusted_proxies private_ranges` should then name the outpost's actual address
rather than trusting every private range.
### What you give up
- **The app doesn't know who you are.** It sees an authenticated-by-someone
request. Apps with their own accounts still show their own login, so you get
**two logins** — Authentik, then the app. Some apps can read the
`X-Authentik-*` headers and skip theirs; most can't.
- **Non-browser clients break.** Mobile apps, API clients and anything doing
`curl` can't complete an interactive login flow. This is why Jellyfin and
Audiobookshelf use native OIDC instead — see
[jellyfin](../jellyfin/README.md) and
[audiobookshelf](../audiobookshelf/README.md). If a service has an API you
actually call, forward auth in front of it will stop that too.
- **It is all-or-nothing per hostname.** Every path behind that block requires
login, including webhooks and health checks.
### Where I use it
- **[Firefly III](../firefly/README.md)** — the config above is verbatim what
runs in front of it. Firefly keeps its own login underneath, so it's two
logins, and for bank data that's a trade worth making.
- **[copyparty](../copyparty/README.md)** and other apps with their own account
system, where the second login is the price of not exposing the app's login
form to the internet.
Prefer native OIDC wherever the app supports it. Reach for this when it doesn't.
## Groups and roles ## Groups and roles
TODO: how you map Authentik groups to per-app roles, and the admin/user The mechanism is the same everywhere: Authentik puts your group membership in
split. the token, and the app decides what that means.
**You usually don't need a custom mapping.** The standard `profile` scope
already includes group membership, so most apps see your groups with nothing
configured. Reach for a custom **scope mapping** (Customization → Property
Mappings) only when an app wants the claim under a different name or in a
different shape.
**Each app consumes it differently**, and that's the part to check per service —
Audiobookshelf has group and permission claim fields under its OpenID settings;
the Jellyfin SSO-Auth plugin has its own role and admin filters. There is no
single setting that makes "the admins group" mean administrator everywhere.
**The split worth setting up:** one group for ordinary access, one for admin,
and grant the app's admin role only to the second. The reason is the failure
mode below — if everyone who can log in is an admin, an SSO misconfiguration
isn't just an outage, it's an escalation.
TODO: the actual group names and per-app role mapping used here, once the same
shape has survived a few services.
## Gotchas ## Gotchas
TODO: collect these as you hit them. Known ones worth writing down: - **Keep a local fallback admin until the flow is proven.** If the only account
that can administer an app is behind SSO and SSO breaks, you have locked
- Locking yourself out of an app whose only admin is now behind SSO — keep a yourself out of the thing you need in order to fix it. Prove the login works,
local fallback admin until the flow is proven. in a private window, *before* removing the local account.
- Redirect URI mismatches, which usually surface as a generic error. - **Redirect URI mismatches surface as a generic error** that never names the
URI. Read the exact URIs off the application's own settings page rather than
copying them from a guide — Audiobookshelf prints them, and
[its README](../audiobookshelf/README.md#the-redirect-uris-are-where-this-goes-wrong)
documents a case where the published guide was wrong for that install.
- **Behind TLS, apps build `http://` redirect URLs.** The proxy terminates TLS,
the app sees a plain HTTP request, and the callback it generates is insecure —
so the login fails halfway through. Every app has its own name for the fix;
Jellyfin's SSO-Auth plugin calls it
[Scheme Override](../jellyfin/README.md#the-setting-that-cost-me-the-time).
Firefly's `TRUSTED_PROXIES=**` is the same class of setting.
- **`email_verified` is `False` by default.** Some apps refuse to authenticate a
user whose email isn't marked verified, and the resulting error rarely says
so. Fix it in the scope mapping.
- **Forward auth breaks everything that isn't a browser.** Mobile apps and API
clients can't complete an interactive login. Check what talks to a service
before putting it behind forward auth — this is the single biggest reason to
prefer native OIDC.
- **Adding the application to an outpost is a separate step** from creating the
provider and the application, and forgetting it fails in a way that looks like
a Caddy problem.
- **The client secret belongs in the service's `.env`**, which is git-ignored.
Never in `compose.yaml`, which is committed.
## Related ## Related
+12 -3
View File
@@ -221,9 +221,18 @@ behind a proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the default choice. - [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the default choice.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for this one. It's a text - [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for this one. It's a text
app; nothing here bumps the ~100 MB body cap unless you attach large files. app; nothing here bumps the ~100 MB body cap unless you attach large files.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — **think first.** - [Single sign-on with Authentik](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
This is your bank data, and Firefly's own login is the only thing in front of — **what I do.** Firefly has no OIDC support, so it sits behind Authentik
it today. Keep a working local admin until any SSO flow is proven. forward auth: Caddy checks with Authentik before the request reaches the
container. That means two logins, Authentik then Firefly, which for bank data
is a trade worth making — the app's own login form is never exposed to the
internet. The exact Caddyfile is in that doc.
Two things to keep in mind here specifically. Firefly's **API is behind the
same wall**, so external API clients stop working (the bundled importer is
unaffected — it talks to `app:8080` inside the compose network, never through
the proxy). And keep a working local admin until the flow is proven; locking
yourself out of your own ledger is an unpleasant afternoon.
## Links ## Links
+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
# Every value here has a default in compose.yaml, so a fresh clone runs
# with no .env at all.
# ─── Identity ───────────────────────────────────────────────────────
# Become USER_UID / USER_GID in the container, which is how Gitea's `git`
# user is made to match the owner of the bind-mounted data directory.
# Wrong values mean Gitea cannot write its own data.
#
# id -u ; id -g
#
#PUID=1000
#PGID=1000
# ─── Paths ──────────────────────────────────────────────────────────
# Repositories, the SQLite database and conf/app.ini. This is the whole
# backup -- nothing in it regenerates.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
#PORT=3000
# Host port for SSH clone; the container always listens on 22. Port 22 on
# the host belongs to the host's own sshd, which is why this isn't 22.
#
# If you change it, also uncomment GITEA__server__SSH_PORT in compose.yaml
# so the clone URLs Gitea advertises match -- otherwise it hands out
# port 22 and clients land on the host's sshd instead.
#SSH_PORT=222
# ─── Timezone ───────────────────────────────────────────────────────
# There is no TZ variable here on purpose: compose.yaml mounts the host's
# /etc/timezone and /etc/localtime read-only, so the container follows the
# host automatically.
+129
View File
@@ -0,0 +1,129 @@
# Gitea
Self-hosted Git with a web UI, issues and pull requests. A few hundred MB of Go
that starts in a second, where the alternative is a JVM and a shopping list of
services.
This is the stack the rest of this repo is developed against — the compose files
you're reading live in a Gitea instance, and GitHub is the mirror.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # PUID/PGID must match the owner of ./gitea
docker compose up -d
```
Open `http://<host>:3000`. You get Gitea's **install wizard**, not a login page —
it writes `app.ini` from what you enter and that's the moment the instance is
configured. Two things to settle there rather than later:
1. **Server and Third-Party Service Settings → Base URL.** Set it to the URL
people will actually use. Wrong here means every clone URL it hands out is
wrong.
2. **Create the admin account** at the bottom of the wizard. If you skip it,
*the first user to register becomes the administrator* — which is fine on a
LAN and not fine on anything reachable.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 3000 | http | Web UI and the HTTP(S) clone endpoint |
| 222 | ssh | SSH clone endpoint (container listens on 22) |
## Why it looks like this
**It started as upstream's example** —
[Install with Docker](https://docs.gitea.com/installation/install-with-docker/)
— with four changes to bring it in line with the rest of this repo, listed at
the end of this section. Everything else is upstream's, and what follows is what
those choices actually mean.
**SQLite by default.** There's no database container here, and none is needed:
with no `GITEA__database__DB_TYPE` set, Gitea uses SQLite inside `/data`. For one
person or a small team that is genuinely the right answer — one less container,
one less backup, and the performance ceiling is far above what a personal
instance reaches. Upstream documents MySQL and PostgreSQL variants using
`GITEA__database__DB_TYPE=mysql` / `postgres`; switch if you outgrow it, and do
it before there's data to migrate.
**`USER_UID` / `USER_GID` exist because of the bind mount.** `./gitea:/data` is a
host directory, so the `git` user inside the container has to match whoever owns
it. Upstream notes that named volumes avoid the problem entirely — Docker manages
ownership — at the cost of your data being somewhere less obvious.
**SSH is on 222, not 22**, because 22 on the host belongs to the host's own sshd.
This is the source of the most common Gitea confusion: see the gotchas.
**Environment variables map to `app.ini` sections** with the pattern
`GITEA__section__KEY`. So `GITEA__server__ROOT_URL` sets `ROOT_URL` under
`[server]`. Useful to know, because the wizard only exposes a fraction of what
Gitea can be told.
### What was changed from upstream's example
Four things, all for the same reason — this file is published, and a fresh clone
has to work:
- **`${PUID:-1000}` / `${PGID:-1000}` instead of bare `${PUID}`.** Compose
interpolates an unset variable to an *empty string* and starts the container
anyway, so upstream's version hands Gitea `USER_UID=` on any clone without a
`.env`. Defaults are the convention here for exactly this reason.
- **`restart: unless-stopped` instead of `always`**, so a deliberate stop
survives a reboot.
- **`${DATA_PATH:-./data}` instead of `./gitea`**, which also stops the data
landing at the slightly comic `gitea/gitea/`.
- **Pinned to `1.27.3` instead of `:latest`**, because Gitea runs schema
migrations between releases.
Two settings are also present but commented out —
`GITEA__server__SSH_PORT` and `GITEA__server__ROOT_URL` — because both are
things you will need the moment you publish this beyond localhost. See the
gotchas.
## Gotchas
- **SSH clone URLs advertise the wrong port by default.** Gitea knows it listens
on 22 *inside* the container; it has no idea you published it on 222. So it
offers `git@host:user/repo.git`, which sends the client to port 22 — the host's
sshd — and the failure looks like a key problem rather than a port problem.
Uncomment `GITEA__server__SSH_PORT` in `compose.yaml` (it tracks `SSH_PORT`,
so the two can't drift), or set the SSH port in the wizard.
- **Behind a reverse proxy, `ROOT_URL` is the setting that matters.** Get it
wrong and the UI works fine while every clone URL, webhook and OAuth redirect
points somewhere else. See [reverse proxy](../docs/reverse-proxy.md), which
already lists Gitea as a service needing this.
- **The install wizard runs once.** After it writes `app.ini`, that file wins —
changing an environment variable afterwards does *not* change a setting the
wizard already recorded. Edit `gitea/gitea/conf/app.ini` (or delete it and
reinstall) if you need to change course.
- **First registered user becomes admin** if you didn't create an admin in the
wizard. Disable registration once your accounts exist:
`GITEA__service__DISABLE_REGISTRATION=true`.
- **Back up `./gitea`.** It holds every repository, the SQLite database, and
`conf/app.ini`. There is nothing else to back up, and nothing in it
regenerates.
- **Mirror pushes need credentials that outlive your session.** If you push from
here to GitHub, that's an access token or deploy key stored in Gitea, not your
laptop's agent.
## Exposing it
Publishes plain HTTP; TLS belongs at the proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — **needs `ROOT_URL`**,
see above. HTTP clone and the web UI both go through it.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — works for the web UI and HTTP
clone, but **SSH clone won't pass** and the ~100 MB body cap will meet you at
the first large push. Fine for browsing, awkward as your only route.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Gitea speaks OIDC
natively, so use that rather than forward auth, which would break `git` over
HTTP as surely as it breaks any other non-browser client.
## Links
- Install with Docker: <https://docs.gitea.com/installation/install-with-docker/>
- Configuration cheat sheet: <https://docs.gitea.com/administration/config-cheat-sheet>
- Image: <https://hub.docker.com/r/gitea/gitea>
+40
View File
@@ -0,0 +1,40 @@
networks:
gitea:
external: false
services:
server:
# Pinned. Gitea runs schema migrations between releases, so an unattended
# pull picking up a new minor is not something to meet by surprise.
image: docker.gitea.com/gitea:1.27.3
container_name: gitea
restart: unless-stopped
networks:
- gitea
environment:
# Gitea's `git` user is made to match the owner of the bind-mounted data
# directory. Defaults so a fresh clone runs with no .env at all -- bare
# ${PUID} would interpolate to an empty string and start anyway.
- USER_UID=${PUID:-1000}
- USER_GID=${PGID:-1000}
# Gitea knows it listens on 22 inside the container and has no idea it is
# published on 222, so the SSH clone URLs it hands out send clients to the
# host's own sshd. Uncomment to match the published port below.
#- GITEA__server__SSH_PORT=${SSH_PORT:-222}
# Set this once you're behind a proxy, or every clone URL, webhook and
# OAuth redirect points at the wrong host.
#- GITEA__server__ROOT_URL=https://git.example.com/
volumes:
# Every repository, the SQLite database and conf/app.ini. Back this up --
# nothing in it regenerates.
- ${DATA_PATH:-./data}:/data
# Timezone comes from the host rather than a TZ variable.
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${PORT:-3000}:3000"
# 22 on the host belongs to the host's own sshd, hence 222.
- "${SSH_PORT:-222}:22"
+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