Self-hosted tracker for series, movies, anime, games, books and music — with optional friends/reviews/comments social features. Built as a TV Time replacement you fully own: your data lives in your own PostgreSQL, catalogues come live from TMDB (movies & series), AniList (anime), IGDB (games), Google Books (books) and MusicBrainz (music).
- Monorepo pnpm workspaces, 100% TypeScript
apps/api— NestJS + Prisma + PostgreSQL (JWT auth, rotating refresh tokens)apps/web— SvelteKit PWA (installable, dark UI), talks to the APIpackages/shared— DTOs/enums shared between front and back
Key design choice: the database is an on-demand cache. Searching queries TMDB/AniList live; a media is persisted (with its seasons, episodes and external IDs — TMDB/AniList/TVDB/IMDB) only when a user tracks it. Episode watches are stored one row per viewing, so rewatches are first-class.
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD, both JWT secrets and TMDB_API_TOKEN.
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml up -dImages are pre-built by CI and pulled from GHCR (ghcr.io/logan2234/loomkeep-{api,web})
— no local build needed.
Then open http://localhost:8080, create an account, done.
On a NAS, set PUBLIC_API_URL and WEB_ORIGIN to the host's address
(e.g. http://nas.local:3000/api and http://nas.local:8080).
Upgrades: docker compose -f docker/docker-compose.yml pull && docker compose -f docker/docker-compose.yml up -d
— database migrations run automatically when the API boots.
Every section below (public hosting, logs/monitoring, Portainer, ...) adds
one more optional docker-compose.<name>.yml override on top of the base
file. Past the first override, don't chain -f flags by hand — set
COMPOSE_FILE in .env instead (Compose reads it automatically,
colon-separated; see the commented-out example in .env.example):
COMPOSE_FILE=docker/docker-compose.yml:docker/docker-compose.prod.yml:docker/docker-compose.observability.ymlthen always run the same two commands, no -f needed — every section below
just tells you which file(s) to add to that line:
docker compose pull
docker compose up -dThis is the same mechanism the VPS auto-deploy uses (see "Auto-deploy from CI" below) — turning an add-on on or off in production is editing that one line, nothing else.
Create a free account on themoviedb.org, then copy the API Read Access
Token (v4) from Settings → API into TMDB_API_TOKEN. Anime search (AniList)
works without any key.
Password reset, email verification, account change confirmations and email
alerts for new episodes all go through SMTP_* in .env. Empty = email is
silently disabled, everything else still works (the reset-password flow just
has no way to reach you).
Brevo has a free SMTP relay (300 emails/day, no
credit card): create an account (personal accounts are fine, no company or
website required), then SMTP & API in the sidebar for SMTP_HOST,
SMTP_USER and SMTP_PASS.
For a stack that's always up under your own domain (e.g. an OVH VPS), and
reachable from your phone as an installed PWA (a public HTTPS URL is
required — the service worker and Web Push refuse plain HTTP on a real
device), use the docker/docker-compose.prod.yml override. It adds a
single-origin Caddy proxy, and Caddy requests and renews a real Let's
Encrypt certificate itself, serving HTTPS on the standard ports.
-
Point the domain's A (and AAAA if you have IPv6) record at the VPS's public IP, and make sure ports 80 and 443 are open on the VPS firewall (80 is required for the Let's Encrypt HTTP challenge, and Caddy redirects it to 443).
-
Set the domain once in
.env— the override derivesPUBLIC_API_URL,WEB_ORIGINand Caddy'sSITE_ADDRESSfrom it:DOMAIN=loomkeep.app
-
Add
docker/docker-compose.prod.ymltoCOMPOSE_FILEin.env(see "Combining add-ons" above), then start the stack:docker compose pull docker compose up -d
-
Open
https://<DOMAIN>— the first request can take a few seconds while Caddy obtains the certificate. Checkdocker compose logs caddyif it doesn't come up (common causes: DNS not yet propagated, or port 80/443 blocked by the VPS provider's firewall in addition to the OS one — OVH VPS also has a network firewall in the control panel that must allow 80/443 separately from anyufw/iptablesrules on the box).
Auto-deploy from CI: .github/workflows/deploy.yml redeploys automatically
on every successful CI run on main (git reset --hard origin/main +
docker compose pull && docker compose up -d, no -f flags, pinned to the
images CI built and pushed to GHCR for that exact commit). It relies on COMPOSE_FILE
being set in the VPS's own .env (see .env.example) to know which override
files to combine — so turning an optional add-on (observability, Portainer,
GlitchTip, ...) on or off in production is done by editing that line on the
VPS, not by touching the workflow.
If the domain's DNS is on Cloudflare (free plan is enough), proxying it through Cloudflare instead of pointing DNS straight at the VPS hides the origin IP and adds free DDoS/bot mitigation and edge caching in front of Caddy. None of this is required — self-hosting works identically with plain DNS — but for a publicly reachable instance it's a meaningful upgrade for zero cost. One-time dashboard setup, no code:
- SSL/TLS mode: Full (Strict), not Flexible. Caddy already gets a real Let's Encrypt certificate (see "Public hosting" above) — Flexible would make Cloudflare talk to the origin over plain HTTP, defeating that.
- Always Use HTTPS: on.
- Proxy status: orange-cloud every subdomain actually served by this
stack (the apex,
grafana.,errors.,portainer.,auth.,home.,feedback., ...) — not just the main app. Free-tier proxying supports WebSockets automatically (Grafana Live needs this), no extra config. - Bot Fight Mode: on (Security → Bots) — free-tier automated-traffic filtering, no configuration needed.
- Optional, one Cache Rule (or Page Rule on very old accounts) caching
/_app/immutable/*aggressively — SvelteKit's own hashed, cache-forever build assets, safe to cache at the edge indefinitely.
Trusting the real client IP: once Cloudflare proxies traffic, every
request Caddy sees comes from Cloudflare's edge, not the actual visitor —
without adjustment, rate-limiting (ThrottlerGuard) and @Ip() would see
Cloudflare's IP for every request instead of the real one. This is already
handled: the API only trusts X-Forwarded-For when TRUST_PROXY=true
(set automatically by docker-compose.prod.yml, since Caddy is always the
one hop in front there — see src/main.ts). Self-host without the prod
override leaves it off by default, since nothing there guarantees a proxy
sits in front of the API.
Residual risk worth locking down (manual, outside this repo): the base
docker-compose.yml still publishes the API's port directly
(3000:3000), so if that port is reachable from the internet on the VPS,
someone could bypass Cloudflare and Caddy entirely, hitting the API
directly and forging X-Forwarded-For themselves — TRUST_PROXY=true
alone doesn't protect against that. Two independent ways to close it: your
cloud provider's own firewall/security group only allowing 22/80/443 in
(not just the VPS's OS-level ufw/iptables — Docker is known to
sometimes bypass ufw rules by writing to iptables directly), and/or a
Caddy remote_ip matcher only accepting connections from
Cloudflare's published IP ranges,
rejecting everything else with a 403. Not wired into this repo's Caddyfile
since Cloudflare's ranges can change over time and hardcoding them here
would need periodic maintenance — worth doing by hand on the actual VPS if
you want the extra layer.
A CAPTCHA-alternative widget on the register form, gated the same way every optional integration here is: empty env var = feature off, nothing else changes.
- Requires a Cloudflare account (see above), but not proxying — Turnstile works independently of whether DNS/proxy is set up.
- Cloudflare dashboard → Turnstile → Add widget → Managed challenge type, domain = your instance's domain.
- Copy the Site Key into
PUBLIC_TURNSTILE_SITE_KEY(public, read by the web app) and the Secret Key intoTURNSTILE_SECRET_KEY(private, read by the API — never expose this one to the browser). - Leave both empty for self-host without a Cloudflare account: no widget
renders, and
TurnstileService.verify()always passes server-side too.
The API logs structured JSON (level, route, duration) via nestjs-pino —
docker compose logs api works out of the box, and every service's logs are
capped at 10MB × 5 files so they can't slowly fill the disk. For a
searchable log history, dashboards, and metrics, add the observability
override on top of whichever deployment you're running:
Set GRAFANA_ADMIN_PASSWORD in .env, add docker/docker-compose.observability.yml
to COMPOSE_FILE (see "Combining add-ons" above), then:
docker compose pull
docker compose up -dThis adds:
- Grafana + Loki + Promtail — Promtail ships every container's logs into Loki automatically.
- Prometheus + node_exporter + postgres_exporter — host-level metrics (CPU/RAM/disk) and Postgres metrics (connections, table sizes...), both pre-wired as a Grafana data source.
If you set DOMAIN (see "Public hosting" above), Grafana is also reachable
publicly at grafana.<DOMAIN> via Caddy, gated by Grafana's own login. It's
always reachable at 127.0.0.1:3001 too, whether or not DOMAIN is set —
useful as a tunnel-only fallback:
ssh -L 3001:localhost:3001 <user>@<your-vps>then open http://localhost:3001 locally. Prometheus itself has no exposed
port at all — it's purely a Grafana data source, queried through Grafana's
own auth.
Grafana/GlitchTip tell you when the app is unhealthy or throwing errors, but
neither notices a scheduled job that silently stops firing (a crashed
scheduler, a hung job that never throws) — Postgres and the app can both
look perfectly healthy while a cron job just isn't running anymore. The API
has four such jobs (src/jobs/job-keys.ts): notification scan (hourly),
media cache refresh (every 6h), reports digest and the automatic backup
(both daily).
Healthchecks.io closes that gap: each job pings it once it finishes, and Healthchecks.io itself alerts you if an expected ping doesn't show up on schedule — no add-on to run, works whether or not you have the observability override above.
- Create a free account, then one check per job, with a Period/Grace matching its schedule (e.g. Period 1h for the notification scan, Period 6h for the cache refresh, Period 1 day for the digest and the backup — a Grace of an hour or so absorbs normal jitter).
- Copy each check's ping URL into
.env:HEALTHCHECKS_NOTIFICATIONS_SCAN_URL,HEALTHCHECKS_MEDIA_REFRESH_STALE_URL,HEALTHCHECKS_REPORTS_DIGEST_URL,HEALTHCHECKS_BACKUP_URL. Any left empty just means that job doesn't ping — nothing else is affected. - Optional: for the Homepage tile below, a read-only API key
(
HEALTHCHECKS_API_KEY, Project Settings → API Access) shows an up/down count across every check at a glance. - Optional, if you also run the observability override: Prometheus scrapes
Healthchecks.io's own per-project metrics endpoint (
hc_check_upper check) instead of just a Homepage tile — native Grafana history/alerting on job health, no extra container. Generate a read-only API key (Project Settings → API Access), write it (no trailing newline) todocker/observability/healthchecks_token(copy fromhealthchecks_token.example, gitignored — same convention as Authelia's secrets), and set the project's UUID in thehealthchecksjob'smetrics_pathindocker/observability/prometheus.yml.
Add docker/docker-compose.portainer.yml to COMPOSE_FILE (see "Combining
add-ons" above), then:
docker compose pull
docker compose up -dAdds Portainer, reachable at
portainer.<DOMAIN> via Caddy — a web UI for containers/images/volumes
without SSH. Gated by Portainer's own admin login (set on first visit).
Set GLITCHTIP_SECRET_KEY and GLITCHTIP_DB_PASSWORD in .env, add
docker/docker-compose.glitchtip.yml to COMPOSE_FILE (see "Combining
add-ons" above), then:
docker compose pull
docker compose up -dAdds GlitchTip (Sentry-API-compatible, self-hosted
error tracker), reachable at errors.<DOMAIN> via Caddy. Where the log
search above shows every log line, GlitchTip groups exceptions by
fingerprint into a single "issue" with an occurrence count, and can email
you the moment a new error type first appears rather than waiting for you
to go looking. Gated by GlitchTip's own login (the first visitor sets up the
org). Runs its own dedicated Postgres + Valkey, separate from the app's
database. Email alerts are off out of the box — see the
GLITCHTIP_EMAIL_URL comment in .env.example to enable them.
The app itself only reports to GlitchTip once you create a project there
(one for the API, platform "Node"; one for the web app, platform
"JavaScript") and set the resulting DSNs — GLITCHTIP_API_DSN and
PUBLIC_GLITCHTIP_WEB_DSN in .env (both empty by default, meaning
reporting stays off). Production only; pnpm dev never reports here. Errors
only — no performance tracing on the web side, no session replay (GlitchTip
doesn't implement the replay protocol, so those events would just be
dropped). See apps/api/src/instrument.ts and apps/web/src/hooks.client.ts.
Set UMAMI_APP_SECRET in .env (openssl rand -hex 32), add
docker/docker-compose.umami.yml to COMPOSE_FILE (see "Combining add-ons"
above), then:
docker compose pull
docker compose up -dAdds Umami, a lightweight self-hosted analytics tool for
the public landing page, reachable at stats.<DOMAIN> via Caddy. Cookie-less
and anonymous by design (no persistent visitor ID) — tracks page views,
referrers, and clicks on the main CTA buttons and every external link
(data-umami-event attributes in apps/web/src/routes/+page.svelte), never
anything under /app. Shares the app's own Postgres instance rather than
running a dedicated database container — see the top comment in
docker-compose.umami.yml for the one-time manual step required on an
already-running instance. Gated by Umami's own login only (default
admin/umami on first visit — change it immediately); not behind
Authelia, since Umami has no OIDC support and gating the whole subdomain
would also block the tracker script for anonymous visitors.
After first login, register the site under Settings > Websites > Add
website, then set PUBLIC_UMAMI_WEBSITE_ID (the UUID shown there) and
PUBLIC_UMAMI_SCRIPT_URL (https://stats.<DOMAIN>/loomkeep.js) in .env and
redeploy the web service — both empty by default, meaning no tracking
script loads at all until configured.
TRACKER_SCRIPT_NAME/COLLECT_API_ENDPOINT (set in
docker-compose.umami.yml) already apply Umami's own documented ad-blocker
mitigation for self-hosted instances (renaming script.js//api/send off
their defaults, which generic filter lists block by name even for
cookie-less tools). If using the Cloudflare setup above, add one more
one-time dashboard step so Umami's location stats reflect real visitors
instead of Cloudflare's edge: Rules > Settings > Managed Transforms,
enable Add visitor location headers — Caddy's reverse_proxy in
umami/umami.caddy already forwards every header untouched, so no compose
or Caddy change is needed on this side.
Add docker/docker-compose.authelia.yml and docker/docker-compose.homepage.yml
to COMPOSE_FILE (see "Combining add-ons" above), then:
docker compose pull
docker compose up -dAdds Homepage, reachable at home.<DOMAIN> — one
page with a link tile for every dashboard above (Grafana, Portainer,
GlitchTip, the app itself, Authelia, plus a few external bookmarks) and a
Google search bar. Requires the single sign-on section below too —
Homepage has no login of its own and is gated entirely by Authelia's
forward_auth.
No Docker socket access — deliberately. Grafana and Portainer get live
widgets through their own APIs (an admin password reused from
GRAFANA_ADMIN_USER/PASSWORD, and a dedicated Portainer access token);
GlitchTip has no native Homepage widget, so its tile calls GlitchTip's own
Sentry-compatible issues API directly to list recent unresolved errors — the
most fragile of the three, first thing to check if it ever goes blank.
Per-container CPU/RAM stats on every tile were considered and skipped:
Grafana + Prometheus + cAdvisor already cover that in more depth, so a
second Docker-access path here would've been redundant. To wire up the
widgets, set in .env (see the comments there for exactly where to
generate each): PORTAINER_API_KEY, PORTAINER_ENV_ID,
GLITCHTIP_API_TOKEN, GLITCHTIP_ORG_SLUG. Any left empty just means that
tile's widget shows no data — nothing else breaks.
The "Statistiques" tile reads GET /api/public-stats/summary
(apps/api/src/admin/public-stats.controller.ts) — a small dedicated
endpoint, deliberately not the full /admin/stats page's getStats(),
which computes cohorts/retention curves too heavy to run on every ~10s
widget poll. Four cheap count() queries in parallel: total users, total
LibraryEntry rows (media tracked across every domain), open Reports
(moderation queue), and new signups in the last 7 days. Gated by a shared
secret (HOMEPAGE_STATS_API_KEY in .env, sent as a bearer token) rather
than the app's normal JWT login, since Homepage has no user session —
generate any long random string, the endpoint fails closed (unreachable,
not just widget-less) if it's unset. It's used here rather than
@nestjs/terminus's /health (still what Docker's own healthcheck uses,
untouched) because Terminus's response always includes an error object
field ({} when healthy), and Homepage's customapi widget crashes outright
trying to render that as a React child (error #31) — happened live, even
after mapping and remapping that field away. public-stats/summary is
plain strings/numbers end to end, no such risk. The App tile itself has no
widget at all — a lone "Statut" field it had before added no information
the tile's own link and Docker's healthcheck don't already cover. Authelia's
tile lives in bookmarks.yaml, not the main service tiles — nothing to
manage there day-to-day, kept only as a visible reminder the dashboards sit
behind SSO. bookmarks.yaml also has a "recent error logs" deep link
straight into Grafana Explore's Loki view (last hour, every container) —
the single most fragile link on the page, since Grafana's Explore URL
format is version-specific and has changed before; falls back to opening
Grafana normally if it ever breaks.
A "DB" tile shows total database size and row count, straight from
Prometheus/postgres_exporter's default metrics (pg_database_size_bytes,
pg_stat_user_tables_n_live_tup) — this deliberately isn't in the app's own
/admin/stats "Système" section (moved out on purpose: it's infrastructure
monitoring, not Loomkeep business data, and doesn't need a query running on
every admin page load).
Development-loop visibility that has nothing to do with the app itself
lives in two tiles: "Déploiement" (the deployed build's git SHA next to
GitHub's latest main commit — eyeball the two, no computed diff, that'd
mean the API calling GitHub's own API just to compare itself — and the last
successful deploy.yml run) and "Pull requests & issues" (open counts via
GitHub's search API, ?q=repo:...+is:pr+is:open / is:issue+is:open, a
bare total_count instead of paginating the full list). Each stacks its
API calls via Homepage's widgets: (plural) key, which always renders
vertically — confirmed against Homepage's item.jsx source, no config
makes separate stacked widgets sit side by side, only fields that share one
mappings list on one API call do (that's why a single widget's own
multiple fields, like VPS's CPU/RAM/Disque, do render in a row). Getting
these five GitHub numbers on one row would need a small proxy combining
three different endpoints server-side into one JSON response first — more
machinery than it's worth here. "Qualité" similarly stacks two independent
API calls (Codecov's coverage, and the public
api.securityscorecards.dev JSON endpoint — same source as the README's
OpenSSF badge — for the Scorecard number), so it's a two-row stack too, not
a two-across row.
Dependabot alerts and CodeQL code-scanning alerts get their own tiles —
each a dynamic-list widget (a handful of alert rows) capped at 5 visible
rows, plus a second stacked widget hitting the same URL again with
per_page=100 and a format: size mapping (no field, so it measures the
root array's length) to show a "Total" count. That duplicate request is the
only way to get a count next to a dynamic-list — Homepage doesn't let one
widget be both a list and expose a plain count field — and the total is
itself capped at 100 (GitHub's own per-page max): if it ever reads exactly
"100" there may be more. GlitchTip's tile does the same trick against its
own API (capped at limit=100 there instead). No severity sort on Code
scanning: GitHub's alerts API only sorts by created/updated (checked
against GitHub's own REST docs), and Homepage's dynamic-list has no
client-side sort option either — rows are in whatever order GitHub returns
them (newest first). Both Dependabot and code-scanning need a token even on
a public repo (confirmed against GitHub's own docs) — a fine-grained PAT
scoped to just this repo, with both "Dependabot alerts: read-only" and
"Code scanning alerts: read-only" permissions (gated independently despite
sharing one Security tab), set as HOMEPAGE_GITHUB_TOKEN and reused across
every GitHub-hosted tile for the higher authenticated rate limit (5000/h vs
60/h).
All of the above is split across five services.yaml groups (Loomkeep,
Infrastructure, GitHub & qualité, Emails, Alertes — Brevo has nothing to do
with the repo, so it doesn't live in "GitHub & qualité" despite being
tracked alongside it during this page's early iterations), each rendered
with Homepage's own default tile styling. A dark/monospace/hairline full
reskin was tried and reverted: Homepage's tile markup is fixed (icon + name
- description + a stats row), so no amount of CSS made it look like a
hand-designed page, and
color: tealinsettings.yamlturned out to tint the entire page background rather than just accents.color: slate(neutral) is what's running; the only survivingcustom.cssrule is a singlemargin-bottomon.services-groupso collapsed groups don't sit flush against each other. Every group except Alertes setsstyle: rowinsettings.yaml'slayout(tiles side by side instead of Homepage's default one-per-line stack —columnsalone silently does nothing withoutstyle: row, confirmed against Homepage's own source); Alertes stays at the default stack since its three tiles are alert lists that need full row width, not a cramped side-by-side column. Loomkeep's three tiles are ordered App, Statistiques, Admin (not alphabetically) so its 2-column row places Admin directly under App and Statistiques on the right — Homepage's grid has no per-tile row-span, so ordering into the row-major grid fill is the only lever. Groups also get an icon (layout.<name>.icon, samemdi-/si-/sh-/URL resolution as a service's ownicon:— undocumented but real, found by readinggroup.jsx; a literal emoji does not work there or on a service icon, it falls through to a broken image, confirmed againstresolvedicon.jsx). Homepage's block-highlighting feature (widget.highlight, colors a value red/amber/emerald past a threshold) is wired up on Healthchecks (up green / down red), UptimeRobot (sitesUp green / sitesDown red), Portainer (running green / stopped red), and VPS's CPU/RAM/Disque (green under 33%, amber 33–66%, red above) — all native orprometheusmetricwidgets. It's explicitly not supported oncustomapiwidgets per Homepage's own docs, which rules it out for Brevo/Déploiement/Pull requests & issues/Qualité/GlitchTip/Dependabot/Code scanning/Statistiques — no coloring, no workaround, on any of those. VPS/Healthchecks/UptimeRobot/Portainer's thresholds are otherwise unverified live (VPS especially: highlighting isn't documented either way forprometheusmetric, only called out as unsupported forcustomapi). There's also no group-level "total alerts across every tile" number — Homepage has no group-wide computed aggregate, only per-service widgets.
One login for every dashboard above instead of a separate password each — Authelia sits in front of Caddy and either gates a site directly (Homepage) or lets the app itself redirect to it via OIDC (Grafana, GlitchTip, Portainer — visiting a second app after the first just bounces through silently, no second password).
-
Copy the two templates and fill in every
REPLACE_ME(never share the plaintext values in chat, including with Claude):cp docker/authelia/configuration.yml.example docker/authelia/configuration.yml cp docker/authelia/users_database.yml.example docker/authelia/users_database.yml
-
Generate the secrets (run each once, paste the output where the matching
REPLACE_MEsays so):docker run --rm authelia/authelia:4.39.20 authelia crypto rand --length 64 # x4: reset-password jwt_secret, session secret, storage encryption_key, oidc hmac_secret cd docker/authelia && docker run --rm -v "$(pwd):/out" authelia/authelia:4.39.20 authelia crypto pair rsa generate -d /out && cd - # writes docker/authelia/private.pem — paste its full contents (indented, including # -----BEGIN/END PRIVATE KEY-----) into the oidc.jwks key, then delete both # private.pem and public.pem (the -v mount is required — without it "-d ." # writes inside the throwaway --rm container instead of your machine) docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'your-own-password' # your login — never tell anyone (including Claude) the plaintext
Every
crypto hash generate/crypto randcommand above prints a labelled line, e.g.Digest: $argon2id$v=19$...orRandom Value: xY7k...— copy only the part after theDigest:/Random Value:label into the config. Pasting the label too is the single most common way this setup silently fails to authenticate.Log in with the username (the YAML key in
users_database.yml, e.g.logan), not the email address — Authelia's file backend doesn't accept email-as-username at the login form. -
For each of the three OIDC clients (grafana/glitchtip/portainer) in
docker/authelia/configuration.yml: generate a random secret and its hash, keep the plaintext somewhere safe (you'll need it in step 5/6), paste the hash intoclient_secret:docker run --rm authelia/authelia:4.39.20 authelia crypto rand --length 64 --charset alphanumeric # the plaintext docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate pbkdf2 --variant sha512 --password 'paste-the-plaintext-above' # the hash, goes in configuration.yml
-
Also fill in
notifier.smtp.username/passwordwith the same values as this repo'sSMTP_USER/SMTP_PASS— unlike the rest of the app, Authelia hard-fails to start without working SMTP (it's a startup health check, not an optional feature — no blank-SMTP degraded mode here). -
Set
GRAFANA_OIDC_CLIENT_SECRETin.envto the plaintext secret from step 3 for thegrafanaclient. -
With
docker/docker-compose.authelia.yml(and whichever of observability/ Portainer/GlitchTip you use) already inCOMPOSE_FILE(see "Combining add-ons" above), bring the stack up, then do the two integrations that can't be done through config alone:docker compose pull docker compose up -d
- GlitchTip: on the VPS, temporarily set
ENABLE_ADMIN: trueindocker-compose.glitchtip.ymlanddocker compose up -d glitchtip(no rebuild, no git push needed — this is a runtime toggle, not a code change). Visithttps://errors.<DOMAIN>/admin/socialaccount/socialapp/, add a SocialApp — ProviderOpenID Connect, Provider IDauthelia, Client IDglitchtip, Secret Key = the plaintext from step 3 for theglitchtipclient, Settings{"server_url":"https://auth.<DOMAIN>/.well-known/openid-configuration"}. Then setENABLE_ADMINback tofalseindocker-compose.glitchtip.ymlanddocker compose up -d glitchtipagain. - Portainer: Settings → Authentication → OAuth → Provider
Custom, Client IDportainer, Client Secret = the plaintext from step 3 for theportainerclient, Authorization URLhttps://auth.<DOMAIN>/api/oidc/authorization, Access Token URLhttps://auth.<DOMAIN>/api/oidc/token, Resource URLhttps://auth.<DOMAIN>/api/oidc/userinfo, Redirect URLhttps://portainer.<DOMAIN>, User Identifierpreferred_username, Scopesopenid profile email— space-separated, no commas: Portainer's own Scopes field placeholder looks comma-separated but sends whatever you type verbatim as one raw OAuthscopeparameter, so commas produce a single invalid scope (invalid_scope: ... 'openid,profile,email') instead of three valid ones.
- GlitchTip: on the VPS, temporarily set
Grafana needs no manual step — it's fully wired via env vars in
docker/docker-compose.observability.yml.
Logout is one-way: Authelia doesn't yet support RP-Initiated Logout (the
OIDC mechanism for propagating a logout back to the identity provider —
open upstream issue), so
logging out of Grafana/GlitchTip/Portainer individually only ends that app's
own session, not the Authelia SSO session itself. To fully log out, visit
auth.<DOMAIN> directly — otherwise the session just expires on its own
after session.expiration (authelia/configuration.yml, 1h by default).
pnpm i
docker run -d --name loomkeep-dev-db -e POSTGRES_USER=loomkeep \
-e POSTGRES_PASSWORD=loomkeep -e POSTGRES_DB=loomkeep \
-p 5433:5432 postgres:18-alpine
cp .env.example .env # then add your TMDB token — read by the API too, see below
cp apps/api/.env.example apps/api/.env
pnpm --filter @loomkeep/api exec prisma migrate dev
pnpm build:package
pnpm dev # api on :3000, web on :5173Tests:
pnpm --filter @loomkeep/api test # unit (provider mapping)
pnpm --filter @loomkeep/api test:e2e # full API flow, isolated "e2e" schemaAGPL-3.0 — self-host freely; run it as a service, share your changes.