Compare commits

...

10 Commits

Author SHA1 Message Date
Goyban f1f184c36e add gitea, gluetun and heimdall to the service table 2026-09-07 10:42:08 +02:00
Goyban 3c2f03d130 added Heimdall
Documents ALLOW_INTERNAL_REQUESTS=false, which blocks enhanced-app
lookups to private IPs by default and makes every LAN tile fail silently.
Also that the data dir holds the API keys of every enhanced app.
2026-09-07 10:41:55 +02:00
Goyban 18e468c88f added gluetun with qBittorrent and pyload-ng
VPN kill switch via shared network namespace, TorGuard WireGuard through
gluetun's custom provider. compose.yaml is the maintained version;
compose.personal.yaml is the variant actually running, kept as-is.

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

.gitignore: ignore wg*.conf and *.ovpn everywhere, because add gluetun/compose.yaml gluetun/compose.personal.yaml gluetun/.env.example gluetun/README.md .gitignore/init/** would
otherwise commit a WireGuard private key dropped in a service's init/ dir.
Un-ignore compose.personal.yaml narrowly, so compose.override.yaml stays out.
2026-09-07 10:41:40 +02:00
Goyban 04e84fde7a docs: explain the proxy target address in the forward auth example 2026-09-03 14:27:22 +02:00
Goyban 2f678693bf added Gitea 2026-09-03 14:26:31 +02:00
Goyban cd1e3f836d docs: fill in the Authentik SSO guide
Forward auth with the Caddyfile used in front of Firefly III, the native
OIDC endpoint reference, group mapping, and the gotchas collected while
setting up jellyfin and audiobookshelf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:11:17 +02:00
Goyban 50f9aff43a Document Authentik forwards auth for apps without SSO support, like Firefly iii 2026-09-01 10:02:28 +02:00
Goyban 64773f0a16 Added firefly iii to main readme 2026-08-31 15:14:02 +02:00
Goyban d603e966e6 jellyfin: document authentik SSO via SSO-Auth plugin 2026-08-31 15:12:54 +02:00
Goyban 7622a0deb4 audiobookshelf: document authentik OIDC setup 2026-08-31 15:12:11 +02:00
16 changed files with 1091 additions and 23 deletions
+11
View File
@@ -7,6 +7,10 @@
# ─── …except the files that define a stack ──────────────────────────
!*/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
!*/README.md
@@ -26,3 +30,10 @@
*.crt
secrets/
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
+4
View File
@@ -41,6 +41,10 @@ handled separately — see [Exposing services](#exposing-services).
| [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 |
| [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 |
| [jellyfin](jellyfin/) | 8096 | Media streaming. Also 7359/udp for auto-discovery |
| [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 |
+73 -2
View File
@@ -81,8 +81,79 @@ Publishes a plain HTTP port; nothing here is proxy-aware.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — audio is far lighter than video,
but it's still media served through the proxy; read
[the limits](../docs/cloudflare-tunnel.md) before pointing a tunnel at it.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — as with Jellyfin,
the mobile apps won't understand a forward-auth login page.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Audiobookshelf
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
+198 -16
View File
@@ -1,6 +1,8 @@
# 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
@@ -16,30 +18,210 @@ fallback.
## Native OIDC — the shape
TODO: provider + application setup in Authentik, then the four values every
app asks for:
Use this wherever the app supports it. You get real accounts, real logout, and
clients that aren't browsers keep working.
- Issuer / discovery URL
- Client ID
- Client secret → `.env`, never committed
- Redirect URI
### In Authentik
## 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
TODO: how you map Authentik groups to per-app roles, and the admin/user
split.
The mechanism is the same everywhere: Authentik puts your group membership in
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
TODO: collect these as you hit them. Known ones worth writing down:
- Locking yourself out of an app whose only admin is now behind SSO — keep a
local fallback admin until the flow is proven.
- Redirect URI mismatches, which usually surface as a generic error.
- **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
yourself out of the thing you need in order to fix it. Prove the login works,
in a private window, *before* removing the local account.
- **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
+12 -3
View File
@@ -221,9 +221,18 @@ behind a proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the default choice.
- [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.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — **think first.**
This is your bank data, and Firefly's own login is the only thing in front of
it today. Keep a working local admin until any SSO flow is proven.
- [Single sign-on with Authentik](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
— **what I do.** Firefly has no OIDC support, so it sits behind Authentik
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
+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
# Every value here has a default in compose.yaml, so a fresh clone runs
# with no .env at all.
# ─── Identity ───────────────────────────────────────────────────────
# Become USER_UID / USER_GID in the container, which is how Gitea's `git`
# user is made to match the owner of the bind-mounted data directory.
# Wrong values mean Gitea cannot write its own data.
#
# id -u ; id -g
#
#PUID=1000
#PGID=1000
# ─── Paths ──────────────────────────────────────────────────────────
# Repositories, the SQLite database and conf/app.ini. This is the whole
# backup -- nothing in it regenerates.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
#PORT=3000
# Host port for SSH clone; the container always listens on 22. Port 22 on
# the host belongs to the host's own sshd, which is why this isn't 22.
#
# If you change it, also uncomment GITEA__server__SSH_PORT in compose.yaml
# so the clone URLs Gitea advertises match -- otherwise it hands out
# port 22 and clients land on the host's sshd instead.
#SSH_PORT=222
# ─── Timezone ───────────────────────────────────────────────────────
# There is no TZ variable here on purpose: compose.yaml mounts the host's
# /etc/timezone and /etc/localtime read-only, so the container follows the
# host automatically.
+129
View File
@@ -0,0 +1,129 @@
# Gitea
Self-hosted Git with a web UI, issues and pull requests. A few hundred MB of Go
that starts in a second, where the alternative is a JVM and a shopping list of
services.
This is the stack the rest of this repo is developed against — the compose files
you're reading live in a Gitea instance, and GitHub is the mirror.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # PUID/PGID must match the owner of ./gitea
docker compose up -d
```
Open `http://<host>:3000`. You get Gitea's **install wizard**, not a login page —
it writes `app.ini` from what you enter and that's the moment the instance is
configured. Two things to settle there rather than later:
1. **Server and Third-Party Service Settings → Base URL.** Set it to the URL
people will actually use. Wrong here means every clone URL it hands out is
wrong.
2. **Create the admin account** at the bottom of the wizard. If you skip it,
*the first user to register becomes the administrator* — which is fine on a
LAN and not fine on anything reachable.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 3000 | http | Web UI and the HTTP(S) clone endpoint |
| 222 | ssh | SSH clone endpoint (container listens on 22) |
## Why it looks like this
**It started as upstream's example** —
[Install with Docker](https://docs.gitea.com/installation/install-with-docker/)
— with four changes to bring it in line with the rest of this repo, listed at
the end of this section. Everything else is upstream's, and what follows is what
those choices actually mean.
**SQLite by default.** There's no database container here, and none is needed:
with no `GITEA__database__DB_TYPE` set, Gitea uses SQLite inside `/data`. For one
person or a small team that is genuinely the right answer — one less container,
one less backup, and the performance ceiling is far above what a personal
instance reaches. Upstream documents MySQL and PostgreSQL variants using
`GITEA__database__DB_TYPE=mysql` / `postgres`; switch if you outgrow it, and do
it before there's data to migrate.
**`USER_UID` / `USER_GID` exist because of the bind mount.** `./gitea:/data` is a
host directory, so the `git` user inside the container has to match whoever owns
it. Upstream notes that named volumes avoid the problem entirely — Docker manages
ownership — at the cost of your data being somewhere less obvious.
**SSH is on 222, not 22**, because 22 on the host belongs to the host's own sshd.
This is the source of the most common Gitea confusion: see the gotchas.
**Environment variables map to `app.ini` sections** with the pattern
`GITEA__section__KEY`. So `GITEA__server__ROOT_URL` sets `ROOT_URL` under
`[server]`. Useful to know, because the wizard only exposes a fraction of what
Gitea can be told.
### What was changed from upstream's example
Four things, all for the same reason — this file is published, and a fresh clone
has to work:
- **`${PUID:-1000}` / `${PGID:-1000}` instead of bare `${PUID}`.** Compose
interpolates an unset variable to an *empty string* and starts the container
anyway, so upstream's version hands Gitea `USER_UID=` on any clone without a
`.env`. Defaults are the convention here for exactly this reason.
- **`restart: unless-stopped` instead of `always`**, so a deliberate stop
survives a reboot.
- **`${DATA_PATH:-./data}` instead of `./gitea`**, which also stops the data
landing at the slightly comic `gitea/gitea/`.
- **Pinned to `1.27.3` instead of `:latest`**, because Gitea runs schema
migrations between releases.
Two settings are also present but commented out —
`GITEA__server__SSH_PORT` and `GITEA__server__ROOT_URL` — because both are
things you will need the moment you publish this beyond localhost. See the
gotchas.
## Gotchas
- **SSH clone URLs advertise the wrong port by default.** Gitea knows it listens
on 22 *inside* the container; it has no idea you published it on 222. So it
offers `git@host:user/repo.git`, which sends the client to port 22 — the host's
sshd — and the failure looks like a key problem rather than a port problem.
Uncomment `GITEA__server__SSH_PORT` in `compose.yaml` (it tracks `SSH_PORT`,
so the two can't drift), or set the SSH port in the wizard.
- **Behind a reverse proxy, `ROOT_URL` is the setting that matters.** Get it
wrong and the UI works fine while every clone URL, webhook and OAuth redirect
points somewhere else. See [reverse proxy](../docs/reverse-proxy.md), which
already lists Gitea as a service needing this.
- **The install wizard runs once.** After it writes `app.ini`, that file wins —
changing an environment variable afterwards does *not* change a setting the
wizard already recorded. Edit `gitea/gitea/conf/app.ini` (or delete it and
reinstall) if you need to change course.
- **First registered user becomes admin** if you didn't create an admin in the
wizard. Disable registration once your accounts exist:
`GITEA__service__DISABLE_REGISTRATION=true`.
- **Back up `./gitea`.** It holds every repository, the SQLite database, and
`conf/app.ini`. There is nothing else to back up, and nothing in it
regenerates.
- **Mirror pushes need credentials that outlive your session.** If you push from
here to GitHub, that's an access token or deploy key stored in Gitea, not your
laptop's agent.
## Exposing it
Publishes plain HTTP; TLS belongs at the proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — **needs `ROOT_URL`**,
see above. HTTP clone and the web UI both go through it.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — works for the web UI and HTTP
clone, but **SSH clone won't pass** and the ~100 MB body cap will meet you at
the first large push. Fine for browsing, awkward as your only route.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Gitea speaks OIDC
natively, so use that rather than forward auth, which would break `git` over
HTTP as surely as it breaks any other non-browser client.
## Links
- Install with Docker: <https://docs.gitea.com/installation/install-with-docker/>
- Configuration cheat sheet: <https://docs.gitea.com/administration/config-cheat-sheet>
- Image: <https://hub.docker.com/r/gitea/gitea>
+40
View File
@@ -0,0 +1,40 @@
networks:
gitea:
external: false
services:
server:
# Pinned. Gitea runs schema migrations between releases, so an unattended
# pull picking up a new minor is not something to meet by surprise.
image: docker.gitea.com/gitea:1.27.3
container_name: gitea
restart: unless-stopped
networks:
- gitea
environment:
# Gitea's `git` user is made to match the owner of the bind-mounted data
# directory. Defaults so a fresh clone runs with no .env at all -- bare
# ${PUID} would interpolate to an empty string and start anyway.
- USER_UID=${PUID:-1000}
- USER_GID=${PGID:-1000}
# Gitea knows it listens on 22 inside the container and has no idea it is
# published on 222, so the SSH clone URLs it hands out send clients to the
# host's own sshd. Uncomment to match the published port below.
#- GITEA__server__SSH_PORT=${SSH_PORT:-222}
# Set this once you're behind a proxy, or every clone URL, webhook and
# OAuth redirect points at the wrong host.
#- GITEA__server__ROOT_URL=https://git.example.com/
volumes:
# Every repository, the SQLite database and conf/app.ini. Back this up --
# nothing in it regenerates.
- ${DATA_PATH:-./data}:/data
# Timezone comes from the host rather than a TZ variable.
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${PORT:-3000}:3000"
# 22 on the host belongs to the host's own sshd, hence 222.
- "${SSH_PORT:-222}:22"
+38
View File
@@ -0,0 +1,38 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
#
# Only TEMPDISK is required -- everything else has a default in
# compose.yaml. (compose.personal.yaml has no defaults at all: it uses
# bare ${PUID} etc., which Compose turns into empty strings rather than
# failing, so with that file set all four.)
# ─── Identity ───────────────────────────────────────────────────────
# Owner of the downloads directory and the two config directories.
# id -u ; id -g
#PUID=1000
#PGID=1000
#TZ=Europe/Paris
# ─── Where downloads land ───────────────────────────────────────────
# The parent of downloads/torrents and downloads/pyload. Point this at
# the disk with room on it -- this is the one path that cannot have a
# sensible default.
TEMPDISK=/mnt/tank
# ─── Proxy authentication ───────────────────────────────────────────
# Both proxies are UNAUTHENTICATED unless these are set -- anyone who can
# reach ports 9980/9998 can route traffic through your VPN account. Set
# both (gluetun needs user and password together) and authentication turns
# on for the HTTP and SOCKS5 proxies at once.
#PROXY_USER=
#PROXY_PASSWORD=
# ─── Ports ──────────────────────────────────────────────────────────
#QBT_PORT=9880
#PYLOAD_PORT=8880
#SOCKS_PORT=9980
#HTTPPROXY_PORT=9998
# ─── NOT in this file ───────────────────────────────────────────────
# Your WireGuard credentials do NOT belong here. They live in wg0.conf
# next to compose.yaml, which is git-ignored (see the README). Do not
# move that file into init/ and do not paste its keys into .env.
+223
View File
@@ -0,0 +1,223 @@
# gluetun + qBittorrent + pyload-ng
A VPN container with two download clients living inside its network stack. The
point is the **kill switch**: qBittorrent and pyload-ng have no network of their
own, so if the tunnel drops they lose connectivity entirely rather than falling
back to your real address.
Also exposes gluetun's HTTP and SOCKS5 proxies, so other machines and browser
extensions (SwitchyOmega and friends) can route through the same tunnel without
running a VPN client themselves.
Configured for **TorGuard WireGuard** via gluetun's `custom` provider.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # TEMPDISK is the one you must set
# Get a WireGuard config from TorGuard's config generator, save it as
# wg0.conf NEXT TO THIS FILE. It is git-ignored. Do not put it in init/.
cp ~/Downloads/torguard-wg.conf ./wg0.conf
docker compose up -d
docker compose logs -f gluetun # watch for the public IP line
```
Verify the tunnel is actually carrying traffic before you seed anything:
```bash
docker compose exec gluetun wget -qO- https://ipinfo.io/ip # should be TorGuard's
curl -x http://localhost:9998 https://ipinfo.io/ip # same, via the HTTP proxy
```
## Ports
Every port is published on the **gluetun** service, because the other two
containers have no network namespace of their own.
| Host | Container | Purpose |
|------|-----------|---------|
| 9880 | 9880 | qBittorrent web UI |
| 8880 | 8000 | pyload-ng web UI |
| 9980 | 1080 | SOCKS5 proxy |
| 9998 | 8888 | HTTP proxy |
In `compose.personal.yaml` two of these differ: pyload is published `8880:8880`
against a container that listens on 8000, and the SOCKS5 server isn't running at
all. See the gotchas.
## Two compose files
**[`compose.yaml`](compose.yaml)** is the maintained one and what
`docker compose up` uses.
**[`compose.personal.yaml`](compose.personal.yaml)** is the older variant still
running on my own box. It works and its HTTP proxy is in daily use, so it's kept
as-is rather than tidied — but it has the quirks listed under gotchas below.
Compose won't load it unless you name it:
```bash
docker compose -f compose.personal.yaml up -d
```
What the maintained file changes:
| Change | Why |
|--------|-----|
| `SOCKS5_ENABLED` / `SOCKS5_LISTENING_ADDRESS` | `SOCKSPROXY*` are not gluetun variables; the SOCKS5 server never starts |
| `HTTPPROXY_LISTENING_ADDRESS` | `HTTPPROXY_PORT` works but logs a deprecation warning |
| `8880:8000` | pyload-ng listens on 8000; its port isn't settable by env var |
| `HTTPPROXY_USER` / `SOCKS5_USER` + passwords | opt-in proxy authentication, off unless set in `.env` |
| `qmcgaw/gluetun:v3.41.3` | pinned; this container holds the tunnel |
| `${PUID:-1000}`, `${TEMPDISK:?…}` | defaults where sensible, loud failure where a value can't be guessed |
| `condition: service_healthy` | gluetun ships a healthcheck; wait for the tunnel instead of racing it |
| `lscr.io/linuxserver/qbittorrent` | matches pyload-ng's registry; same image |
Same design, same ports, same volumes.
## Other VPN providers
Nothing here is TorGuard-specific except `wg0.conf`. gluetun natively supports
around twenty providers, and for most of them you delete the file mount and set
two or three variables instead:
```yaml
- VPN_SERVICE_PROVIDER=mullvad
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- WIREGUARD_ADDRESSES=${WIREGUARD_ADDRESSES}
- SERVER_CITIES=Amsterdam
```
**Native WireGuard** — the easy path: AirVPN, FastestVPN, IVPN, Mullvad,
NordVPN, ProtonVPN, Surfshark, Windscribe.
**Native OpenVPN** — every supported provider, usually just `OPENVPN_USER` and
`OPENVPN_PASSWORD`: the list above plus Cyberghost, ExpressVPN, Giganews,
HideMyAss, IPVanish, Privado, Private Internet Access, PrivateVPN, PureVPN,
SlickVPN, TorGuard, VPNSecure, VPN Unlimited, VyprVPN.
**WireGuard via the custom provider** — Cyberghost, Private Internet Access,
PrivateVPN, PureVPN, TorGuard, VPN Unlimited and VyprVPN have no native
WireGuard support in gluetun, so they take the `wg0.conf` route this stack uses.
That last line is why this stack looks the way it does: **TorGuard *is* a
built-in provider (`VPN_SERVICE_PROVIDER=torguard`), but only for OpenVPN.**
Wanting TorGuard *and* WireGuard is precisely the case gluetun's own
documentation sends to the custom provider. It isn't a workaround — it's the
documented path.
Switching provider changes nothing else: the kill switch, the shared network
namespace and both proxies work the same way regardless of who terminates the
tunnel. Check your provider's page in
[the gluetun wiki](https://github.com/qdm12/gluetun-wiki) for its exact
variables.
## Why it looks like this
**`network_mode: "service:gluetun"` is the whole design.** qBittorrent and
pyload-ng don't get their own network stack — they share gluetun's. That's what
makes the kill switch absolute: there is no route out except the tunnel, so a
misconfiguration fails closed rather than leaking. The cost is that those
containers **cannot declare their own `ports:`**; Docker refuses it. Every port
they need is published on gluetun instead, which is why the list above looks
like it belongs to the wrong service.
**gluetun's firewall is on by default.** Confirmed in its startup summary
(`Firewall settings: Enabled: yes`) — you don't have to configure a kill switch,
you have to avoid breaking the one that's already there.
**`VPN_SERVICE_PROVIDER=custom` plus a mounted `wg0.conf`.** TorGuard isn't one
of gluetun's built-in providers, so rather than translating their config into
`WIREGUARD_PRIVATE_KEY` / `VPN_ENDPOINT_IP` environment variables, the whole
config file is mounted at `/gluetun/wireguard/wg0.conf` and gluetun parses it.
Verified working: gluetun reads the endpoint, interface address and keys
straight out of that file. It also keeps the private key out of `docker inspect`
and out of your shell history, which the environment-variable approach does not.
**`:latest` on all three images**, which is upstream's habit rather than a
decision. Worth pinning gluetun in particular — it's the one holding the tunnel.
## Security: the proxies are unauthenticated by default
Both proxies ship with no credentials, so **anyone who can reach ports 9998 or
9980 can route traffic through your VPN account**. On a trusted LAN that may be
exactly what you want — it's what makes them useful from a browser extension on
another machine. It is not something to expose beyond that, and not something to
forget you did.
`compose.yaml` has the variables wired already; set both in `.env` and
authentication switches on for both proxies at once:
```bash
PROXY_USER=someone
PROXY_PASSWORD=a-long-random-string
```
Leave them unset and behaviour is unchanged. gluetun needs the user *and*
password together — one alone does nothing.
Never port-forward these from your router. An open proxy on the public internet
is found within hours, and everything that follows is attributed to your VPN
account.
## Gotchas
- **`SOCKSPROXY` is not a gluetun variable.** Neither is `SOCKSPROXY_PORT`.
gluetun ignores both silently — no warning — so the SOCKS5 server never
starts and its published port maps to nothing. Its own settings summary is
the proof: `SOCKS5 proxy server settings: Enabled: no`. The real names are
`SOCKS5_ENABLED=on` and `SOCKS5_LISTENING_ADDRESS=:1080`. Fixed in
`compose.yaml`; still present in `compose.personal.yaml`, whose HTTP proxy
works fine and whose SOCKS port does not.
- **`HTTPPROXY_PORT` is deprecated but does work.** gluetun logs `WARN You are
using the old environment variable HTTPPROXY_PORT, please consider changing
it to HTTPPROXY_LISTENING_ADDRESS`, then brings the proxy up on 8888 anyway.
- **pyload-ng listens on 8000.** The LinuxServer image's web UI port is 8000
and is *not* settable by environment variable — only inside pyload's own
config in `./pyloadconfig`. So a `8880:8880` mapping reaches nothing unless
you already changed it there; `compose.yaml` publishes `8880:8000` instead.
(Click'n'Load on 9666 isn't published at all.)
- **`wg0.conf` must exist before the first `up`.** It's a bind mount of a
*file*; if the path is missing, Docker creates a **directory** there, gluetun
finds no config, and the error doesn't mention the mount. Same failure mode as
[overleaf's init script](../overleaf/README.md#running-it-on-openmediavault).
- **Never put `wg0.conf` in `init/`.** This repo's `.gitignore` un-ignores
`*/init/**` so that runtime config fragments get committed — which for a
WireGuard config means publishing your private key. `wg*.conf` and `*.ovpn`
are now explicitly ignored everywhere as a guard, but the habit is the real
protection.
- **Restarting gluetun cuts the other two off.** They share its namespace, so
when it goes away their network does too, and `depends_on` doesn't bring them
back. Restart the whole stack, not one service.
- **`TORRENTING_PORT=6881` is not published**, so incoming peer connections
don't arrive and you're limited to outgoing ones. Fixing that properly needs
port forwarding from TorGuard, then publishing the matching port on gluetun.
- **In `compose.personal.yaml` the variables have no defaults.** `${PUID}`,
`${PGID}`, `${TZ}` and `${TEMPDISK}` are bare, so Compose interpolates empty
strings and starts the containers anyway. `compose.yaml` uses
`${PUID:-1000}` and `${TEMPDISK:?…}`, which aborts with a readable message
instead.
- **Check for leaks after any change**, not just at setup. `docker compose exec
gluetun wget -qO- https://ipinfo.io/ip` is the ten-second version.
## Exposing it
**Don't.** This stack has no business being reachable from the internet: two
admin UIs with weak-by-default auth and two unauthenticated open proxies.
If you need the UIs remotely, reach them over your own VPN or a
[Cloudflare Tunnel](../cloudflare-tunnel/) with
[Authentik forward auth](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
in front — and leave the proxy ports off it entirely.
## Links
- gluetun (now at the passteque org): <https://github.com/passteque/gluetun>
- gluetun wiki: <https://github.com/qdm12/gluetun-wiki>
- TorGuard WireGuard + gluetun guide:
<https://www.torguard.net/support/articles/TorGuard-Software-Features/torguard-wireguard-gluetun-docker-setup.php>
- qBittorrent image: <https://docs.linuxserver.io/images/docker-qbittorrent/>
- pyload-ng image: <https://docs.linuxserver.io/images/docker-pyload-ng/>
+61
View File
@@ -0,0 +1,61 @@
services:
gluetun:
image: qmcgaw/gluetun
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
# Enable SOCKS5 proxy
- SOCKSPROXY=on
- SOCKSPROXY_PORT=1080
# Optional HTTP proxy
- HTTPPROXY=on
- HTTPPROXY_PORT=8888
ports:
- "9880:9880/tcp" # Qbittorrent Web UI
- "8880:8880/tcp" # PyLoad Web-UI
- "9980:1080/tcp" # Socks Proxy
- "9998:8888/tcp" # HTTP Proxy
volumes:
- /etc/localtime:/etc/localtime:ro
- ./wg0.conf:/gluetun/wireguard/wg0.conf
restart: unless-stopped
qbittorrent:
image: linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- WEBUI_PORT=9880
- TORRENTING_PORT=6881
volumes:
- ./config:/config
- '${TEMPDISK}/downloads/torrents:/downloads'
network_mode: "service:gluetun"
depends_on:
- gluetun
restart: unless-stopped
pyload-ng:
image: lscr.io/linuxserver/pyload-ng:latest
container_name: pyload-ng
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./pyloadconfig:/config
- ${TEMPDISK}/downloads/pyload:/downloads
network_mode: "service:gluetun"
depends_on:
- gluetun
restart: unless-stopped
+95
View File
@@ -0,0 +1,95 @@
# The maintained version of this stack -- this is what `docker compose up`
# uses. See compose.personal.yaml for the older variant still running on
# my own box, and the README for what differs between them.
services:
gluetun:
# Pinned. This container holds the tunnel; an unattended pull breaking it
# means the download clients lose all connectivity (by design).
image: qmcgaw/gluetun:v3.41.3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
# SOCKS5 proxy. The names matter: SOCKSPROXY / SOCKSPROXY_PORT are NOT
# gluetun variables -- it ignores them silently and the proxy never
# starts. Verified against gluetun's own settings summary.
- SOCKS5_ENABLED=on
- SOCKS5_LISTENING_ADDRESS=:1080
# HTTP proxy. HTTPPROXY_PORT still works but logs a deprecation warning;
# LISTENING_ADDRESS is the current name.
- HTTPPROXY=on
- HTTPPROXY_LISTENING_ADDRESS=:8888
# Proxy authentication. Empty means NO authentication -- anyone who can
# reach the published ports can use the VPN. Set both vars in .env to
# turn it on; leaving them unset keeps the current behaviour.
- HTTPPROXY_USER=${PROXY_USER:-}
- HTTPPROXY_PASSWORD=${PROXY_PASSWORD:-}
- SOCKS5_USER=${PROXY_USER:-}
- SOCKS5_PASSWORD=${PROXY_PASSWORD:-}
ports:
- "${QBT_PORT:-9880}:9880/tcp" # qBittorrent Web UI
# pyload-ng listens on 8000 inside the container and the port is not
# settable by environment variable -- only in pyload's own config.
- "${PYLOAD_PORT:-8880}:8000/tcp" # pyLoad Web UI
- "${SOCKS_PORT:-9980}:1080/tcp" # SOCKS5 proxy
- "${HTTPPROXY_PORT:-9998}:8888/tcp" # HTTP proxy
# Incoming BitTorrent peers. Needs a forwarded port from TorGuard, and
# the number here must match TORRENTING_PORT below.
#- "6881:6881/tcp"
#- "6881:6881/udp"
volumes:
- /etc/localtime:/etc/localtime:ro
# Bind mount of a FILE. It must exist before the first `up` or Docker
# creates a directory here and gluetun finds no config. Git-ignored.
- ./wg0.conf:/gluetun/wireguard/wg0.conf:ro
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
- WEBUI_PORT=9880
- TORRENTING_PORT=6881
volumes:
- ${CONFIG_PATH:-./config}:/config
- "${TEMPDISK:?set TEMPDISK in .env to the disk your downloads live on}/downloads/torrents:/downloads"
# No network of its own: everything goes through the tunnel, and there is
# no route out if the tunnel drops. This is also why it cannot declare
# its own `ports:` -- they live on gluetun above.
network_mode: "service:gluetun"
depends_on:
gluetun:
# gluetun ships a healthcheck; wait for the tunnel rather than racing it.
condition: service_healthy
restart: unless-stopped
pyload-ng:
image: lscr.io/linuxserver/pyload-ng:latest
container_name: pyload-ng
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
volumes:
- ${PYLOAD_CONFIG_PATH:-./pyloadconfig}:/config
- ${TEMPDISK}/downloads/pyload:/downloads
network_mode: "service:gluetun"
depends_on:
gluetun:
condition: service_healthy
restart: unless-stopped
+29
View File
@@ -0,0 +1,29 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
#
# compose.yaml currently writes these as bare ${PUID} / ${PGID} / ${TZ},
# with no defaults, so a clone without a .env starts the container with
# empty values instead of failing. Set all three -- or add `:-` defaults
# to compose.yaml, which is what the rest of this repo does.
# ─── Identity ───────────────────────────────────────────────────────
# Must own the data directory, or Heimdall can't write its database.
# id -u ; id -g
PUID=1000
PGID=1000
TZ=Europe/Paris
# ─── Paths ──────────────────────────────────────────────────────────
# Mounted at /config. Holds the SQLite database, uploaded icons, the
# nginx config -- and the API keys of every "enhanced" app you add.
# Back it up; never commit it.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
#PORT=4080
#HTTPS_PORT=4043
# ─── Reaching your own LAN ──────────────────────────────────────────
# NOT set in compose.yaml, and the one you will most likely need.
# Heimdall blocks enhanced-app lookups to private/reserved IPs by
# default -- which is every service in your house. See the README.
#ALLOW_INTERNAL_REQUESTS=true
+96
View File
@@ -0,0 +1,96 @@
# Heimdall
A start page for everything else in this repo. Tiles that link to your services,
and for some of them an "enhanced" tile that polls the app's API and shows
something useful on the face of it — disk free, active downloads, unread count.
Actively maintained, despite its age: v2.8.3 landed in September 2026.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # PUID/PGID/TZ have no defaults in compose.yaml
docker compose up -d
```
Open `http://<host>:4080`. There is no login and no setup wizard — you get an
empty dashboard, and everything is configured by clicking around. Read the
security note below before you put anything on it.
## Ports
| Host | Container | Purpose |
|------|-----------|---------|
| 4080 | 80 | Web UI |
| 4043 | 443 | Heimdall's own HTTPS listener — see gotchas |
## Why it looks like this
**The LinuxServer image**, same reasoning as [jellyfin](../jellyfin/README.md):
`PUID`/`PGID`/`TZ`, `/config`, s6, one rebuild cadence. For a service this small
the win is that it behaves like everything else on the box.
**`./data` mounted at `/config`.** The names disagree because the repo's
convention is `data/` on the host and the image's convention is `/config` in the
container. Whatever it's called, it is the entire service: the SQLite database,
uploaded icons, the nginx config, and the API keys of every enhanced tile.
**Nothing else.** No database container, no cache, no environment variables
beyond the LinuxServer three. This is one of the genuinely simple stacks — which
is why the interesting content below is about what Heimdall *doesn't* tell you.
## Gotchas
- **Heimdall blocks requests to your own LAN by default.** This is the one that
wastes an afternoon. Enhanced tiles — the ones that show live data — make a
server-side request to the app's API, and the image ships
`ALLOW_INTERNAL_REQUESTS=false`, which refuses lookups to private or reserved
IP addresses. Every service in your house is on a private address, so every
enhanced tile fails, and the tile just sits there looking broken. Add
`ALLOW_INTERNAL_REQUESTS=true` to the environment block. It is not in
`compose.yaml` today.
- **There is no authentication out of the box.** Anyone who reaches port 4080
gets a map of your entire infrastructure, with working links. Heimdall can
password-protect itself, or you can put basic auth in front via the image's
nginx:
```bash
docker exec -it heimdall htpasswd -c /config/nginx/.htpasswd <username>
```
then uncomment the basic auth lines in `/config/nginx/site-confs/default.conf`
and restart. Better still, put [Authentik forward
auth](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
in front — a dashboard is the textbook case for it, since it has no accounts
worth integrating and every client is a browser.
- **Enhanced tiles store API keys in the data directory.** That's a Sonarr key,
a Nextcloud token, whatever you connected. `data/` is git-ignored by this
repo's deny-by-default rule — verified — but it means the directory is a
credential store, not just cosmetics. Back it up accordingly, and don't paste
it into a gist when asking for help.
- **Port 4043 is Heimdall's own TLS**, with a self-signed certificate. In this
repo TLS terminates at the proxy, so the mapping is redundant — drop the line
unless you're reaching it directly and are willing to click through the
warning.
- **`${PUID}`, `${PGID}` and `${TZ}` are bare.** No `:-1000` defaults, so a
clone with no `.env` gets empty strings and starts anyway — confirmed:
Compose emits `The "PUID" variable is not set. Defaulting to a blank string.`
and carries on. The rest of this repo writes `${PUID:-1000}`.
- **`:latest`.** Heimdall ships real releases (`2.8.3`, `v2.8.3-ls363`); pin one
if you'd rather not be surprised.
- **Back up `data/`.** Rebuilding a dashboard by hand is an hour you won't enjoy,
and the icons you uploaded aren't anywhere else.
## Exposing it
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the normal route; use
port 4080 and ignore 4043.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — **strongly
recommended if this leaves the LAN.** Forward auth is the right fit: no
accounts to map, browser-only clients, and it stops the dashboard being a
directory of your network for anyone who finds it.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine, it's a small HTML page.
## Links
- Upstream: <https://github.com/linuxserver/Heimdall>
- Image: <https://docs.linuxserver.io/images/docker-heimdall/>
+14
View File
@@ -0,0 +1,14 @@
services:
heimdall:
image: lscr.io/linuxserver/heimdall:latest
container_name: heimdall
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
volumes:
- ./data:/config
ports:
- 4080:80
- 4043:443
restart: unless-stopped
+34 -2
View File
@@ -100,8 +100,40 @@ Publishes plain HTTP; nothing here is proxy-aware.
- [Caddy reverse proxy](../docs/reverse-proxy.md) — what I do.
- [Cloudflare Tunnel](../docs/cloudflare-tunnel.md) — **not for this one.**
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
clients can't handle a forward-auth login page.
- [Authentik SSO](../docs/authentik-sso.md) — forward auth works for the web UI
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