Compare commits
3 Commits
main
..
aaef0796a3
| Author | SHA1 | Date | |
|---|---|---|---|
| aaef0796a3 | |||
| 7846372ee7 | |||
| 1efc465172 |
-18
@@ -7,19 +7,9 @@
|
|||||||
# ─── …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
|
||||||
|
|
||||||
# Support files a stack needs at runtime: init scripts, config fragments.
|
|
||||||
# Keep them in a per-service `init/` directory so they are never mistaken for
|
|
||||||
# application state.
|
|
||||||
!*/init/
|
|
||||||
!*/init/**
|
|
||||||
|
|
||||||
# Docs are not service data.
|
# Docs are not service data.
|
||||||
!docs/**
|
!docs/**
|
||||||
|
|
||||||
@@ -29,11 +19,3 @@
|
|||||||
*.pem
|
*.pem
|
||||||
*.crt
|
*.crt
|
||||||
secrets/
|
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
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Goyban
|
Copyright (c) 2025 goyban
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
|||||||
@@ -36,22 +36,10 @@ handled separately — see [Exposing services](#exposing-services).
|
|||||||
|
|
||||||
| Service | Port | Notes |
|
| Service | Port | Notes |
|
||||||
|---------|------|-------|
|
|---------|------|-------|
|
||||||
| [audiobookshelf](audiobookshelf/) | 13378 | Audiobooks and podcasts, with per-user progress sync |
|
|
||||||
| [cloudflare-tunnel](cloudflare-tunnel/) | — | Outbound connector. Exposes other services without opening a port |
|
|
||||||
| [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 |
|
|
||||||
| [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, talks to an external model backend |
|
||||||
| [openwebui](openwebui/) | 3033 | LLM chat UI. One `OWUI_TAG` switches between CPU, CUDA and bundled-Ollama |
|
| [owui-ollama](owui-ollama/) | 3034 | Open WebUI bundled with Ollama |
|
||||||
| [overleaf](overleaf/) | 8020 | LaTeX editor. Four containers: app, Mongo (replica set), Redis, init |
|
| [overleaf](overleaf/) | 8020 | LaTeX editor. Needs Mongo + Redis |
|
||||||
|
|
||||||
<!-- One row per service. Add as you go — the table is the index people scan. -->
|
<!-- One row per service. Add as you go — the table is the index people scan. -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Identity ───────────────────────────────────────────────────────
|
|
||||||
# Defaulted to 1000/1000 in compose.yaml. Unlike the LinuxServer images in
|
|
||||||
# this repo, these are NOT passed to the container as PUID/PGID -- that image
|
|
||||||
# ignores them. They feed Docker's `user:` directive, which actually changes
|
|
||||||
# the uid the process runs as. Must own CONFIG_PATH, METADATA_PATH and be able
|
|
||||||
# to read the media.
|
|
||||||
#PUID=1000
|
|
||||||
#PGID=1000
|
|
||||||
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
|
|
||||||
# ─── Paths ──────────────────────────────────────────────────────────
|
|
||||||
# Audiobookshelf's database and settings. Small; defaults to ./config.
|
|
||||||
#CONFIG_PATH=./config
|
|
||||||
|
|
||||||
# Cover art, cached metadata, downloads. Grows with your library.
|
|
||||||
#METADATA_PATH=./metadata
|
|
||||||
|
|
||||||
# REQUIRED. The parent folder holding Audiobooks/ and Podcasts/.
|
|
||||||
# compose.yaml mounts ${MEDIA_PATH}/Audiobooks and ${MEDIA_PATH}/Podcasts.
|
|
||||||
MEDIA_PATH=/srv/media
|
|
||||||
|
|
||||||
# ─── Ports ──────────────────────────────────────────────────────────
|
|
||||||
# Host port only. Do NOT add PORT to the container's environment -- the image
|
|
||||||
# reads PORT to decide what it listens on internally, which would break the
|
|
||||||
# 13378:80 mapping.
|
|
||||||
#PORT=13378
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
# Audiobookshelf
|
|
||||||
|
|
||||||
Audiobook and podcast server. Tracks progress per user across devices, which is
|
|
||||||
the thing a plain file share can't do — pick up on your phone where the car
|
|
||||||
left off.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
$EDITOR .env # MEDIA_PATH is required
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://<host>:13378` and create the admin account. Point the Audiobooks
|
|
||||||
library at `/audiobooks` and Podcasts at `/podcasts` — those are the paths
|
|
||||||
*inside* the container.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 13378 | http | Web UI and API (container listens on 80) |
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**The official image, not LinuxServer.** Audiobookshelf ships its own and it's
|
|
||||||
the one upstream actually tests. That's a different call from jellyfin, where I
|
|
||||||
stay on LinuxServer out of long habit — here there's no reason to add a layer.
|
|
||||||
|
|
||||||
**`user:` instead of `PUID`/`PGID`.** This is the part worth knowing, because
|
|
||||||
copying the pattern from the other services in this repo produces something
|
|
||||||
that *looks* right and silently isn't. The image's config:
|
|
||||||
|
|
||||||
```
|
|
||||||
User : '' <- empty, so root
|
|
||||||
Entrypoint: ['tini', '--']
|
|
||||||
Cmd : ['node', 'index.js']
|
|
||||||
```
|
|
||||||
|
|
||||||
There's no s6 layer and no init script — `tini` execs straight into Node. A
|
|
||||||
`PUID` environment variable is accepted and then read by nobody. Everything
|
|
||||||
gets written as root, and you find out when you try to move your library from
|
|
||||||
the host and can't.
|
|
||||||
|
|
||||||
Docker's own `user:` directive is the mechanism that actually works here, so
|
|
||||||
`PUID`/`PGID` in `.env` feed that instead. Same names as the rest of the repo,
|
|
||||||
different plumbing underneath.
|
|
||||||
|
|
||||||
**`/config` and `/metadata` are separate mounts** because they grow very
|
|
||||||
differently. `/config` is the SQLite database and settings — small, and the
|
|
||||||
thing to back up. `/metadata` is cover art, cached data and downloads, and
|
|
||||||
tracks the size of your library. Splitting them means a backup job can take the
|
|
||||||
first and skip the second.
|
|
||||||
|
|
||||||
**Two media mounts, not one.** Audiobookshelf wants each library rooted
|
|
||||||
separately, so `${MEDIA_PATH}` points at the parent and the compose file mounts
|
|
||||||
`Audiobooks/` and `Podcasts/` beneath it. Only `MEDIA_PATH` goes in `.env`.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`PORT` is a container variable here.** The image reads `PORT` to decide
|
|
||||||
what it listens on internally. It's used in `compose.yaml` for the *host*
|
|
||||||
side of `13378:80` only — if you ever add it to the `environment:` block,
|
|
||||||
the container moves off port 80 and the mapping breaks with nothing in the
|
|
||||||
logs to explain it.
|
|
||||||
- **Switching to `user:` on an existing install needs a chown.** If you ran
|
|
||||||
this as root first, the files it created are root-owned and the container
|
|
||||||
now can't write them. `sudo chown -R 1000:1000 config metadata` once.
|
|
||||||
- **Back up `/config`.** It holds the database — accounts, libraries, and
|
|
||||||
everyone's listening progress. `/metadata` regenerates; `/config` doesn't.
|
|
||||||
- **Media can be mounted `:ro`** unless you turn on storing metadata alongside
|
|
||||||
the media files, which is off by default. Worth doing if you want the library
|
|
||||||
protected from the server.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
Publishes a plain HTTP port; nothing here is proxy-aware.
|
|
||||||
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md)
|
|
||||||
- [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) — 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
|
|
||||||
|
|
||||||
- Upstream docs: <https://www.audiobookshelf.org/docs>
|
|
||||||
- Image: <https://github.com/advplyr/audiobookshelf/pkgs/container/audiobookshelf>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
services:
|
|
||||||
audiobookshelf:
|
|
||||||
image: ghcr.io/advplyr/audiobookshelf:latest
|
|
||||||
container_name: audiobookshelf
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# NOT a LinuxServer image: no s6, no init script -- tini execs straight
|
|
||||||
# into node, as root. PUID/PGID would be silently ignored, so ownership is
|
|
||||||
# set with Docker's own `user:` instead. See the README.
|
|
||||||
user: "${PUID:-1000}:${PGID:-1000}"
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ${CONFIG_PATH:-./config}:/config
|
|
||||||
- ${METADATA_PATH:-./metadata}:/metadata
|
|
||||||
- "${MEDIA_PATH:?set MEDIA_PATH in .env to the parent of your Audiobooks/Podcasts folders}/Audiobooks:/audiobooks"
|
|
||||||
- ${MEDIA_PATH}/Podcasts:/podcasts
|
|
||||||
|
|
||||||
ports:
|
|
||||||
# 13378 is the conventional host port; the container always listens on 80.
|
|
||||||
- "${PORT:-13378}:80"
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
# NEVER put the real token in this file.
|
|
||||||
|
|
||||||
# ─── Required ───────────────────────────────────────────────────────
|
|
||||||
# Cloudflare Zero Trust tunnel token.
|
|
||||||
#
|
|
||||||
# Dashboard -> Zero Trust -> Networks -> Tunnels -> (your tunnel) -> Configure
|
|
||||||
# https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
|
|
||||||
#
|
|
||||||
# The dashboard shows a pre-filled `docker run ... --token <TOKEN>` command.
|
|
||||||
# Copy only the token itself, not the whole command.
|
|
||||||
#
|
|
||||||
# Treat it like a password: anyone holding it can route traffic into your
|
|
||||||
# network. If it leaks, delete the tunnel in the dashboard and make a new one
|
|
||||||
# — the token cannot be rotated on its own.
|
|
||||||
TUNNEL_TOKEN=
|
|
||||||
|
|
||||||
# ─── Shared ─────────────────────────────────────────────────────────
|
|
||||||
# Defaulted to Etc/UTC in compose.yaml. Only affects log timestamps here.
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# Cloudflare Tunnel
|
|
||||||
|
|
||||||
The `cloudflared` connector. It dials **out** to Cloudflare and Cloudflare
|
|
||||||
routes public traffic back down that connection — so a service reaches the
|
|
||||||
internet with no port forwarded, no static IP and no VPS.
|
|
||||||
|
|
||||||
One tunnel serves every service; you don't need a copy of this per app.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
$EDITOR .env # paste TUNNEL_TOKEN
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Get the token at **Dashboard → Zero Trust → Networks → Tunnels →** *(your
|
|
||||||
tunnel)* **→ Configure**. The page shows a pre-filled `docker run … --token
|
|
||||||
<TOKEN>` command — copy only the token part.
|
|
||||||
|
|
||||||
Docs: <https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/>
|
|
||||||
|
|
||||||
Then add routes in the same dashboard: **Public Hostname → Service**, e.g.
|
|
||||||
`gitea.example.com → http://host.docker.internal:3000`.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
**None.** Nothing listens on the host — that's the entire point. If you find
|
|
||||||
yourself adding a `ports:` block here, something has been misunderstood.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**The token lives in `.env`, and compose refuses to start without it.** It's
|
|
||||||
written `${TUNNEL_TOKEN:?…}` rather than with a default, because a
|
|
||||||
blank token gives you a container that starts, retries, and quietly routes
|
|
||||||
nothing. The token is a genuine credential — it authorises routing traffic
|
|
||||||
into your network — so it never belongs in a committed file.
|
|
||||||
|
|
||||||
**No `PUID`/`PGID`.** Unlike most services here, this isn't a LinuxServer
|
|
||||||
image and doesn't understand those variables. It writes nothing to disk and
|
|
||||||
needs no volumes, so there's no ownership to get right. `TZ` is kept only
|
|
||||||
because it affects log timestamps.
|
|
||||||
|
|
||||||
**`extra_hosts: host.docker.internal:host-gateway`.** Routes are configured in
|
|
||||||
the dashboard, and the connector has to resolve whatever hostname you put
|
|
||||||
there. This line lets you point at services published on the Docker host
|
|
||||||
without hardcoding the LAN IP — which would otherwise break the day your
|
|
||||||
router hands out a different lease.
|
|
||||||
|
|
||||||
**`--no-autoupdate`.** The binary can update itself in place, which means the
|
|
||||||
thing routing your traffic changes without you doing anything. Pinning that
|
|
||||||
off makes updates a deliberate `docker compose pull`.
|
|
||||||
|
|
||||||
**No config file, no volume.** This is the remotely-managed style of tunnel:
|
|
||||||
routing lives in the Cloudflare dashboard, not on disk. The trade is that your
|
|
||||||
routing config isn't in this repo — the alternative (`cloudflared tunnel
|
|
||||||
create` plus a mounted `config.yml`) is version-controllable but more setup.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Don't put video through it.** Cloudflare's terms restrict serving large
|
|
||||||
non-HTML content, and streaming a media library is the classic way to get an
|
|
||||||
account flagged. Jellyfin belongs behind your own reverse proxy.
|
|
||||||
- **~100 MB upload cap** on the free plan. Photo backup and file sync break on
|
|
||||||
this, sometimes with nothing clearer than a `413`.
|
|
||||||
- **The token can't be rotated.** If it leaks, delete the tunnel and create a
|
|
||||||
new one.
|
|
||||||
- **A dead tunnel looks like a DNS problem.** If a hostname stops resolving,
|
|
||||||
check `docker compose logs cloudflared` before suspecting Cloudflare.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [When to use a tunnel, and when not to](../docs/cloudflare-tunnel.md) — the
|
|
||||||
longer write-up, including the comparison against a VPS reverse proxy.
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md)
|
|
||||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — note Cloudflare
|
|
||||||
Access can also sit in front of a tunnel; running both is usually redundant.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Connector docs: <https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/>
|
|
||||||
- Image: <https://hub.docker.com/r/cloudflare/cloudflared>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
services:
|
|
||||||
cloudflaretunnel:
|
|
||||||
image: cloudflare/cloudflared:latest
|
|
||||||
container_name: cloudflare-tunnel
|
|
||||||
restart: unless-stopped
|
|
||||||
command: tunnel --no-autoupdate run
|
|
||||||
|
|
||||||
environment:
|
|
||||||
# Only affects log timestamps here — nothing is written to disk.
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
# A real secret: it grants the ability to route traffic into your network.
|
|
||||||
# Dashboard -> Zero Trust -> Networks -> Tunnels -> (tunnel) -> Configure
|
|
||||||
- TUNNEL_TOKEN=${TUNNEL_TOKEN:?get it from Cloudflare Zero Trust > Networks > Tunnels > Configure}
|
|
||||||
|
|
||||||
# No `ports:` and no PUID/PGID on purpose — see the README.
|
|
||||||
|
|
||||||
# Lets dashboard routes reach services published on the Docker host, e.g.
|
|
||||||
# gitea.example.com -> http://host.docker.internal:3000
|
|
||||||
# Drop this if you route to containers by name on a shared network.
|
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Required ───────────────────────────────────────────────────────
|
|
||||||
# Web UI password. There is no default and no "first run" setup: if neither
|
|
||||||
# PASSWORD nor HASHED_PASSWORD is set, code-server serves an editor and a
|
|
||||||
# terminal with NO authentication whatsoever.
|
|
||||||
PASSWORD=
|
|
||||||
|
|
||||||
# Instead of the plaintext above, you can store only a hash. It overrides
|
|
||||||
# PASSWORD when set. Generate one with:
|
|
||||||
#
|
|
||||||
# echo -n "yourpassword" | npx argon2-cli -e
|
|
||||||
#
|
|
||||||
# Then uncomment HASHED_PASSWORD in compose.yaml.
|
|
||||||
#HASHED_PASSWORD=
|
|
||||||
|
|
||||||
# ─── Sudo inside the container ──────────────────────────────────────
|
|
||||||
# Set this if you want to install things from the built-in terminal. The image
|
|
||||||
# is deliberately minimal -- no python, no pip, no compilers -- so anything
|
|
||||||
# beyond editing needs apt:
|
|
||||||
#
|
|
||||||
# sudo apt update && sudo apt install -y python3-pip
|
|
||||||
#
|
|
||||||
# Leaving it empty is safe and simply disables sudo; the init script tests for
|
|
||||||
# a non-empty value, so empty is not an enabled blank password. It can be the
|
|
||||||
# same as PASSWORD or different -- it only guards `sudo` inside the container.
|
|
||||||
#
|
|
||||||
# Be aware of what it grants: root in the container. That is a container, not
|
|
||||||
# your host, but it is still root over anything you have mounted.
|
|
||||||
SUDO_PASSWORD=
|
|
||||||
|
|
||||||
# Store a hash instead of the plaintext. Takes priority over SUDO_PASSWORD.
|
|
||||||
# Format is $type$salt$hash. Uncomment the matching line in compose.yaml.
|
|
||||||
#SUDO_PASSWORD_HASH=
|
|
||||||
|
|
||||||
# ─── Identity ───────────────────────────────────────────────────────
|
|
||||||
# Files you create in the editor are owned by this user. `id -u` / `id -g`.
|
|
||||||
# Defaulted to 1000/1000 in compose.yaml.
|
|
||||||
#PUID=1000
|
|
||||||
#PGID=1000
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
|
|
||||||
# ─── Paths ──────────────────────────────────────────────────────────
|
|
||||||
# Settings, installed extensions, and the default workspace.
|
|
||||||
#CONFIG_PATH=./config
|
|
||||||
|
|
||||||
# Optional: existing code on the host to expose in the editor. Uncomment the
|
|
||||||
# matching volume line in compose.yaml too.
|
|
||||||
#PROJECTS_PATH=/srv/projects
|
|
||||||
|
|
||||||
# ─── Ports and proxying ─────────────────────────────────────────────
|
|
||||||
#PORT=8443
|
|
||||||
#DEFAULT_WORKSPACE=/config/workspace
|
|
||||||
|
|
||||||
# Only for subdomain port-forwarding (3000.code.example.com). Needs wildcard
|
|
||||||
# DNS and a wildcard certificate; skip it if you don't need that.
|
|
||||||
#PROXY_DOMAIN=code.example.com
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# code-server
|
|
||||||
|
|
||||||
VS Code in a browser. Useful for editing on the NAS from a laptop, a tablet, or
|
|
||||||
any machine you don't want to set a toolchain up on.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
$EDITOR .env # PASSWORD is required
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://<host>:8443`. The password from `.env` is the only credential.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 8443 | http | Editor UI (plain HTTP — put TLS in front of it) |
|
|
||||||
|
|
||||||
## Read this before exposing it
|
|
||||||
|
|
||||||
code-server is not a document editor with a terminal bolted on. It **is** a
|
|
||||||
shell, with a file browser, running as `PUID`/`PGID`, with network access to
|
|
||||||
everything the container can reach. Treat exposing it exactly as you'd treat
|
|
||||||
exposing SSH.
|
|
||||||
|
|
||||||
Three consequences worth being deliberate about:
|
|
||||||
|
|
||||||
**`PASSWORD` is required here** (`${PASSWORD:?…}`), which is a deviation from
|
|
||||||
upstream's example. Their compose marks it `#optional`, and it is — but the
|
|
||||||
docs also say: *"If PASSWORD or HASHED_PASSWORD is not provided, there will be
|
|
||||||
no auth."* Optional-and-silently-open is the wrong default for something that
|
|
||||||
hands out a shell, so this stack refuses to start without one.
|
|
||||||
|
|
||||||
**`SUDO_PASSWORD` is how you install anything.** The image is deliberately
|
|
||||||
minimal — no python, no pip, no compilers — so `sudo apt install python3-pip`
|
|
||||||
is the normal first move, and that needs sudo enabled. It grants root *in the
|
|
||||||
container*, which is a real boundary but not your host. Leave it empty if you
|
|
||||||
only ever edit; the init script tests for a non-empty value, so empty means
|
|
||||||
disabled rather than an enabled blank password.
|
|
||||||
|
|
||||||
**Whatever you mount is executable, not just editable.** The commented
|
|
||||||
`PROJECTS_PATH` line is genuinely convenient and genuinely a decision — mount
|
|
||||||
your code, not your whole home directory. Never mount `/var/run/docker.sock`
|
|
||||||
here; that converts a container password into root on the host.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**LinuxServer image**, so the usual `PUID`/`PGID`/`TZ` contract applies and
|
|
||||||
files you create are owned by you rather than root. Same reasoning as
|
|
||||||
[jellyfin](../jellyfin/) — one dialect across the stacks.
|
|
||||||
|
|
||||||
**`HASHED_PASSWORD` is offered but commented.** `.env` is git-ignored, so a
|
|
||||||
plaintext password there is not exposed, but the hash means the credential
|
|
||||||
isn't sitting in a readable file at all. Generate one with
|
|
||||||
`echo -n "yourpassword" | npx argon2-cli -e`. It overrides `PASSWORD` when set.
|
|
||||||
|
|
||||||
**`PROXY_DOMAIN` is commented out.** It only matters if you want forwarded
|
|
||||||
ports served as subdomains — port 3000 as `3000.code.example.com` — which needs
|
|
||||||
a wildcard DNS record and a wildcard certificate. Without it, forwarded ports
|
|
||||||
still work through the editor's own proxy path.
|
|
||||||
|
|
||||||
**`DEFAULT_WORKSPACE` is set** so the editor opens somewhere useful instead of
|
|
||||||
an empty file picker on every visit.
|
|
||||||
|
|
||||||
## What survives a rebuild, and what doesn't
|
|
||||||
|
|
||||||
`/config` is the app user's home directory — `abc:x:911:1001::/config` — and
|
|
||||||
it's the only persisted path. That single fact decides how you should install
|
|
||||||
things:
|
|
||||||
|
|
||||||
| Installed with | Lands in | Survives `docker compose up` after a pull? |
|
|
||||||
|---|---|---|
|
|
||||||
| `sudo apt install …` | `/usr/…` | **No** — gone with the old container |
|
|
||||||
| `pip install --user …` | `/config/.local` | Yes |
|
|
||||||
| VS Code extensions | `/config` | Yes |
|
|
||||||
| Anything in your workspace | `/config/workspace` | Yes |
|
|
||||||
|
|
||||||
So `sudo apt install python3-pip` has to be repeated after every image update,
|
|
||||||
while the packages you then `pip install --user` stay put. Prefer `--user`
|
|
||||||
installs, and keep a note of the apt packages you need so you can replay them.
|
|
||||||
|
|
||||||
If a toolchain matters enough that re-running apt annoys you, build a small
|
|
||||||
image `FROM lscr.io/linuxserver/code-server` with the packages baked in.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Extensions live in `/config`.** They're part of what to back up, and
|
|
||||||
they're why the config directory grows.
|
|
||||||
- **The VS Code Marketplace isn't available.** code-server uses Open VSX, so a
|
|
||||||
few Microsoft-licensed extensions are missing. Most have equivalents.
|
|
||||||
- **Port forwarding is per-session.** Ports you open inside the editor are
|
|
||||||
reachable through it, not published on the host.
|
|
||||||
- **A container password is a weaker boundary than it looks.** Anyone who logs
|
|
||||||
in can reach every service on your Docker network directly by name.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the minimum. Never
|
|
||||||
serve this over plain HTTP off your LAN; the password crosses the wire.
|
|
||||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — a good fit here.
|
|
||||||
Forward auth in front of code-server means an attacker needs to get past
|
|
||||||
Authentik before reaching a login form that leads to a shell.
|
|
||||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — works, and avoids opening a port.
|
|
||||||
Pair it with Cloudflare Access rather than relying on the password alone.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Image docs: <https://docs.linuxserver.io/images/docker-code-server/>
|
|
||||||
- Upstream: <https://github.com/coder/code-server>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
services:
|
|
||||||
code-server:
|
|
||||||
image: lscr.io/linuxserver/code-server:latest
|
|
||||||
container_name: code-server
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- PUID=${PUID:-1000}
|
|
||||||
- PGID=${PGID:-1000}
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
|
|
||||||
# REQUIRED. With neither PASSWORD nor HASHED_PASSWORD set, code-server
|
|
||||||
# starts with NO authentication -- an editor and a shell, open to anyone
|
|
||||||
# who can reach the port.
|
|
||||||
- PASSWORD=${PASSWORD:?set one - without it code-server has no auth at all}
|
|
||||||
|
|
||||||
# Overrides PASSWORD when set. Use it to keep the plaintext out of .env.
|
|
||||||
#- HASHED_PASSWORD=${HASHED_PASSWORD}
|
|
||||||
|
|
||||||
# Enables `sudo` in the built-in terminal -- needed to apt-install
|
|
||||||
# anything, since the image ships no python/pip/compilers. Empty is safe
|
|
||||||
# and simply leaves sudo disabled: the init script tests `-n`, so an
|
|
||||||
# empty value is not the same as an enabled blank password.
|
|
||||||
- SUDO_PASSWORD=${SUDO_PASSWORD:-}
|
|
||||||
# Takes priority over SUDO_PASSWORD if you'd rather store only a hash.
|
|
||||||
#- SUDO_PASSWORD_HASH=${SUDO_PASSWORD_HASH}
|
|
||||||
|
|
||||||
# Only needed if you serve forwarded ports on subdomains, so that
|
|
||||||
# port 3000 becomes 3000.code.example.com. Requires a wildcard DNS
|
|
||||||
# record and a wildcard certificate.
|
|
||||||
#- PROXY_DOMAIN=${PROXY_DOMAIN}
|
|
||||||
|
|
||||||
- DEFAULT_WORKSPACE=${DEFAULT_WORKSPACE:-/config/workspace}
|
|
||||||
#- PWA_APPNAME=${PWA_APPNAME:-code-server}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Settings, extensions, and the default workspace. Back this up.
|
|
||||||
- ${CONFIG_PATH:-./config}:/config
|
|
||||||
# Code that lives elsewhere on the host. Whatever you mount here is
|
|
||||||
# editable -- and runnable -- from the browser.
|
|
||||||
#- ${PROJECTS_PATH}:/config/workspace/projects
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${PORT:-8443}:8443"
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Required ───────────────────────────────────────────────────────
|
|
||||||
# Password for the account named in init/copyparty.conf. That config grants
|
|
||||||
# access to this account ONLY -- there is no anonymous access.
|
|
||||||
#
|
|
||||||
# openssl rand -base64 18
|
|
||||||
#
|
|
||||||
CP_PASSWORD=
|
|
||||||
|
|
||||||
# The directory to share. Bound to /w inside the container, which is what
|
|
||||||
# init/copyparty.conf serves at the webroot.
|
|
||||||
# Add `:ro` to the volume line in compose.yaml for a download-only server.
|
|
||||||
SHARE_PATH=/srv/downloads
|
|
||||||
|
|
||||||
# ─── Optional ───────────────────────────────────────────────────────
|
|
||||||
# Username. Change it here AND in the `accs:` block of init/copyparty.conf --
|
|
||||||
# the two must match or the account has no permissions.
|
|
||||||
#CP_USER=admin
|
|
||||||
|
|
||||||
# Index, thumbnails and copyparty's own config dir. Keeps state out of the
|
|
||||||
# shared folder. Back this up.
|
|
||||||
#DATA_PATH=./data
|
|
||||||
|
|
||||||
# The image runs as root unless told otherwise and reads no PUID/PGID; these
|
|
||||||
# feed Docker's `user:` directive. Must be able to read/write SHARE_PATH.
|
|
||||||
#PUID=1000
|
|
||||||
#PGID=1000
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
|
|
||||||
#PORT=3923
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# copyparty
|
|
||||||
|
|
||||||
File server with an unusual amount packed in: resumable uploads, dedup, a media
|
|
||||||
index with thumbnails, share links, and WebDAV/FTP/SFTP/TFTP on the side. One
|
|
||||||
Python process, no database server.
|
|
||||||
|
|
||||||
Overlaps with [filebrowser](../filebrowser/) — that one is a tidy file manager,
|
|
||||||
this one is a transfer workhorse. Uploading 40 GB over a flaky connection is
|
|
||||||
where the difference shows.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
echo "CP_PASSWORD=$(openssl rand -base64 18)" >> .env
|
|
||||||
$EDITOR .env # set SHARE_PATH
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://<host>:3923` and log in as `admin` with that password.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 3923 | http | Web UI, WebDAV, and the API |
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**No anonymous access.** Upstream's example config grants `rw: *` — read *and
|
|
||||||
write* to anybody who can reach the port. That's a sensible LAN dropbox and a
|
|
||||||
poor default for anything else, so this config gives access to one named
|
|
||||||
account and nothing to the public. Verified: anonymous gets `403`, the account
|
|
||||||
gets `200`.
|
|
||||||
|
|
||||||
**The password is not in the config file.** copyparty's `[accounts]` section
|
|
||||||
takes `user: password` in plaintext, which is fine on your own disk but not in
|
|
||||||
a git repo. Accounts are passed as a command-line argument instead, so
|
|
||||||
`CP_PASSWORD` stays in `.env`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
command: ["-a", "${CP_USER:-admin}:${CP_PASSWORD:?...}"]
|
|
||||||
```
|
|
||||||
|
|
||||||
The username there must match the `accs:` block in
|
|
||||||
[`init/copyparty.conf`](init/copyparty.conf), or the account exists with no
|
|
||||||
permissions and every page looks empty.
|
|
||||||
|
|
||||||
**`XDG_CONFIG_HOME=/data`.** `/cfg` is mounted read-only, so without this
|
|
||||||
copyparty can't write its config directory and falls back to `/tmp` — logging a
|
|
||||||
warning most people scroll past. The consequence is that **the filekeys behind
|
|
||||||
every share link regenerate on each restart**, quietly breaking links you've
|
|
||||||
handed out.
|
|
||||||
|
|
||||||
**`hist: /data/hist`.** By default the index and thumbnail cache go into a
|
|
||||||
`.hist` directory *inside the folder you're sharing*. Redirecting it keeps your
|
|
||||||
media directory as you left it.
|
|
||||||
|
|
||||||
**The `ac` image.** Variants differ by what's bundled — `min` is the bare
|
|
||||||
server, `ac` adds ffmpeg for thumbnails and media tags, others add more. `ac` is
|
|
||||||
what the config's `e2ts` needs.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Inline comments need two spaces before the `#`.** With one, the `#` and
|
|
||||||
everything after becomes part of the value. copyparty warns at startup rather
|
|
||||||
than failing, so it's easy to miss.
|
|
||||||
- **Share links bypass authentication by design.** That's the point of them,
|
|
||||||
but a leaked link is public access to that file.
|
|
||||||
- **`e2dsa` indexes on first start.** For a large share, expect the first boot
|
|
||||||
to churn; uploads are blocked while it runs and it says so in the log.
|
|
||||||
- **It is CPU-hungry, and on a small box you will feel it.** On my Intel N100
|
|
||||||
this is the service that shows up in `top`, not the media server. I still run
|
|
||||||
it and still want it, but I stop the container when I'm not actively moving
|
|
||||||
files rather than leave it idling — on a four-core low-TDP chip that headroom
|
|
||||||
is worth more than instant availability. The expensive parts are the ones
|
|
||||||
this config asks for: `e2dsa` walking the share and the media tag/thumbnail
|
|
||||||
work that the `ac` image exists to provide. Nothing is misconfigured — it's
|
|
||||||
what the feature set costs. If you want it always-on there, drop to the `min`
|
|
||||||
image and turn off `e2ts`, and accept losing thumbnails.
|
|
||||||
- **Back up `DATA_PATH`.** It holds the index and the filekeys that make share
|
|
||||||
links stable.
|
|
||||||
- **Version-check is opt-in.** Uncomment `vc-url` and `vc-exit` in the config
|
|
||||||
and copyparty refuses to start on a version with a known advisory — worth it
|
|
||||||
if you expose this.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — needed; the built-in
|
|
||||||
TLS uses a self-signed certificate.
|
|
||||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — copyparty has its
|
|
||||||
own accounts, so forward auth means two logins. Worth it anyway if this faces
|
|
||||||
the internet.
|
|
||||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — **think first.** Large uploads
|
|
||||||
are the reason to run copyparty, and the tunnel's ~100 MB body cap is exactly
|
|
||||||
what breaks.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Upstream: <https://github.com/9001/copyparty>
|
|
||||||
- The compose example this started from:
|
|
||||||
<https://github.com/9001/copyparty/blob/hovudstraum/docs/examples/docker/basic-docker-compose/docker-compose.yml>
|
|
||||||
- Image: <https://hub.docker.com/r/copyparty/ac>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
services:
|
|
||||||
copyparty:
|
|
||||||
# Variants differ by what's bundled: `min` is the bare server, `ac` adds
|
|
||||||
# ffmpeg for thumbnails and media tags, `iv`/`dj` add more. `ac` is the
|
|
||||||
# sensible default and what the config's e2ts flag needs.
|
|
||||||
image: copyparty/ac:1.20.21
|
|
||||||
container_name: copyparty
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# Runs as root unless told otherwise. It reads no PUID/PGID.
|
|
||||||
user: "${PUID:-1000}:${PGID:-1000}"
|
|
||||||
|
|
||||||
# The thumbnailer gets ~10s to finish after the stop signal.
|
|
||||||
stop_grace_period: 15s
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
# Keeps log lines from being buffered.
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
# Without this, copyparty cannot write its config dir (/cfg is read-only)
|
|
||||||
# and falls back to /tmp -- which silently regenerates the filekeys behind
|
|
||||||
# every share link on each restart.
|
|
||||||
- XDG_CONFIG_HOME=/data
|
|
||||||
# Replace NOPE with 2 to enable mimalloc: faster, roughly double the RAM.
|
|
||||||
- LD_PRELOAD=/usr/lib/libmimalloc-secure.so.NOPE
|
|
||||||
|
|
||||||
# Accounts live here, not in the committed config file. The name must match
|
|
||||||
# the one in init/copyparty.conf's `accs:` block.
|
|
||||||
command: ["-a", "${CP_USER:-admin}:${CP_PASSWORD:?set a password - the config grants access to this account only}"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ./init:/cfg:ro
|
|
||||||
# Index, thumbnails and copyparty's own config dir. Back this up.
|
|
||||||
- ${DATA_PATH:-./data}:/data
|
|
||||||
# What you're sharing. Add `:ro` for a download-only server.
|
|
||||||
- "${SHARE_PATH:?set SHARE_PATH to the directory you want to share}:/w"
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3923}:3923"
|
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
# The "/._" suffix matches copyparty's default log filter, so the probe
|
|
||||||
# doesn't fill the log.
|
|
||||||
test: ["CMD-SHELL", "wget --spider -q 127.0.0.1:3923/?reset=/._"]
|
|
||||||
interval: 1m
|
|
||||||
timeout: 2s
|
|
||||||
retries: 5
|
|
||||||
start_period: 15s
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# copyparty configuration. Everything in /cfg is loaded by the image's own
|
|
||||||
# initcfg, which also sets `chdir: /w`.
|
|
||||||
#
|
|
||||||
# Not YAML despite appearances -- indentation matters, `key: value` for options
|
|
||||||
# that take one, bare words for flags.
|
|
||||||
#
|
|
||||||
# Inline comments need AT LEAST TWO SPACES before the `#`. With one space the
|
|
||||||
# `#` and everything after it becomes part of the value, and copyparty warns
|
|
||||||
# about it at startup rather than failing.
|
|
||||||
#
|
|
||||||
# No [accounts] section here on purpose: accounts are passed on the command
|
|
||||||
# line from .env, so no password is ever committed. See compose.yaml.
|
|
||||||
|
|
||||||
[global]
|
|
||||||
e2dsa # index files and scan the filesystem
|
|
||||||
e2ts # read media tags; needs the "ac" image, which has ffmpeg
|
|
||||||
|
|
||||||
# Keep the index out of the shared folder. Without this, copyparty creates
|
|
||||||
# a .hist directory inside whatever you are sharing.
|
|
||||||
hist: /data/hist
|
|
||||||
|
|
||||||
# ipa: lan # reject connections from outside the LAN
|
|
||||||
# df: 16 # refuse uploads when under 16 GB free
|
|
||||||
# grid # thumbnail grid by default
|
|
||||||
# theme: 2 # monokai
|
|
||||||
|
|
||||||
# Warn -- and with vc-exit, refuse to start -- if the running version has a
|
|
||||||
# known advisory. Off by default upstream; worth it for anything exposed.
|
|
||||||
# vc-url: https://api.copyparty.eu/advisories
|
|
||||||
# vc-exit
|
|
||||||
|
|
||||||
[/] # volume at the webroot
|
|
||||||
/w # serving /w, which compose binds to your SHARE_PATH
|
|
||||||
accs:
|
|
||||||
# Upstream's example uses `rw: *`, i.e. read-write for anonymous visitors.
|
|
||||||
# That is a reasonable LAN dropbox and a bad default for anything else, so
|
|
||||||
# only the account created in compose.yaml gets access here.
|
|
||||||
rwmda: admin # read-write-move-delete-admin
|
|
||||||
flags:
|
|
||||||
e2ds
|
|
||||||
+16
-198
@@ -1,8 +1,6 @@
|
|||||||
# Single sign-on with Authentik
|
# Single sign-on with Authentik
|
||||||
|
|
||||||
One login for everything self-hosted here, and one place to revoke it. The
|
TODO: fill in as you go. Outline below so the shape is decided in advance.
|
||||||
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
|
||||||
|
|
||||||
@@ -18,210 +16,30 @@ fallback.
|
|||||||
|
|
||||||
## Native OIDC — the shape
|
## Native OIDC — the shape
|
||||||
|
|
||||||
Use this wherever the app supports it. You get real accounts, real logout, and
|
TODO: provider + application setup in Authentik, then the four values every
|
||||||
clients that aren't browsers keep working.
|
app asks for:
|
||||||
|
|
||||||
### In Authentik
|
- Issuer / discovery URL
|
||||||
|
- Client ID
|
||||||
|
- Client secret → `.env`, never committed
|
||||||
|
- Redirect URI
|
||||||
|
|
||||||
**Applications → Create**, provider type **OAuth2/OpenID Connect**. Three things
|
## Forward auth — the shape
|
||||||
to note as you go:
|
|
||||||
|
|
||||||
- the application **slug** — it appears in half the URLs below
|
TODO: the Caddy snippet, and which services you apply it to.
|
||||||
- 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
|
||||||
|
|
||||||
The mechanism is the same everywhere: Authentik puts your group membership in
|
TODO: how you map Authentik groups to per-app roles, and the admin/user
|
||||||
the token, and the app decides what that means.
|
split.
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
- **Keep a local fallback admin until the flow is proven.** If the only account
|
TODO: collect these as you hit them. Known ones worth writing down:
|
||||||
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,
|
- Locking yourself out of an app whose only admin is now behind SSO — keep a
|
||||||
in a private window, *before* removing the local account.
|
local fallback admin until the flow is proven.
|
||||||
- **Redirect URI mismatches surface as a generic error** that never names the
|
- Redirect URI mismatches, which usually surface as a generic error.
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -33,19 +33,15 @@ TODO: your preferred flow — dashboard-created tunnel vs. `cloudflared tunnel
|
|||||||
create`. The dashboard route is easier to show on video; the CLI route is
|
create`. The dashboard route is easier to show on video; the CLI route is
|
||||||
easier to keep in version control.
|
easier to keep in version control.
|
||||||
|
|
||||||
The connector itself is a service in this repo — see
|
|
||||||
[`cloudflare-tunnel/`](../cloudflare-tunnel/) for the compose file, the token
|
|
||||||
setup and the gotchas. In short:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
cloudflaretunnel:
|
cloudflared:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:latest
|
||||||
container_name: cloudflare-tunnel
|
container_name: cloudflared
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: tunnel --no-autoupdate run
|
command: tunnel --no-autoupdate run
|
||||||
environment:
|
environment:
|
||||||
- TUNNEL_TOKEN=${TUNNEL_TOKEN:?}
|
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
|
||||||
```
|
```
|
||||||
|
|
||||||
The tunnel token is a **real secret** — it grants the ability to route
|
The tunnel token is a **real secret** — it grants the ability to route
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Required ───────────────────────────────────────────────────────
|
|
||||||
# The directory to browse. Mounted at /srv inside the container, which is
|
|
||||||
# what init/config.yaml points at.
|
|
||||||
#
|
|
||||||
# Add `:ro` to the volume line in compose.yaml if you want a viewer rather
|
|
||||||
# than a file manager -- worth considering, since anyone who logs in can
|
|
||||||
# delete whatever they can see.
|
|
||||||
BROWSE_PATH=/srv/downloads
|
|
||||||
|
|
||||||
# ─── Paths ──────────────────────────────────────────────────────────
|
|
||||||
# SQLite database: users, shares, search index. Back this up. Small.
|
|
||||||
#DATA_PATH=./data
|
|
||||||
|
|
||||||
# ─── Identity ───────────────────────────────────────────────────────
|
|
||||||
# The image runs as its own non-root user, uid/gid 1000, and reads no
|
|
||||||
# PUID/PGID -- these feed Docker's `user:` directive instead. They must be
|
|
||||||
# able to read BROWSE_PATH and write DATA_PATH.
|
|
||||||
#PUID=1000
|
|
||||||
#PGID=1000
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
|
|
||||||
# ─── Ports ──────────────────────────────────────────────────────────
|
|
||||||
#PORT=3672
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# File Browser
|
|
||||||
|
|
||||||
Web file manager for a single directory tree — browse, preview, upload,
|
|
||||||
download, share links. Handy for reaching a downloads folder from a phone
|
|
||||||
without exposing SMB or SSH.
|
|
||||||
|
|
||||||
This is **[FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser)**,
|
|
||||||
the maintained fork. See below for why not the original.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
$EDITOR .env # BROWSE_PATH is required
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://<host>:3672` and log in as **`admin` / `admin`** — then change the
|
|
||||||
password immediately, see below.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 3672 | http | Web UI (container listens on 80) |
|
|
||||||
|
|
||||||
## Change the default password first
|
|
||||||
|
|
||||||
First start creates an admin account with a default password and says so:
|
|
||||||
|
|
||||||
```
|
|
||||||
[INFO ] Resetting admin user to default username and password.
|
|
||||||
```
|
|
||||||
|
|
||||||
Change it in the UI under user settings, or from the CLI. **The database is
|
|
||||||
locked while the server runs**, so the container has to be stopped first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop
|
|
||||||
docker compose run --rm filebrowser set -u "admin,your-new-password" -a
|
|
||||||
docker compose start
|
|
||||||
```
|
|
||||||
|
|
||||||
Running that against a live container fails with *"the database is locked,
|
|
||||||
please close all other instances"*, which is easy to misread as corruption.
|
|
||||||
|
|
||||||
## Why not `filebrowser/filebrowser`
|
|
||||||
|
|
||||||
The original project was wound down on **2026-09-01**. Its own binary announced
|
|
||||||
it on every start:
|
|
||||||
|
|
||||||
```
|
|
||||||
NOTICE: File Browser is being wound down.
|
|
||||||
NOTICE: The project is archived on 2026-09-01, after which there will be no
|
|
||||||
NOTICE: further releases and no security fixes.
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point its advisory page listed around 30 published issues, including a
|
|
||||||
high-severity one where recursive copy, rename and delete ignored rules that
|
|
||||||
should have denied access to descendants. Those are now permanent.
|
|
||||||
|
|
||||||
Quantum is the actively developed fork. It keeps the same idea but has its own
|
|
||||||
configuration format — a YAML file rather than `FB_*` environment variables —
|
|
||||||
so it is not a drop-in swap of the image name.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**The config is a committed file, not environment variables.** Quantum reads a
|
|
||||||
YAML config and exits `FATAL` if it can't find one. Without
|
|
||||||
`FILEBROWSER_CONFIG` it looks for `config.yaml` *inside the data directory*,
|
|
||||||
which means the file you must write by hand and the database it generates end
|
|
||||||
up in the same place. Pointing it at `/config/config.yaml` keeps the two
|
|
||||||
separate: [`init/config.yaml`](init/config.yaml) is version-controlled and
|
|
||||||
mounted read-only, `DATA_PATH` holds only generated state.
|
|
||||||
|
|
||||||
**`minLength: 5` in the config is deliberate.** Raise it and the first start
|
|
||||||
dies before serving anything:
|
|
||||||
|
|
||||||
```
|
|
||||||
[FATAL] store.Users.Save: password must be at least 8 characters long
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the *default admin* being created — its password is shorter than the
|
|
||||||
limit you just set. Raise `minLength` only after setting a real password.
|
|
||||||
|
|
||||||
**No `PUID`/`PGID`.** The image runs as its own `filebrowser` user (uid/gid
|
|
||||||
1000) and reads neither those nor `UID`/`GID`. Ownership is set with Docker's
|
|
||||||
`user:` directive, the same as [audiobookshelf](../audiobookshelf/).
|
|
||||||
|
|
||||||
**One source, mounted at `/srv`.** `config.yaml` declares `sources: [/srv]` and
|
|
||||||
the compose file binds your directory there, so the path inside the container
|
|
||||||
never changes and the config stays generic.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Anyone who logs in can delete anything they can see.** Mount `BROWSE_PATH`
|
|
||||||
with `:ro` if you only want to look and download.
|
|
||||||
- **Share links bypass login by design.** That's the feature, but it means a
|
|
||||||
leaked link is public access to that file.
|
|
||||||
- **The database is locked while the container runs** — user management needs
|
|
||||||
it stopped.
|
|
||||||
- **Back up `DATA_PATH`.** It holds users and share links; losing it means
|
|
||||||
recreating both.
|
|
||||||
- **A missing config file looks like a broken config file.** The error names
|
|
||||||
`Sources` as invalid rather than saying the file is absent.
|
|
||||||
- **Tags are `1.5.3-stable`, not `v1.5.3`.** The `v` prefix 404s, and `stable`
|
|
||||||
and `latest` are moving tags.
|
|
||||||
|
|
||||||
## Running it on OpenMediaVault
|
|
||||||
|
|
||||||
OMV's compose plugin deploys the compose file and the env file, nothing else.
|
|
||||||
Two things follow, and both cost me a round trip.
|
|
||||||
|
|
||||||
**Put `config.yaml` in `init/`, not the stack root.** The compose mounts
|
|
||||||
`./init/config.yaml`, so that exact path must exist as a *file* before the
|
|
||||||
first start. Docker creates a **directory** for a missing bind-mount source,
|
|
||||||
and filebrowser then reports a validation failure rather than a missing file:
|
|
||||||
|
|
||||||
```
|
|
||||||
could not validate config: Key: 'Settings.Server.Sources'
|
|
||||||
Error:Field validation for 'Sources' failed on the 'required' tag
|
|
||||||
```
|
|
||||||
|
|
||||||
That message sends you looking for a mistake in your config when the config
|
|
||||||
isn't being read at all. Check the host first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
stat -c '%F %n' init/config.yaml # must say "regular file"
|
|
||||||
```
|
|
||||||
|
|
||||||
If it says `directory`, `rmdir` it, put the real file there, and **recreate**
|
|
||||||
the container — a restart isn't enough, the mount is fixed at creation.
|
|
||||||
|
|
||||||
**Make `DATA_PATH` writable by `PUID:PGID`.** Docker creates it as root, but
|
|
||||||
the container runs as your `PUID:PGID` — which on OMV is often `1000:100`
|
|
||||||
(the `users` group), not `1000:1000`. Otherwise:
|
|
||||||
|
|
||||||
```
|
|
||||||
[FATAL] could not open database: open /data/database.db: permission denied
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chown -R 1000:100 data
|
|
||||||
```
|
|
||||||
|
|
||||||
Also: OMV names the env file `filebrowser.env`, so manual `docker compose`
|
|
||||||
commands fail on `BROWSE_PATH` unless you pass `--env-file filebrowser.env`.
|
|
||||||
Use plain `docker exec` / `docker logs` for one-offs.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the minimum.
|
|
||||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — worth it here.
|
|
||||||
Forward auth means a stranger has to get past Authentik before reaching a
|
|
||||||
login form that guards your filesystem.
|
|
||||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — mind the ~100 MB upload cap, and
|
|
||||||
don't route large media downloads through it.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Upstream: <https://github.com/gtsteffaniak/filebrowser>
|
|
||||||
- Image: `ghcr.io/gtsteffaniak/filebrowser`
|
|
||||||
- The wound-down original: <https://github.com/filebrowser/filebrowser/security/advisories>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
filebrowser:
|
|
||||||
# FileBrowser Quantum -- the maintained fork. The original
|
|
||||||
# filebrowser/filebrowser was wound down on 2026-09-01 with ~30 unfixed
|
|
||||||
# security advisories; see the README before reaching for it again.
|
|
||||||
image: ghcr.io/gtsteffaniak/filebrowser:1.5.3-stable
|
|
||||||
container_name: filebrowser
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# The image already runs as its own non-root `filebrowser` user, uid/gid
|
|
||||||
# 1000. It reads no PUID/PGID, so ownership is set the Docker way. Change
|
|
||||||
# this if the files it must read are owned by someone else.
|
|
||||||
user: "${PUID:-1000}:${PGID:-1000}"
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
# Without these it looks for config.yaml *inside* the data directory and
|
|
||||||
# exits FATAL when it is missing. Splitting them keeps the committed
|
|
||||||
# config separate from the database it generates.
|
|
||||||
- FILEBROWSER_CONFIG=/config/config.yaml
|
|
||||||
- FILEBROWSER_DATABASE=/data/database.db
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ./init/config.yaml:/config/config.yaml:ro
|
|
||||||
# SQLite database: users, shares, the search index. Back this up.
|
|
||||||
- ${DATA_PATH:-./data}:/data
|
|
||||||
# The directory you actually want to browse. Add `:ro` to make this a
|
|
||||||
# viewer rather than a file manager.
|
|
||||||
- "${BROWSE_PATH:?set BROWSE_PATH to the directory you want to browse}:/srv"
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3672}:80"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# FileBrowser Quantum configuration.
|
|
||||||
#
|
|
||||||
# Mounted read-only at /config/config.yaml; the container is pointed at it with
|
|
||||||
# FILEBROWSER_CONFIG. `./filebrowser -c` inside the image prints the full
|
|
||||||
# annotated default if you want to see every available option.
|
|
||||||
|
|
||||||
server:
|
|
||||||
port: 80
|
|
||||||
baseURL: "/"
|
|
||||||
logging:
|
|
||||||
- levels: "info|warning|error"
|
|
||||||
# What gets browsed. The compose file bind-mounts your directory here.
|
|
||||||
sources:
|
|
||||||
- path: "/srv"
|
|
||||||
|
|
||||||
auth:
|
|
||||||
methods:
|
|
||||||
password:
|
|
||||||
enabled: true
|
|
||||||
# Leave at 5. The first run creates a default admin whose password is
|
|
||||||
# shorter than 8 characters, and raising this makes that creation fail
|
|
||||||
# with a FATAL before the server ever starts. Raise it only after you
|
|
||||||
# have set a real password -- see the README.
|
|
||||||
minLength: 5
|
|
||||||
# No self-registration. Users are created by an admin.
|
|
||||||
signup: false
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
#
|
|
||||||
# ─── READ THIS FIRST ────────────────────────────────────────────────
|
|
||||||
# This stack is different from the others in this repo. Elsewhere, .env
|
|
||||||
# holds a handful of variables that Compose interpolates into
|
|
||||||
# compose.yaml. Here, `env_file: .env` hands the whole file to the
|
|
||||||
# application, so .env *is* Firefly III's configuration file.
|
|
||||||
#
|
|
||||||
# Because of that, upstream's instruction is to start from their full
|
|
||||||
# file rather than write one by hand:
|
|
||||||
#
|
|
||||||
# wget https://raw.githubusercontent.com/firefly-iii/firefly-iii/main/.env.example -O .env
|
|
||||||
#
|
|
||||||
# Then change the values below. This file is the list of what to change
|
|
||||||
# and why -- not a drop-in replacement for theirs.
|
|
||||||
|
|
||||||
# ─── Required: the two 32-character secrets ─────────────────────────
|
|
||||||
# Both must be EXACTLY 32 characters. Not "at least" -- exactly. Firefly
|
|
||||||
# rejects anything else, and the cron token silently 404s.
|
|
||||||
#
|
|
||||||
# openssl rand -base64 24 | cut -c1-32
|
|
||||||
#
|
|
||||||
APP_KEY=
|
|
||||||
|
|
||||||
# The cron container authenticates with this and nothing else -- it is a
|
|
||||||
# bearer token in a URL path. Treat it as a password.
|
|
||||||
STATIC_CRON_TOKEN=
|
|
||||||
|
|
||||||
# ─── Required: who you are ──────────────────────────────────────────
|
|
||||||
# Used as the "from" address and shown in the admin pages.
|
|
||||||
SITE_OWNER=you@example.com
|
|
||||||
|
|
||||||
# The external URL people actually type. Firefly builds links from it,
|
|
||||||
# so a wrong value breaks password resets the same way it does anywhere.
|
|
||||||
APP_URL=http://localhost:8564
|
|
||||||
|
|
||||||
# Change this value to your preferred time zone. It also decides when the
|
|
||||||
# cron job runs -- see the README.
|
|
||||||
TZ=Europe/Paris
|
|
||||||
|
|
||||||
# ─── Database ───────────────────────────────────────────────────────
|
|
||||||
# DB_HOST is the compose SERVICE name, not the container name.
|
|
||||||
# DB_PASSWORD must match MYSQL_PASSWORD in .db.env, or the app boots and
|
|
||||||
# then fails every request with a connection error.
|
|
||||||
DB_CONNECTION=mysql
|
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_DATABASE=firefly
|
|
||||||
DB_USERNAME=firefly
|
|
||||||
DB_PASSWORD=
|
|
||||||
|
|
||||||
# ─── Behind a reverse proxy ─────────────────────────────────────────
|
|
||||||
# Upstream's own comment: "Set it to ** and reverse proxies work just
|
|
||||||
# fine." Without it Firefly sees the proxy's IP as the client and builds
|
|
||||||
# http:// links behind your TLS.
|
|
||||||
TRUSTED_PROXIES=**
|
|
||||||
|
|
||||||
# ─── Optional ───────────────────────────────────────────────────────
|
|
||||||
#DEFAULT_LANGUAGE=en_US
|
|
||||||
# Defaults to `log`, which writes mail to the log instead of sending it.
|
|
||||||
# Fine for a single-user install -- you never need a password reset email
|
|
||||||
# if you are also the admin.
|
|
||||||
#MAIL_MAILER=log
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# Firefly III
|
|
||||||
|
|
||||||
Personal finance manager: accounts, budgets, bills and recurring transactions,
|
|
||||||
with a double-entry ledger underneath. Self-hosted, so your bank data stays on
|
|
||||||
your disk instead of in someone's analytics pipeline.
|
|
||||||
|
|
||||||
Two web UIs come with this stack — Firefly itself, and a separate **Data
|
|
||||||
Importer** that pulls transactions in from CSV files, Nordigen/GoCardless or
|
|
||||||
Spectre.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Firefly needs **three** env files, not one. Only `.env` has an example in this
|
|
||||||
repo — the other two are three lines each and are printed below.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Firefly's own configuration. Start from upstream's full file:
|
|
||||||
wget https://raw.githubusercontent.com/firefly-iii/firefly-iii/main/.env.example -O .env
|
|
||||||
$EDITOR .env # see .env.example here for what to change
|
|
||||||
|
|
||||||
# 2. Generate the two secrets. Both must be EXACTLY 32 characters:
|
|
||||||
openssl rand -base64 24 | cut -c1-32 # -> APP_KEY
|
|
||||||
openssl rand -base64 24 | cut -c1-32 # -> STATIC_CRON_TOKEN
|
|
||||||
|
|
||||||
# 3. The database credentials:
|
|
||||||
cat > .db.env <<'EOF'
|
|
||||||
MYSQL_RANDOM_ROOT_PASSWORD=yes
|
|
||||||
MYSQL_USER=firefly
|
|
||||||
MYSQL_PASSWORD=change_me
|
|
||||||
MYSQL_DATABASE=firefly
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 4. The importer (delete the service from compose.yaml if you don't want it):
|
|
||||||
cat > .importer.env <<'EOF'
|
|
||||||
FIREFLY_III_URL=http://app:8080
|
|
||||||
VANITY_URL=http://localhost:8564
|
|
||||||
TRUSTED_PROXIES=**
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 5. Wire up the cron token — see "The cron job" below. This step is manual.
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
`MYSQL_PASSWORD` in `.db.env` and `DB_PASSWORD` in `.env` are the same password
|
|
||||||
written twice. They must match.
|
|
||||||
|
|
||||||
Open `http://<host>:8564`, register, and the first account created becomes the
|
|
||||||
admin. There's no separate admin bootstrap step.
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 8564 | http | Firefly III web UI and API (container listens on 8080) |
|
|
||||||
| 8566 | http | Data Importer web UI (container listens on 8080) |
|
|
||||||
|
|
||||||
MariaDB is reachable only on the internal `firefly_iii` network.
|
|
||||||
|
|
||||||
## The cron job
|
|
||||||
|
|
||||||
**This is the part everyone skips, and the failure is silent.** Firefly does not
|
|
||||||
schedule anything itself — the `app` container serves HTTP and nothing else.
|
|
||||||
Recurring transactions, auto-budgets and periodic housekeeping only happen when
|
|
||||||
something calls the cron endpoint:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET http://app:8080/api/v1/cron/<STATIC_CRON_TOKEN>
|
|
||||||
```
|
|
||||||
|
|
||||||
That is what the fourth container is for. It's a bare `alpine` running `crond`
|
|
||||||
with one entry, hitting that URL at 03:00 daily.
|
|
||||||
|
|
||||||
Skip this and nothing announces itself. The app works, the UI is responsive, and
|
|
||||||
your recurring transactions simply never appear. You notice weeks later when a
|
|
||||||
month is missing its rent entry.
|
|
||||||
|
|
||||||
### Activating it
|
|
||||||
|
|
||||||
The token is a bearer credential in a URL path, and it must be **exactly 32
|
|
||||||
characters**. Anything else is rejected.
|
|
||||||
|
|
||||||
In the compose file here, the cron service has **no `env_file:`**, so the token
|
|
||||||
is not an environment variable it can read — it's baked into the crontab line as
|
|
||||||
the literal string `REPLACEME`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
command: sh -c "echo \"0 3 * * * wget -qO- http://app:8080/api/v1/cron/REPLACEME\" | crontab - && crond -f -L /dev/stdout"
|
|
||||||
```
|
|
||||||
|
|
||||||
So activation is two edits that must agree:
|
|
||||||
|
|
||||||
1. Set `STATIC_CRON_TOKEN=<your 32 chars>` in `.env` (the **app** validates
|
|
||||||
against this).
|
|
||||||
2. Replace `REPLACEME` in `compose.yaml` with **the same 32 characters** (the
|
|
||||||
**cron container** sends this).
|
|
||||||
|
|
||||||
Get them out of sync and the request 404s every night, forever, with no sign in
|
|
||||||
the Firefly UI.
|
|
||||||
|
|
||||||
### Upstream's newer version is better — consider switching
|
|
||||||
|
|
||||||
Current upstream avoids the hardcoded token entirely by giving the cron
|
|
||||||
container the same `.env` and letting the shell expand it (`$$` escapes the `$`
|
|
||||||
so Compose passes it through rather than interpolating it):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
cron:
|
|
||||||
image: alpine
|
|
||||||
restart: always
|
|
||||||
container_name: firefly_iii_cron
|
|
||||||
env_file: .env
|
|
||||||
command: ["sh", "-c", "apk add tzdata && \
|
|
||||||
(ln -s /usr/share/zoneinfo/$$TZ /etc/localtime || true) && \
|
|
||||||
echo \"0 3 * * * wget -qO- http://app:8080/api/v1/cron/$$STATIC_CRON_TOKEN;echo\" \
|
|
||||||
| crontab - && \
|
|
||||||
crond -f -L /dev/stdout"]
|
|
||||||
networks:
|
|
||||||
- firefly_iii
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
```
|
|
||||||
|
|
||||||
Two real improvements: the token lives in exactly one place, and `apk add
|
|
||||||
tzdata` plus the `localtime` symlink make "03:00" mean 03:00 **your** time. The
|
|
||||||
version in this repo has no tzdata, so its 03:00 is 03:00 UTC — which for me is
|
|
||||||
an hour that shifts twice a year.
|
|
||||||
|
|
||||||
### Checking it actually works
|
|
||||||
|
|
||||||
Don't wait until 03:00 to find out. Call the endpoint by hand:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from the host, through the published port
|
|
||||||
curl "http://localhost:8564/api/v1/cron/<your-token>"
|
|
||||||
|
|
||||||
# or from inside the cron container, which proves DNS and the network too
|
|
||||||
docker exec firefly_iii_cron wget -qO- "http://app:8080/api/v1/cron/<your-token>"
|
|
||||||
```
|
|
||||||
|
|
||||||
A correct token returns JSON describing what ran. A wrong one returns a 404 —
|
|
||||||
that 404 is the whole failure mode, so seeing it here is the point of the test.
|
|
||||||
|
|
||||||
The cron container logs to stdout (`crond -f -L /dev/stdout`), so after a
|
|
||||||
scheduled run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs firefly_iii_cron
|
|
||||||
```
|
|
||||||
|
|
||||||
There's also `docker exec firefly_iii_core php artisan firefly-iii:cron` if you'd
|
|
||||||
rather drive it from the app side and skip the token entirely.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**Four containers, and each one earns its place.** `app` is PHP serving HTTP.
|
|
||||||
`db` is MariaDB — Firefly is a double-entry ledger and wants a real relational
|
|
||||||
database, not SQLite. `cron` exists because `app` has no scheduler (see above).
|
|
||||||
`importer` is genuinely optional: it's a separate application with its own
|
|
||||||
release cycle that talks to Firefly over the API like any other client. Delete
|
|
||||||
the service if you only ever enter transactions by hand.
|
|
||||||
|
|
||||||
**The importer must be pointed at `http://app:8080`.** Not `localhost`, not the
|
|
||||||
public URL. The compose file carries upstream's warning at the top: other URLs
|
|
||||||
give `500 | Server Error`. It's container-to-container traffic on the compose
|
|
||||||
network, so it uses the service name and the *internal* port.
|
|
||||||
|
|
||||||
**`VANITY_URL` is the other half of that.** The importer talks to `app:8080`,
|
|
||||||
but the links it renders in your browser have to be clickable from outside — so
|
|
||||||
that variable holds the public URL. Two URLs for one service, one for machines
|
|
||||||
and one for humans.
|
|
||||||
|
|
||||||
**This stack still follows upstream's layout, not this repo's conventions.**
|
|
||||||
Worth stating plainly rather than pretending otherwise, because the differences
|
|
||||||
are visible:
|
|
||||||
|
|
||||||
- Data goes to `./firefly_iii_upload` and `./firefly_iii_db` beside the compose
|
|
||||||
file, instead of `${DATA_PATH}`.
|
|
||||||
- `restart: always` rather than `unless-stopped` — so a deliberate stop does not
|
|
||||||
survive a reboot.
|
|
||||||
- `.env` is the *application's* config file, so the usual "`.env` holds a few
|
|
||||||
Compose variables" pattern doesn't apply here at all.
|
|
||||||
|
|
||||||
Adapting it is a fine idea; just do it deliberately.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`APP_KEY` and `STATIC_CRON_TOKEN` must be exactly 32 characters.** Not a
|
|
||||||
minimum — exactly. `openssl rand -base64 24 | cut -c1-32` gives you one.
|
|
||||||
A wrong-length `APP_KEY` fails at boot; a wrong-length cron token fails
|
|
||||||
silently at 03:00.
|
|
||||||
- **The top-level `volumes:` block is dead code.** It declares
|
|
||||||
`firefly_iii_upload` and `firefly_iii_db`, but both services use *relative
|
|
||||||
bind mounts* (`./firefly_iii_upload`) instead. The named volumes are created
|
|
||||||
and never written to. Harmless, but if you go looking for your database in
|
|
||||||
`docker volume ls` you'll find an empty volume and conclude the wrong thing —
|
|
||||||
the data is in the directory next to `compose.yaml`.
|
|
||||||
- **MariaDB creates its bind directory as root.** Expect `./firefly_iii_db` to
|
|
||||||
be root-owned; that's normal and it needs to stay that way.
|
|
||||||
- **`DB_PASSWORD` (`.env`) and `MYSQL_PASSWORD` (`.db.env`) are the same secret
|
|
||||||
in two files.** Changing one and not the other gives you an app that starts
|
|
||||||
cleanly and then errors on every page.
|
|
||||||
- **Changing `MYSQL_PASSWORD` after the first start does nothing.** The database
|
|
||||||
is initialized once, from the empty data directory. After that the credentials
|
|
||||||
live in MariaDB, and the env var is ignored — change it in the database, or
|
|
||||||
wipe `./firefly_iii_db` and start over.
|
|
||||||
- **`:latest` on both images.** Firefly does schema migrations between releases;
|
|
||||||
an unattended pull across a major is not how you want to meet them. Pin a tag
|
|
||||||
once you're settled.
|
|
||||||
- **Back up `./firefly_iii_db`.** That's the ledger. `firefly_iii_upload` holds
|
|
||||||
attachments and matters less, but isn't regenerable either.
|
|
||||||
- **The importer holds a Personal Access Token, not a password.** Upstream is
|
|
||||||
emphatic that the "command line token" is the wrong one. You can use a client
|
|
||||||
ID instead and leave the token empty.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
Publishes plain HTTP; set `APP_URL` and `TRUSTED_PROXIES=**` and it behaves
|
|
||||||
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#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
|
|
||||||
|
|
||||||
- Upstream docs: <https://docs.firefly-iii.org/>
|
|
||||||
- Docker install guide: <https://docs.firefly-iii.org/how-to/firefly-iii/installation/docker/>
|
|
||||||
- Their sample compose: <https://github.com/firefly-iii/docker/blob/main/docker-compose.yml>
|
|
||||||
- Full `.env.example`: <https://github.com/firefly-iii/firefly-iii/blob/main/.env.example>
|
|
||||||
- Data Importer: <https://docs.firefly-iii.org/how-to/data-importer/>
|
|
||||||
- Images: <https://hub.docker.com/r/fireflyiii/core> · <https://hub.docker.com/r/fireflyiii/data-importer>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# The Firefly III Data Importer will ask you for the Firefly III URL and a "Client ID".
|
|
||||||
# You can generate the Client ID at http://localhost/profile (after registering)
|
|
||||||
# The Firefly III URL is: http://app:8080
|
|
||||||
#
|
|
||||||
# Other URL's will give 500 | Server Error
|
|
||||||
#
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: fireflyiii/core:latest
|
|
||||||
hostname: app
|
|
||||||
container_name: firefly_iii_core
|
|
||||||
networks:
|
|
||||||
- firefly_iii
|
|
||||||
restart: always
|
|
||||||
volumes:
|
|
||||||
- ./firefly_iii_upload:/var/www/html/storage/upload
|
|
||||||
env_file: .env
|
|
||||||
ports:
|
|
||||||
- '8564:8080'
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
db:
|
|
||||||
image: mariadb
|
|
||||||
hostname: db
|
|
||||||
container_name: firefly_iii_db
|
|
||||||
networks:
|
|
||||||
- firefly_iii
|
|
||||||
restart: always
|
|
||||||
env_file: .db.env
|
|
||||||
volumes:
|
|
||||||
- ./firefly_iii_db:/var/lib/mysql
|
|
||||||
|
|
||||||
importer:
|
|
||||||
image: fireflyiii/data-importer:latest
|
|
||||||
hostname: importer
|
|
||||||
restart: always
|
|
||||||
container_name: firefly_iii_importer
|
|
||||||
networks:
|
|
||||||
- firefly_iii
|
|
||||||
ports:
|
|
||||||
- '8566:8080'
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
env_file: .importer.env
|
|
||||||
|
|
||||||
cron:
|
|
||||||
#
|
|
||||||
# To make this work, set STATIC_CRON_TOKEN in your .env file or as an environment variable and replace REPLACEME below
|
|
||||||
# The STATIC_CRON_TOKEN must be *exactly* 32 characters long
|
|
||||||
#
|
|
||||||
image: alpine
|
|
||||||
container_name: firefly_iii_cron
|
|
||||||
restart: always
|
|
||||||
command: sh -c "echo \"0 3 * * * wget -qO- http://app:8080/api/v1/cron/REPLACEME\" | crontab - && crond -f -L /dev/stdout"
|
|
||||||
networks:
|
|
||||||
- firefly_iii
|
|
||||||
volumes:
|
|
||||||
firefly_iii_upload:
|
|
||||||
firefly_iii_db:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
firefly_iii:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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
@@ -1,129 +0,0 @@
|
|||||||
# 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>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
# 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/>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# 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/>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# 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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
# 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>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
#
|
|
||||||
# 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:
|
|
||||||
+2
-34
@@ -100,40 +100,8 @@ 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) — forward auth works for the web UI
|
- [Authentik SSO](../docs/authentik-sso.md) — works for the web UI, but native
|
||||||
but breaks every native client, so use a plugin instead. See below.
|
clients can't handle a forward-auth login page.
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# 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>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Which image ────────────────────────────────────────────────────
|
|
||||||
# This is the only setting that changes what the stack actually is.
|
|
||||||
# README.md has the full comparison; the short version:
|
|
||||||
#
|
|
||||||
# main WebUI only, CPU. Models run somewhere else. (default)
|
|
||||||
# latest identical to main — both are the same rolling build
|
|
||||||
# cuda + CUDA runtime. Speeds up RAG embeddings and Whisper STT,
|
|
||||||
# NOT chat. Needs an NVIDIA host + Container Toolkit.
|
|
||||||
# ollama + Ollama bundled inside the same container.
|
|
||||||
# v0.11.0 a frozen release. main/latest move under you.
|
|
||||||
#
|
|
||||||
#OWUI_TAG=main
|
|
||||||
|
|
||||||
# With OWUI_TAG=cuda or ollama on an NVIDIA host, also uncomment
|
|
||||||
# `gpus: all` in compose.yaml — the tag alone does not grant GPU access.
|
|
||||||
|
|
||||||
# ─── Where the models are ───────────────────────────────────────────
|
|
||||||
# Skip this and set it in the UI instead if you prefer.
|
|
||||||
# Ollama on the Docker host http://host.docker.internal:11434
|
|
||||||
# Ollama in another container http://ollama:11434
|
|
||||||
# Ollama on a Mac or other box http://192.168.1.x:11434
|
|
||||||
# OWUI_TAG=ollama leave unset — it is already local
|
|
||||||
#OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
|
|
||||||
# ─── Paths ──────────────────────────────────────────────────────────
|
|
||||||
# Chats, users, settings, RAG vector DB and model caches. Back this up.
|
|
||||||
#DATA_PATH=./open-webui
|
|
||||||
|
|
||||||
# Pulled Ollama models. ONLY used with OWUI_TAG=ollama, and only if you
|
|
||||||
# uncomment the matching line in compose.yaml. Tens of GB.
|
|
||||||
#OLLAMA_PATH=./ollama
|
|
||||||
|
|
||||||
# ─── Ports ──────────────────────────────────────────────────────────
|
|
||||||
#PORT=3033
|
|
||||||
|
|
||||||
#TZ=Europe/Paris
|
|
||||||
|
|
||||||
# ─── Optional ───────────────────────────────────────────────────────
|
|
||||||
# Signs session cookies. Generated into DATA_PATH when unset, which is fine
|
|
||||||
# for a single instance. Set it explicitly if you move the data between
|
|
||||||
# hosts, otherwise everyone gets logged out.
|
|
||||||
#WEBUI_SECRET_KEY=
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Open WebUI
|
|
||||||
|
|
||||||
Chat interface for local and remote LLMs. One container, one `.env` line to
|
|
||||||
decide which of three quite different stacks you get.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
$EDITOR .env # pick OWUI_TAG, point it at your models
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://<host>:3033`. The first account created becomes the admin.
|
|
||||||
|
|
||||||
## Choosing a tag
|
|
||||||
|
|
||||||
`OWUI_TAG` in `.env` is the whole configuration. Everything else is detail.
|
|
||||||
|
|
||||||
| Tag | What's inside | Choose it when |
|
|
||||||
|---|---|---|
|
|
||||||
| **`main`** / `latest` | WebUI only | Models run somewhere else — the Docker host, another machine, a Mac, or a hosted API. **The default, and the right answer most of the time.** |
|
|
||||||
| **`cuda`** | + CUDA runtime | You're on an NVIDIA host **and** use document RAG or voice input |
|
|
||||||
| **`ollama`** | + Ollama bundled in | One machine, one container, models managed inside it |
|
|
||||||
| `dev` | main branch, unreleased | Testing only |
|
|
||||||
| `v0.11.0` | a frozen release | You want upgrades to be a decision, not a surprise |
|
|
||||||
|
|
||||||
`main` and `latest` are the same rolling image — upstream says so explicitly.
|
|
||||||
Version tags stay frozen.
|
|
||||||
|
|
||||||
### What `cuda` actually accelerates
|
|
||||||
|
|
||||||
Not your chat. Open WebUI does not run LLMs; Ollama does. The image's own
|
|
||||||
environment says what the CUDA build is for:
|
|
||||||
|
|
||||||
```
|
|
||||||
SENTENCE_TRANSFORMERS_HOME=/app/backend/data/cache/embedding/models
|
|
||||||
WHISPER_MODEL_DIR=/app/backend/data/cache/whisper/models
|
|
||||||
USE_CUDA_DOCKER_VER=cu128
|
|
||||||
```
|
|
||||||
|
|
||||||
Embeddings for document chat, and Whisper speech-to-text. If you don't use
|
|
||||||
either, `cuda` buys you a much larger image and nothing else. If you do use
|
|
||||||
them, it's the difference between instant and a visible pause.
|
|
||||||
|
|
||||||
**The tag alone doesn't grant GPU access.** You also have to uncomment
|
|
||||||
`gpus: all` in `compose.yaml` and have the NVIDIA Container Toolkit installed
|
|
||||||
on the host. Getting one without the other is the usual reason "the GPU isn't
|
|
||||||
being used".
|
|
||||||
|
|
||||||
### On Apple silicon, don't use `ollama`
|
|
||||||
|
|
||||||
Docker on macOS runs in a VM with **no GPU passthrough** — Metal is not
|
|
||||||
reachable from a container. The bundled `ollama` tag will work and will be
|
|
||||||
miserably slow, because inference falls back to CPU.
|
|
||||||
|
|
||||||
The right shape on a Mac is Ollama installed natively, where it uses Metal,
|
|
||||||
with Open WebUI in Docker pointing at it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OWUI_TAG=main
|
|
||||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
Same applies to any machine where the GPU belongs to something other than
|
|
||||||
Docker.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**One compose file, not three.** The three variants differ by an image tag and
|
|
||||||
two optional lines. Keeping separate directories per variant meant fixing every
|
|
||||||
bug three times, and the differences — the interesting part — weren't written
|
|
||||||
down anywhere.
|
|
||||||
|
|
||||||
**`extra_hosts: host.docker.internal:host-gateway`** is always present, even
|
|
||||||
though only the "Ollama on the host" case needs it. It costs nothing when
|
|
||||||
unused and is the single most common thing people are missing when the WebUI
|
|
||||||
can't see their models.
|
|
||||||
|
|
||||||
**The Ollama volume is commented out.** It's meaningless except with
|
|
||||||
`OWUI_TAG=ollama`, and mounting it otherwise creates a stray empty directory
|
|
||||||
that looks like it should contain something.
|
|
||||||
|
|
||||||
**`WEBUI_SECRET_KEY` is optional and empty.** The image generates one into the
|
|
||||||
data directory on first run. That's fine until you move the data to another
|
|
||||||
host, at which point a regenerated key logs everyone out.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **Switching tags keeps your data.** `DATA_PATH` holds chats, users and
|
|
||||||
settings regardless of tag, so moving between `main` and `ollama` is safe.
|
|
||||||
Your *models* are a different story — those live in `OLLAMA_PATH` and only
|
|
||||||
exist for the bundled tag.
|
|
||||||
- **`:main` is a rolling tag.** `docker compose pull` can bring a different
|
|
||||||
version any day. Pin `OWUI_TAG=v0.11.0` if you'd rather upgrade deliberately.
|
|
||||||
- **The container runs as root** (`User: 0:0` in the image config) and has no
|
|
||||||
`PUID`/`PGID` support. On rootless Docker this is a non-issue; on rootful,
|
|
||||||
expect `DATA_PATH` files to be root-owned.
|
|
||||||
- **The data directory gets big.** It holds the RAG vector DB and cached
|
|
||||||
embedding models, not just chats.
|
|
||||||
|
|
||||||
## Exposing it
|
|
||||||
|
|
||||||
- [Reverse proxy with Caddy](../docs/reverse-proxy.md)
|
|
||||||
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for this one; chat is
|
|
||||||
low-bandwidth. Watch the ~100 MB cap if you upload documents for RAG.
|
|
||||||
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Open WebUI
|
|
||||||
supports OIDC directly, which is nicer than forward auth here.
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Quick start: <https://docs.openwebui.com/getting-started/quick-start>
|
|
||||||
- Image: <https://github.com/open-webui/open-webui/pkgs/container/open-webui>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
services:
|
|
||||||
open-webui:
|
|
||||||
# The TAG decides what is inside the image — full comparison in README.md.
|
|
||||||
# main / latest WebUI only, CPU. Models live elsewhere. (default)
|
|
||||||
# cuda + CUDA runtime for RAG embeddings and STT
|
|
||||||
# ollama + Ollama bundled in the same container
|
|
||||||
# v0.11.0 a frozen release; main/latest are rolling
|
|
||||||
image: ghcr.io/open-webui/open-webui:${OWUI_TAG:-main}
|
|
||||||
container_name: open-webui
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- TZ=${TZ:-Etc/UTC}
|
|
||||||
# Where the models are. Leave commented to pick it in the UI instead.
|
|
||||||
# Ollama on the Docker host -> http://host.docker.internal:11434
|
|
||||||
# Ollama in another container -> http://ollama:11434
|
|
||||||
# Ollama on a Mac / another box -> http://<its-ip>:11434
|
|
||||||
# OWUI_TAG=ollama (bundled) -> leave unset, it is already local
|
|
||||||
#- OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
|
|
||||||
# Session signing key. Generated into the data dir when unset — set it
|
|
||||||
# explicitly if you ever move the data to another host.
|
|
||||||
#- WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY}
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ${DATA_PATH:-./open-webui}:/app/backend/data
|
|
||||||
# ONLY with OWUI_TAG=ollama — where pulled models are stored. Expect tens
|
|
||||||
# of GB. Leave commented for every other tag.
|
|
||||||
#- ${OLLAMA_PATH:-./ollama}:/root/.ollama
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3033}:8080"
|
|
||||||
|
|
||||||
# Lets the container reach an Ollama running on the Docker host.
|
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
|
|
||||||
# ─── NVIDIA GPU ──────────────────────────────────────────────────────
|
|
||||||
# Uncomment with OWUI_TAG=cuda or OWUI_TAG=ollama on an NVIDIA host.
|
|
||||||
# Needs the NVIDIA Container Toolkit installed. Does nothing on Apple
|
|
||||||
# silicon — Docker cannot reach the GPU there at all. See the README.
|
|
||||||
#gpus: all
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: openwebui
|
||||||
|
services:
|
||||||
|
open-webui:
|
||||||
|
ports:
|
||||||
|
- 3033:8080
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
|
volumes:
|
||||||
|
- ./open-webui:/app/backend/data
|
||||||
|
container_name: open-webui
|
||||||
|
restart: always
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
volumes:
|
||||||
|
open-webui:
|
||||||
|
external: true
|
||||||
|
name: open-webui
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Copy to .env and adjust. .env is git-ignored; this file is not.
|
|
||||||
|
|
||||||
# ─── Required ───────────────────────────────────────────────────────
|
|
||||||
# Signs project invite tokens. Generate your own -- do not reuse anyone's:
|
|
||||||
#
|
|
||||||
# openssl rand -base64 32
|
|
||||||
#
|
|
||||||
OVERLEAF_INVITE_TOKEN_SECRET=
|
|
||||||
|
|
||||||
# ─── Public address ─────────────────────────────────────────────────
|
|
||||||
# Every generated link is built from this: account activation, password
|
|
||||||
# reset, project invites. Overleaf defaults to http://localhost, so links
|
|
||||||
# come out wrong the moment you use a port or a domain -- and you only find
|
|
||||||
# out when someone can't reset their password.
|
|
||||||
#
|
|
||||||
# local, non-standard port http://192.168.1.50:8020
|
|
||||||
# behind a reverse proxy https://overleaf.example.com
|
|
||||||
#
|
|
||||||
OVERLEAF_SITE_URL=http://localhost:8020
|
|
||||||
|
|
||||||
# ─── Basics ─────────────────────────────────────────────────────────
|
|
||||||
#OVERLEAF_APP_NAME=Overleaf
|
|
||||||
#PORT=8020
|
|
||||||
|
|
||||||
# Where Mongo, Redis and Overleaf keep their data. Projects live here --
|
|
||||||
# this is the directory to back up.
|
|
||||||
#DATA_PATH=./data
|
|
||||||
|
|
||||||
# ─── HTTPS ──────────────────────────────────────────────────────────
|
|
||||||
# Serving over TLS? Uncomment OVERLEAF_SECURE_COOKIE in compose.yaml.
|
|
||||||
# Careful: the code checks `!= null`, so setting it to "false" turns it ON.
|
|
||||||
# The only way to disable it is to leave it unset.
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
# Overleaf
|
|
||||||
|
|
||||||
Self-hosted LaTeX editor — the Community Edition of overleaf.com. Real-time
|
|
||||||
collaborative editing, compiles server-side, keeps a full TeX Live install so
|
|
||||||
you don't maintain one locally.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
echo "OVERLEAF_INVITE_TOKEN_SECRET=$(openssl rand -base64 32)" >> .env
|
|
||||||
$EDITOR .env # set OVERLEAF_SITE_URL
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
First boot takes a minute or two. Then create the first admin account — see
|
|
||||||
below, there is no signup form at all.
|
|
||||||
|
|
||||||
## Adding users
|
|
||||||
|
|
||||||
Community Edition has **no public registration**. `/register` exists but renders
|
|
||||||
a page pointing at an admin rather than a form, and there's no setting to open
|
|
||||||
it up. Accounts are created one at a time by you.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# the first account -- make it an admin
|
|
||||||
docker compose exec sharelatex bash -c \
|
|
||||||
'cd /overleaf/services/web && node modules/server-ce-scripts/scripts/create-user.mjs --admin --email=you@example.com'
|
|
||||||
|
|
||||||
# everyone after that -- drop --admin
|
|
||||||
docker compose exec sharelatex bash -c \
|
|
||||||
'cd /overleaf/services/web && node modules/server-ce-scripts/scripts/create-user.mjs --email=someone@example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
The command prints an activation URL:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://overleaf.example.com/user/activate?token=<token>&user_id=<id>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Send that link to the person yourself.** No mail is configured here, so
|
|
||||||
Overleaf's attempt to email it goes nowhere. They open it, choose their own
|
|
||||||
password, and they're in — there's no confirmation step, since
|
|
||||||
`EMAIL_CONFIRMATION_DISABLED` is set.
|
|
||||||
|
|
||||||
The host in that URL comes from `OVERLEAF_SITE_URL`. Get that wrong and the
|
|
||||||
link points somewhere unreachable — it defaults to bare `http://localhost`.
|
|
||||||
|
|
||||||
Once an admin exists you can do the same thing from the web UI at
|
|
||||||
**Admin → Manage Users**, which shows the activation link on screen. Same
|
|
||||||
result; the CLI is just faster over SSH.
|
|
||||||
|
|
||||||
On OMV, use `docker exec` instead of `docker compose exec` — see
|
|
||||||
[Running it on OpenMediaVault](#running-it-on-openmediavault).
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
| Port | Proto | Purpose |
|
|
||||||
|------|-------|---------|
|
|
||||||
| 8020 | http | Web UI (container listens on 80) |
|
|
||||||
|
|
||||||
Mongo and Redis are reachable only on the internal compose network.
|
|
||||||
|
|
||||||
## Why it looks like this
|
|
||||||
|
|
||||||
**Four containers, not one.** The app, MongoDB, Redis, and a one-shot replica-set
|
|
||||||
initializer. Overleaf is a small distributed system pretending to be an app.
|
|
||||||
|
|
||||||
**Mongo runs as a replica set** (`--replSet overleaf`) even though there's one
|
|
||||||
node. Overleaf uses transactions and change streams, which Mongo only provides
|
|
||||||
in replica-set mode. Something has to call `rs.initiate()` exactly once, which
|
|
||||||
is what [`init/mongodb-init-replica-set.js`](init/mongodb-init-replica-set.js)
|
|
||||||
does — Mongo runs anything in `/docker-entrypoint-initdb.d/` on first start
|
|
||||||
against an empty data directory. Skip this and Overleaf hangs at boot with
|
|
||||||
errors that don't mention replica sets.
|
|
||||||
|
|
||||||
`extra_hosts: mongo:127.0.0.1` is part of the same trick: the init script
|
|
||||||
registers the member as `mongo:27017`, so during initialization the container
|
|
||||||
has to resolve its own service name.
|
|
||||||
|
|
||||||
**No `SANDBOXED_COMPILES`.** Upstream's sample `docker-compose.yml` ships these
|
|
||||||
*enabled*:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
SANDBOXED_COMPILES: "true"
|
|
||||||
DOCKER_RUNNER: "true"
|
|
||||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
|
|
||||||
```
|
|
||||||
|
|
||||||
Directly above them, its own comment says sandboxed compiles are Server Pro
|
|
||||||
only and "must be commented out to avoid compile issues" on Community Edition.
|
|
||||||
Copy that file as-is and your documents don't compile. They're absent here on
|
|
||||||
purpose.
|
|
||||||
|
|
||||||
**The image is pinned to `6.2.2`.** Upstream's sample is unpinned. This is a
|
|
||||||
database-backed service with schema migrations between majors — an unattended
|
|
||||||
`docker compose pull` picking up a new major is not something you want to
|
|
||||||
discover on a Sunday.
|
|
||||||
|
|
||||||
**`container_name: overleaf-mongo` / `overleaf-redis`**, while the *service*
|
|
||||||
names stay `mongo` and `redis`. The service names are load-bearing —
|
|
||||||
`OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex` resolves them — but upstream's
|
|
||||||
`container_name: mongo` would claim a very generic name on the host and collide
|
|
||||||
with the next stack that wants it.
|
|
||||||
|
|
||||||
**`stop_grace_period: 60s`** so in-flight compiles finish instead of being
|
|
||||||
killed mid-run.
|
|
||||||
|
|
||||||
## Behind your own domain
|
|
||||||
|
|
||||||
Set the public URL — this is the setting people miss:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env
|
|
||||||
OVERLEAF_SITE_URL=https://overleaf.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Then uncomment `OVERLEAF_SECURE_COOKIE` in `compose.yaml`.
|
|
||||||
|
|
||||||
Caddy, which handles WebSockets automatically (Overleaf needs them for
|
|
||||||
collaborative editing and the compile log):
|
|
||||||
|
|
||||||
```caddy
|
|
||||||
overleaf.example.com {
|
|
||||||
reverse_proxy 192.168.1.50:8020
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the whole config — Caddy gets the certificate itself. See
|
|
||||||
[reverse proxy](../docs/reverse-proxy.md).
|
|
||||||
|
|
||||||
A [Cloudflare Tunnel](../cloudflare-tunnel/) also works and passes WebSockets,
|
|
||||||
but mind the ~100 MB upload cap if you bring in large figures or datasets.
|
|
||||||
|
|
||||||
**Order matters:** change `OVERLEAF_SITE_URL` *before* creating accounts, or the
|
|
||||||
activation links you've already sent point at the wrong host.
|
|
||||||
|
|
||||||
## Running it on OpenMediaVault
|
|
||||||
|
|
||||||
Two things differ from a plain `docker compose up`, and both cost me an evening.
|
|
||||||
|
|
||||||
**Copy `init/mongodb-init-replica-set.js` across before the first start.** OMV's
|
|
||||||
compose plugin deploys the compose file and the env file, not the rest of the
|
|
||||||
directory. If that script isn't on disk when Mongo first starts, Docker creates
|
|
||||||
it as an empty *directory* — bind mounts do that for missing paths — Mongo skips
|
|
||||||
it, and the replica set is never initialized.
|
|
||||||
|
|
||||||
The symptom points nowhere near the cause: Mongo reports **healthy** while the
|
|
||||||
app restart-loops with `500_check_db_access.sh failed with status 1`. Mongo's
|
|
||||||
healthcheck only runs `db.stats().ok`, which succeeds fine without a replica set.
|
|
||||||
|
|
||||||
If you've already started it, fix it in place — the entrypoint won't re-run once
|
|
||||||
the data directory has data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec overleaf-mongo mongosh --quiet --eval \
|
|
||||||
'rs.initiate({_id:"overleaf",members:[{_id:0,host:"mongo:27017"}]})'
|
|
||||||
docker restart overleaf
|
|
||||||
```
|
|
||||||
|
|
||||||
**Use `docker exec`, not `docker compose`, for one-off commands.** OMV names the
|
|
||||||
env file `<stack>.env`, and Compose only auto-loads a file called `.env`. So a
|
|
||||||
manual compose command fails on the required variables even though the running
|
|
||||||
stack is perfectly configured:
|
|
||||||
|
|
||||||
```
|
|
||||||
required variable OVERLEAF_INVITE_TOKEN_SECRET is missing a value
|
|
||||||
```
|
|
||||||
|
|
||||||
The container already has the environment, so talk to it directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec overleaf bash -c \
|
|
||||||
'cd /overleaf/services/web && node modules/server-ce-scripts/scripts/create-user.mjs --admin --email=you@example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
If you'd rather have compose work by hand too, symlink it once — OMV keeps
|
|
||||||
passing its own `--env-file` and won't notice:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ln -s overleaf.env .env
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- **`OVERLEAF_SECURE_COOKIE` is tested with `!= null`.** Setting it to `"false"`
|
|
||||||
turns secure cookies **on**. The only way to disable it is to leave it unset —
|
|
||||||
and with it wrongly on over plain HTTP, login silently fails to stick.
|
|
||||||
- **Mongo 8.0 needs AVX.** Mongo 5.0+ won't start on CPUs without it, and the
|
|
||||||
failure is an immediate exit with nothing useful logged. Check first:
|
|
||||||
`grep -o avx2 /proc/cpuinfo | head -1`
|
|
||||||
- **`start_period` on Mongo's healthcheck is load-bearing.** Without it, slow
|
|
||||||
checks during startup count against `retries` straight away — five of them
|
|
||||||
(~50s) mark Mongo unhealthy, and the app's `condition: service_healthy`
|
|
||||||
refuses to start it. Mongo then recovers on its next check, so by morning
|
|
||||||
everything looks fine except the app, which is simply down. This bites on
|
|
||||||
backup scripts that stop and start containers: Mongo comes back while the
|
|
||||||
disks are still busy, `mongosh` is a Node process and answers slowly, and the
|
|
||||||
stack loses a race nobody knew it was running. Upstream's sample has no
|
|
||||||
`start_period` either.
|
|
||||||
- **A healthy Mongo does not mean the replica set is up.** The healthcheck runs
|
|
||||||
`db.stats().ok`, which passes without one — so `depends_on: service_healthy`
|
|
||||||
is satisfied and the app starts anyway, then fails its own DB check.
|
|
||||||
- **The replica-set script only runs on an empty data directory.** If Mongo has
|
|
||||||
data but no replica set, initialize by hand:
|
|
||||||
`docker compose exec mongo mongosh --eval 'rs.initiate({_id:"overleaf",members:[{_id:0,host:"mongo:27017"}]})'`
|
|
||||||
- **No signup page until the first user exists.** Use the `create-user.mjs`
|
|
||||||
script above; the web UI won't offer to make one.
|
|
||||||
- **Missing LaTeX packages** — see the section below. The short version is
|
|
||||||
`tlmgr --usermode install <pkg>`, not plain `tlmgr install`.
|
|
||||||
- **Back up `DATA_PATH`.** `data/mongo` holds every project. `data/overleaf`
|
|
||||||
holds uploaded files and compile output.
|
|
||||||
- **Upgrading across majors** may need migrations, and Mongo itself can't jump
|
|
||||||
several versions at once. Read the release notes rather than pulling blind.
|
|
||||||
|
|
||||||
## Installing LaTeX packages so they stay installed
|
|
||||||
|
|
||||||
The image ships a reduced TeX Live, so a document can fail on a package that
|
|
||||||
exists upstream — `booktabs` is a common first casualty. The obvious fix works
|
|
||||||
and then quietly undoes itself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec overleaf tlmgr install booktabs # works now, gone after an upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
TeX Live lives at `/usr/local/texlive/2026/`, **inside the image**. A
|
|
||||||
`docker restart` keeps those files; recreating the container after
|
|
||||||
`docker compose pull` does not. A document that compiled last month stops
|
|
||||||
compiling and nothing tells you why.
|
|
||||||
|
|
||||||
The fix is user-mode installs plus a `TEXMFHOME` that both users agree on.
|
|
||||||
That second half matters more than it looks: **tlmgr runs as root, but the
|
|
||||||
compiles run as `www-data`**, and by default those two resolve `TEXMFHOME`
|
|
||||||
differently:
|
|
||||||
|
|
||||||
```
|
|
||||||
root /root/texmf <- where a plain `tlmgr --usermode install` lands
|
|
||||||
www-data /var/www/texmf <- where LaTeX actually looks
|
|
||||||
```
|
|
||||||
|
|
||||||
So the install succeeds, reports no error, and the package still isn't found —
|
|
||||||
and `/root` isn't even readable by `www-data`, so no amount of mounting that
|
|
||||||
path helps. This stack therefore sets:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
TEXMFHOME: /var/lib/overleaf/texmf
|
|
||||||
```
|
|
||||||
|
|
||||||
which is inside the persisted volume, so both users agree and the packages
|
|
||||||
survive image updates. Then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec overleaf tlmgr --usermode init-usertree # once
|
|
||||||
docker exec overleaf tlmgr --usermode install booktabs
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify as the user that actually compiles, not as root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -u www-data overleaf kpsewhich booktabs.sty
|
|
||||||
# /var/lib/overleaf/texmf/... correct, persisted
|
|
||||||
# /usr/local/texlive/... inside the image, lost on update
|
|
||||||
# nothing installed somewhere the compiler can't see
|
|
||||||
```
|
|
||||||
|
|
||||||
**Caveats.** User mode handles ordinary style packages cleanly. Packages that
|
|
||||||
install fonts or need map-file updates may also want `updmap-user`, and a few
|
|
||||||
that expect to modify the main tree won't work this way — install those with a
|
|
||||||
custom image instead:
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM sharelatex/sharelatex:6.2.2
|
|
||||||
RUN tlmgr install <packages>
|
|
||||||
```
|
|
||||||
|
|
||||||
That's the more reproducible option in general, since the package list ends up
|
|
||||||
in version control. The trade is rebuilding on every upstream release. If you
|
|
||||||
stay with user mode, keep a list of what you've installed — the volume survives,
|
|
||||||
but a list is what lets you rebuild from nothing.
|
|
||||||
|
|
||||||
## Health checks
|
|
||||||
|
|
||||||
The image ships its own:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec sharelatex bash -c 'cd /overleaf/services/web && node modules/server-ce-scripts/scripts/check-mongodb.mjs'
|
|
||||||
docker compose exec sharelatex bash -c 'cd /overleaf/services/web && node modules/server-ce-scripts/scripts/check-redis.mjs'
|
|
||||||
docker compose exec mongo mongosh --quiet --eval 'rs.status().members.map(m => m.name + " " + m.stateStr)'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- Upstream repo: <https://github.com/overleaf/overleaf>
|
|
||||||
- Their sample compose: <https://github.com/overleaf/overleaf/blob/main/docker-compose.yml>
|
|
||||||
- The Toolkit (upstream's recommended installer): <https://github.com/overleaf/toolkit>
|
|
||||||
- Image: <https://hub.docker.com/r/sharelatex/sharelatex>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
name: overleaf
|
|
||||||
|
|
||||||
services:
|
|
||||||
sharelatex:
|
|
||||||
image: sharelatex/sharelatex:6.2.2
|
|
||||||
container_name: overleaf
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
mongo:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
# Overleaf needs time to finish in-flight compiles on shutdown.
|
|
||||||
stop_grace_period: 60s
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- "${PORT:-8020}:80"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- ${DATA_PATH:-./data}/overleaf:/var/lib/overleaf
|
|
||||||
|
|
||||||
environment:
|
|
||||||
OVERLEAF_APP_NAME: ${OVERLEAF_APP_NAME:-Overleaf}
|
|
||||||
|
|
||||||
# These hostnames are compose SERVICE names, not container names.
|
|
||||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
|
||||||
# One setting, two variable names. Upstream's own comment: "unfortunately".
|
|
||||||
OVERLEAF_REDIS_HOST: redis
|
|
||||||
REDIS_HOST: redis
|
|
||||||
|
|
||||||
# Required for invite tokens. openssl rand -base64 32
|
|
||||||
OVERLEAF_INVITE_TOKEN_SECRET: ${OVERLEAF_INVITE_TOKEN_SECRET:?generate with openssl rand -base64 32}
|
|
||||||
|
|
||||||
# Public address. Every generated link -- activation, password reset,
|
|
||||||
# project invite -- is built from this. Defaults to http://localhost,
|
|
||||||
# which is wrong the moment you use a port or a domain.
|
|
||||||
OVERLEAF_SITE_URL: ${OVERLEAF_SITE_URL:-http://localhost:8020}
|
|
||||||
|
|
||||||
# Where `tlmgr --usermode install` puts packages. Overridden to a path on
|
|
||||||
# the persisted volume so they survive an image update -- and so that root
|
|
||||||
# (which runs tlmgr) and www-data (which runs the compiles) agree on one
|
|
||||||
# location. Left at the default they disagree, and packages install to a
|
|
||||||
# directory the compiler never searches. See the README.
|
|
||||||
TEXMFHOME: /var/lib/overleaf/texmf
|
|
||||||
|
|
||||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
|
|
||||||
ENABLE_CONVERSIONS: "true"
|
|
||||||
EMAIL_CONFIRMATION_DISABLED: "true"
|
|
||||||
|
|
||||||
# Serving over HTTPS? Uncomment. The value is NOT parsed -- the code
|
|
||||||
# tests `!= null`, so even "false" switches it ON. Comment it out to
|
|
||||||
# disable, never set it to false.
|
|
||||||
#OVERLEAF_SECURE_COOKIE: "true"
|
|
||||||
|
|
||||||
# DO NOT add SANDBOXED_COMPILES / DOCKER_RUNNER here. Upstream's sample
|
|
||||||
# ships them enabled, but they are Server Pro only and break compiles on
|
|
||||||
# Community Edition. See the README.
|
|
||||||
|
|
||||||
mongo:
|
|
||||||
image: mongo:8.0
|
|
||||||
container_name: overleaf-mongo
|
|
||||||
restart: unless-stopped
|
|
||||||
# Overleaf uses transactions, which require replica-set mode even on one node.
|
|
||||||
command: "--replSet overleaf"
|
|
||||||
volumes:
|
|
||||||
- ${DATA_PATH:-./data}/mongo:/data/db
|
|
||||||
- ./init/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js:ro
|
|
||||||
environment:
|
|
||||||
MONGO_INITDB_DATABASE: sharelatex
|
|
||||||
# rs.initiate() registers the member as "mongo:27017", so during init the
|
|
||||||
# container has to resolve that name to itself.
|
|
||||||
extra_hosts:
|
|
||||||
- mongo:127.0.0.1
|
|
||||||
healthcheck:
|
|
||||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
|
||||||
interval: 10s
|
|
||||||
timeout: 20s
|
|
||||||
retries: 5
|
|
||||||
# Without this, failures during startup count against `retries`
|
|
||||||
# immediately: five slow checks (~50s) mark Mongo unhealthy, and the app's
|
|
||||||
# `condition: service_healthy` then refuses to start. Mongo recovers a
|
|
||||||
# moment later, so by morning nothing looks wrong. `mongosh` is a Node
|
|
||||||
# process and is slow to answer on a loaded box -- e.g. right after a
|
|
||||||
# backup job. During start_period a failure is not counted, and a success
|
|
||||||
# promotes to healthy straight away.
|
|
||||||
start_period: 120s
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:6.2
|
|
||||||
container_name: overleaf-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ${DATA_PATH:-./data}/redis:/data
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
volumes:
|
||||||
|
web:
|
||||||
|
mongo:
|
||||||
|
redis:
|
||||||
|
mongo-init-replica:
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: sharelatex/sharelatex:4.2.3@sha256:6c6498de22775f1227eed7cf472c764aa851f0fe5b9e640d989ea8a90837159e
|
||||||
|
depends_on:
|
||||||
|
mongo:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
links:
|
||||||
|
- mongo
|
||||||
|
- redis
|
||||||
|
ports:
|
||||||
|
- 8020:80
|
||||||
|
volumes:
|
||||||
|
- ./data/sharelatex:/var/lib/sharelatex
|
||||||
|
environment:
|
||||||
|
SHARELATEX_APP_NAME: Overleaf
|
||||||
|
SHARELATEX_MONGO_URL: mongodb://mongo/sharelatex
|
||||||
|
SHARELATEX_REDIS_HOST: redis
|
||||||
|
REDIS_HOST: redis
|
||||||
|
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
|
||||||
|
ENABLE_CONVERSIONS: "true"
|
||||||
|
EMAIL_CONFIRMATION_DISABLED: "true"
|
||||||
|
TEXMFVAR: /var/lib/sharelatex/tmp/texmf-var
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:4.4@sha256:52c42cbab240b3c5b1748582cc13ef46d521ddacae002bbbda645cebed270ec0
|
||||||
|
command: "--replSet overleaf"
|
||||||
|
expose:
|
||||||
|
- 27017
|
||||||
|
volumes:
|
||||||
|
- ./data/mongo:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:6.2@sha256:5610a8ffeaf386ecc7b6c8f070662a7a9d01bc7eafe237152a7d16e05b84afc9
|
||||||
|
expose:
|
||||||
|
- 6379
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
|
||||||
|
mongo-init-replica:
|
||||||
|
image: mongo:4.4
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
entrypoint: >
|
||||||
|
bash -c "
|
||||||
|
until mongo --host mongo --eval 'print(\"waiting for mongo\")'; do sleep 2; done;
|
||||||
|
mongo --host mongo --eval 'rs.initiate({_id: \"overleaf\", members: [{_id: 0, host: \"mongo:27017\"}]})'"
|
||||||
|
restart: "on-failure"
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Mounted into Mongo's /docker-entrypoint-initdb.d/ and run ONCE, on the very
|
|
||||||
// first start against an empty data directory. Overleaf needs transactions,
|
|
||||||
// which Mongo only offers in replica-set mode -- even for a single node.
|
|
||||||
//
|
|
||||||
// If you ever wipe ./data/mongo, this runs again. If Mongo has data but no
|
|
||||||
// replica set, it does NOT run and you must call rs.initiate() by hand.
|
|
||||||
|
|
||||||
rs.initiate({ _id: 'overleaf', members: [{ _id: 0, host: 'mongo:27017' }] })
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: obwi-with-ollama
|
||||||
|
services:
|
||||||
|
open-webui:
|
||||||
|
ports:
|
||||||
|
- 3034:8080
|
||||||
|
volumes:
|
||||||
|
- ./ollama:/root/.ollama
|
||||||
|
- ./open-webui:/app/backend/data
|
||||||
|
container_name: open-webui
|
||||||
|
restart: always
|
||||||
|
image: ghcr.io/open-webui/open-webui:ollama
|
||||||
|
volumes:
|
||||||
|
ollama:
|
||||||
|
external: true
|
||||||
|
name: ollama
|
||||||
|
open-webui:
|
||||||
|
external: true
|
||||||
|
name: open-webui
|
||||||
Reference in New Issue
Block a user