Skip to content

Reclaimerr

Jellyfin/Plex disk-space reclamation tool in the media namespace. Identifies and deletes unwatched or low-rated media, exposing an internal-only web UI via Envoy Gateway. Standard bjw-s app-template deployment mirroring sibling apps (maintainerr, pulsarr).

Purpose

  • Reclaim disk by flagging and removing unwatched / low-rated media, integrating with Jellyfin/Plex and the *arr stack (Sonarr/Radarr).
  • Single Deployment pod with a small VolSync-backed PVC (2Gi) at /app/data holding the app database, logs, and static files.
  • Jellyfin/Plex and *arr connections are configured through the UI post-deploy, not in Git.

Design decisions

  • bjw-s app-template chart (OCI source, tag matching maintainerr/pulsarr): the standard ks.yaml + app/ (kustomization, ocirepository, helmrelease) layout. No deviation from the namespace pattern.
  • No ExternalSecret. Reclaimerr auto-generates JWT_SECRET and ENCRYPTION_KEY on first launch and persists them into /app/data (the upstream-recommended path). This is simpler than 1Password management; the cost is that the keys live only in the PVC, so the PVC is the source of truth for sessions and encrypted state.
  • Internal-only ingress. Inline route: on the envoy-internal listener (namespace network) for the single ${SECRET_DOMAIN} hostname: the envoy-internal attachment is what keeps it internal-only. No Cloudflare/external exposure, consistent with maintainerr.
  • COOKIE_SECURE: "true" because Envoy terminates TLS in front of it, and CORS_ORIGINS is the single origin https://reclaimerr.${SECRET_DOMAIN} so the SPA's API calls are accepted.
  • Components: homepage (dashboard tile under the Media group, mdi-broom icon) and volsync (PVC backup). (Gatus monitoring is automatic via the gatus-sidecar chart's HTTPRoute auto-discovery, no gatus/guarded component.) VOLSYNC_CAPACITY: 2Gi overrides the 5Gi default; the cache size is deliberately not pinned, so it follows the component default.
  • Deferred (out of scope): external ingress, pre-seeding JWT_SECRET/ENCRYPTION_KEY via 1Password, and a TMDB_API_KEY override (the bundled upstream key is fine).

Deploy gotchas

  • readOnlyRootFilesystem: true needs a writable /tmp. The container security context locks the root filesystem read-only, so a tmp emptyDir is mounted at /tmp. Without it the app fails to write scratch files at runtime. /app/data is writable via the VolSync PVC.
  • Health endpoint is a backend JSON route, not an SPA. All three probes (liveness, readiness, startup) hit GET /api/info/health on port 8000, and the Gatus condition is [STATUS] == 200 (not a redirecting-SPA check). The Gatus endpoint targets the in-cluster service URL on *.svc.cluster.local.
httpGet: { path: /api/info/health, port: 8000 }
  • Startup probe must allow a slow first boot. First launch generates the JWT/encryption keys and initialises the DB into the empty PVC; size the startup probe generously (failureThreshold: 30, periodSeconds: 5) so it does not CrashLoop before init finishes.
  • Release tag, pinned by digest. The image moved off the beta tag to a 0.5.x@sha256:... release tag in #6104, and Renovate tracks releases. Keep the digest pin so a retagged release cannot silently change the running build.
  • Component path depth. From kubernetes/apps/media/reclaimerr/ks.yaml, the component references need four ../ segments to reach components/. A wrong depth or a missing VOLSYNC_CAPACITY substitute fails the flate/flux render.

Notifications

  • Pushover via Apprise. Reclaimerr has one Apprise notification service, named "Pushover". Like the other connections, it lives in the app's database on the PVC, not in Git.
  • Credentials. The Pushover application "Reclaimerr" has its token in the RECLAIMERR_PUSHOVER_TOKEN field of the pushover item in the Talos 1Password vault, next to the shared PUSHOVER_USER_KEY. The Apprise URL is pover://<user key>@<app token>; both halves are credentials, so never print it.
  • Enabled events: New Cleanup Candidates (top 5 summary), Task Failures, Deletion Execution Failures, Deletion Completed, Deletion Failed, and Your Requests Leaving Soon. Request approved/declined and the admin request events are off (single-user instance). Update Available is off because Renovate already raises release PRs.
  • How it was created (2026-10-14). A short Python script run inside the pod (PYTHONPATH=/app) sent a test with the app's own backend.services.notifications.test_notification_url, then inserted a NotificationSetting row through backend.database.async_db. The URL was piped in on stdin from 1Password, so it never went through the browser.
  • The settings UI can blank the URL. After a reload the UI showed the Apprise URL box empty, although the settings API returns the stored URL. Saving that entry from the UI may overwrite the URL with a blank one. Change it through the in-pod path instead, or confirm the box is filled before saving.

Operational notes

  • The PVC is the only state. Losing /app/data loses the auto-generated JWT_SECRET and ENCRYPTION_KEY (all sessions invalidated, encrypted config unreadable). Recovery is via the VolSync snapshot/backup of the reclaimerr PVC. There is no 1Password copy of the keys.
  • Runs unprivileged. Pod security context is runAsNonRoot as UID/GID 1000 with fsGroup: 1000 and fsGroupChangePolicy: OnRootMismatch; the container drops all capabilities and disables privilege escalation. The cpu request is 10m; the memory limit is 6Gi (#6105, #6111), because a full series sync holds Plex's whole play history in memory and was OOMKilled at 2Gi.
  • First-run setup is manual. After the pod is Ready, browse to the internal hostname and wire up Jellyfin/Plex and *arr connections through the UI; nothing is pre-seeded.
  • Verify after merge: PVC Bound, pod Running / Ready=1/1, logs show it listening on :8000 with keys generated into /app/data, the HTTPRoute is accepted by envoy-internal, and GET /api/info/health returns a 200 JSON body via the internal hostname.
  • Rollback: flux suspend helmrelease reclaimerr -n media then delete the pod; reverting the commits prunes the Kustomization (prune: true) while VolSync snapshots the PVC per the volsync component defaults.