Compare commits

..

3 Commits

Author SHA1 Message Date
Goyban fe76630071 added jellyfin 2026-08-18 15:14:23 +02:00
Goyban f1c75e95f2 updated scripts 2025-07-08 10:14:41 +02:00
Goyban 712e08d038 Initial commit 2025-07-08 09:14:59 +02:00
58 changed files with 118 additions and 3778 deletions
-18
View File
@@ -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
+3 -15
View File
@@ -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. -->
-29
View File
@@ -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
-161
View File
@@ -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>
-23
View File
@@ -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"
-20
View File
@@ -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
-82
View File
@@ -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>
-21
View File
@@ -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
-57
View File
@@ -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
-112
View File
@@ -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>
-44
View File
@@ -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"
-31
View File
@@ -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
-103
View File
@@ -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>
-48
View File
@@ -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
-40
View File
@@ -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
View File
@@ -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
+3 -7
View File
@@ -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
-25
View File
@@ -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
-163
View File
@@ -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>
-32
View File
@@ -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"
-26
View File
@@ -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
-63
View File
@@ -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
-244
View File
@@ -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>
-64
View File
@@ -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
-34
View File
@@ -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
View File
@@ -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>
-40
View File
@@ -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"
-38
View File
@@ -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.
-223
View File
@@ -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/>
-61
View File
@@ -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
-95
View File
@@ -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
-29
View File
@@ -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
-96
View File
@@ -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/>
-14
View File
@@ -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
-20
View File
@@ -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
-122
View File
@@ -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>
-14
View File
@@ -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
-20
View File
@@ -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;
}
}
}
-40
View File
@@ -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
-121
View File
@@ -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>
-76
View File
@@ -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
View File
@@ -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
-50
View File
@@ -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
-93
View File
@@ -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>
-19
View File
@@ -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)
-30
View File
@@ -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.
-103
View File
@@ -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>
-24
View File
@@ -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
-44
View File
@@ -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=
-113
View File
@@ -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>
-41
View File
@@ -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
+16
View File
@@ -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
-32
View File
@@ -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.
-296
View File
@@ -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>
-93
View File
@@ -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
+60
View File
@@ -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' }] })
+18
View File
@@ -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