docs: update jellyfin-services.md — cron run-once Watchtower approach

This commit is contained in:
2026-05-24 11:49:44 +00:00
parent bb81353a46
commit ad1dd01484
+43 -50
View File
@@ -5,13 +5,12 @@
- **IP**: `192.168.88.10`
- **SSH**: `jgitta@192.168.88.10` (alias: `jellyfin`)
- **Portainer endpoint ID**: 4
- **Compose files**: managed via Portainer (stack IDs below); source of truth is Gitea `jgitta/homelab-configs`
---
## Stacks
## Stacks (Portainer)
| Stack | Portainer ID | Gitea Path |
| Stack | ID | Gitea Path |
|---|---|---|
| arr-stack | 35 | `jellyfin/arr-stack/docker-compose.yml` |
| portainer-agent | 36 | `jellyfin/portainer-agent/` |
@@ -27,67 +26,61 @@
| sonarr | lscr.io/linuxserver/sonarr:latest | 8989 | /srv/docker/sonarr/config |
| prowlarr | lscr.io/linuxserver/prowlarr:latest | 9696 | /srv/docker/prowlarr/config |
| flaresolverr | ghcr.io/flaresolverr/flaresolverr:latest | 8191 | — |
| watchtower | containrrr/watchtower | — | see below |
| watchtower-prowlarr | containrrr/watchtower | — | see below |
Media volume: `/mnt/media` on jellyfin host → mounted as `/data` in radarr/sonarr.
Media volume: `/mnt/media` → mounted as `/data` in radarr/sonarr.
---
## Watchtower — Split-Instance Setup
## Auto-Updates — Cron + Watchtower run-once
Two Watchtower instances run in the arr-stack to stagger registry checks and avoid
rate limiting lscr.io/ghcr.io with too many concurrent requests.
Watchtower is NOT run as a persistent container. Instead, cron jobs on the jellyfin
host invoke Watchtower in `--run-once` mode, one container at a time, at staggered
3-minute intervals. This serializes all lscr.io registry checks and eliminates the
CDN burst rate limit errors that occurred with concurrent requests.
| Instance | Schedule | Scope | Manages |
|---|---|---|---|
| `watchtower` | 4:00 AM | (none) | radarr, sonarr, flaresolverr, portainer-agent |
| `watchtower-prowlarr` | 4:05 AM | `prowlarr` | prowlarr only |
### Schedule (jgitta crontab on jellyfin)
The scope works via Docker labels: prowlarr has
`com.centurylinklabs.watchtower.scope=prowlarr` — this tells the main `watchtower`
to skip it, and tells `watchtower-prowlarr` to manage it.
| Time | Container | Command |
|---|---|---|
| 4:00 AM | radarr | `watchtower-update.sh radarr` |
| 4:03 AM | sonarr | `watchtower-update.sh sonarr` |
| 4:06 AM | prowlarr | `watchtower-update.sh prowlarr` |
| 4:09 AM | flaresolverr + others | `watchtower-update.sh` (no scope) |
Both instances mount `/home/jgitta/.docker/config.json:/config.json:ro` for
authenticated registry pulls.
### Update script
Location: `/usr/local/bin/watchtower-update.sh` on jellyfin host.
To view: `ssh jellyfin cat /usr/local/bin/watchtower-update.sh`
To edit cron: `ssh jellyfin crontab -e`
Each run spins up a temporary `containrrr/watchtower` container with `--run-once`,
checks for updates, sends an email notification if anything changed, and exits.
The container is automatically removed after each run (`--rm`).
### Scope labels
radarr, sonarr, and prowlarr each carry a `com.centurylinklabs.watchtower.scope=<name>`
label in the compose file. This tells Watchtower to only check the matching container
when that scope is set. flaresolverr has no scope label and is handled by the
unscoped (4:09 AM) run.
---
## Registry Authentication (fixed 2026-05-20)
## Registry Authentication
**Problem**: Watchtower was hitting lscr.io/ghcr.io rate limits and timeouts with
unauthenticated requests. lscr.io is a proxy that redirects to ghcr.io for the actual
registry API — credentials for lscr.io alone were not enough; ghcr.io also needed auth.
Two separate issues were compounding:
1. Unauthenticated requests shared a small anonymous quota
2. Two concurrent Watchtower instances were doubling all requests at 4 AM
Credentials are stored in `/home/jgitta/.docker/config.json` on the jellyfin host.
The update script mounts this file into each Watchtower container.
**Fix**:
Both `lscr.io` and `ghcr.io` are authenticated (lscr.io proxies to ghcr.io for the
actual registry API, so both need credentials).
Three sets of credentials were configured — both as the `jgitta` user (for Watchtower's
own registry digest checks) and as `root` (for the Docker daemon's actual image pulls):
Token: no-expiry `read:packages` GitHub PAT — see credentials.md.
To re-authenticate (e.g. after rotating token):
```bash
# As jgitta (Watchtower reads /home/jgitta/.docker/config.json via volume mount)
echo <token> | docker login lscr.io -u jgitta --password-stdin
echo <token> | docker login ghcr.io -u jgitta --password-stdin
# As root (Docker daemon uses /root/.docker/config.json for docker pull)
echo <token> | sudo docker login lscr.io -u jgitta --password-stdin
echo <token> | sudo docker login ghcr.io -u jgitta --password-stdin
ssh jellyfin
echo <new_token> | docker login lscr.io -u jgitta --password-stdin
echo <new_token> | docker login ghcr.io -u jgitta --password-stdin
```
Token used: the no-expiry `read:packages` GitHub PAT in credentials.md.
**If token ever needs rotating**: run the four `docker login` commands above on jellyfin
with the new token. No compose changes needed — both config files are read live.
---
## Notes
- Do NOT run `docker restart watchtower` manually while `watchtower-prowlarr` is running —
Watchtower's multi-instance detection will cause one to kill the other. Use
`docker restart watchtower watchtower-prowlarr` to restart both together, or let
Portainer manage redeployment.
- Portainer stack 35 is the authoritative runtime config. If it drifts from Gitea,
update via Portainer API with `prune: true` to remove orphaned services.
No script or compose changes needed — the config.json is read at runtime.