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