Table of Contents
- ytdlp-web-player
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Without the proxy, this release is open to everyone who knows the URL
- Routing: Ingress or Gateway API
- Who owns the host — and in Gateway mode nothing else will tell you
- Nothing was measured on a live Ingress of this chart, because there is none
- What the dependency costs
- Three load-bearing lines in configFile
- The provider is the only gate, and it is wider than it looks
- Repointing the proxy secret does not restart it
- A Ready pod does not mean a video will play
- The video cache is an eviction risk, not a disk
- /tmp is not mounted, /cache is — both measured
- yt-dlp does not update itself here
- The app's twenty switches are not chart values
- Documented deviation: the uid
- More than one replica shares nothing
- Not measured
- The oauth2-proxy subchart is not covered by this policy
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- What this chart deliberately does not do
- CI
- Renovate
- See also
ytdlp-web-player
Deploys ytdlp_web_player, a web front end that fetches and transcodes videos on demand, as a stateless Deployment. The video cache and the tool cache are emptyDirs — there is no PersistentVolume. The only chart in this repo with a dependency: an optional oauth2-proxy subchart, off by default, because the application ships no authentication at all.
| Image | docker.io/matszwe02/ytdlp_web_player |
| Versions | not listed on purpose — read the current pair off ytdlp-web-player/Chart.yaml, see Chart index |
| Workloads | Deployment (1 replica); plus the oauth2-proxy subchart's own when enabled |
| Storage | none — two emptyDirs (/data, /cache) |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.clusterIssuer required only while the Ingress is on, ingress.domain while either mode is. The subchart has no Gateway support, so with the proxy owning the host the publishing object is its Ingress in both modes — see Routing |
| Dependencies | oauth2-proxy 10.7.0 (https://oauth2-proxy.github.io/manifests), condition: oauth2-proxy.enabled, off by default |
| Secrets | 1 × OnePasswordItem, and only with the proxy enabled |
| Install-test | ci/ytdlp-web-player/ (test values only — no fixtures needed) |
Prerequisites
- cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. - Egress to the internet, including
github.com— yt-dlp fetches a JavaScript challenge-solver library from there. Without egress the pod is Ready and every playback fails. - Only with the proxy enabled: an OIDC provider with a client application and a group/policy binding, the 1Password operator, and one 1Password item — see the fields below.
- A default install needs no 1Password item at all.
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install player jlab/ytdlp-web-player \
--namespace ytdlp --create-namespace \
-f my-values.yaml
Minimal my-values.yaml — and this one is open to everyone who reaches the URL:
ingress:
domain: player.example.com
clusterIssuer: letsencrypt
With the proxy, which is what a reachable install should look like:
secrets:
oauth2ProxySecretRef: op://vault/ytdlp-oauth2-proxy
ingress:
enabled: false # the proxy owns the host from here on
oauth2-proxy:
enabled: true
ingress:
enabled: true
className: nginx
hosts: ["player.example.com"]
tls:
- secretName: tls-player-example-com
hosts: ["player.example.com"]
annotations:
cert-manager.io/cluster-issuer: letsencrypt
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
config:
configFile: |
email_domains = ["*"]
upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"]
reverse_proxy = true
session_cookie_minimal = true
provider = "oidc"
oidc_issuer_url = "https://sso.example.com/application/o/player/"
redirect_url = "https://player.example.com/oauth2/callback"
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — including the open-instance warning and the currently effective ephemeral-storage limit. Read it — it is the per-release version of this page.
What the 1Password item must contain
The chart renders a OnePasswordItem CR; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: the release goes through and the proxy pod then sits in CreateContainerConfigError, which looks like a broken chart.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.oauth2ProxySecretRef |
<release>-ytdlp-web-player-oauth2-proxy-secret |
client-id (the OIDC client id), client-secret, cookie-secret (16, 24 or 32 bytes, URL-safe base64) |
Generate the cookie secret as
openssl rand -base64 32 | tr '+/' '-_'
The tr is not cosmetic: oauth2-proxy decodes the value as URL-safe base64 first, so a standard-base64 value containing + or / fails to decode and the pod exits with cookie_secret must be 16, 24, or 32 bytes.
The CR is rendered only while the proxy is enabled and oauth2-proxy.config.existingSecret still names this chart's Secret. Point that value at a Secret you manage yourself and the chart creates nothing — your Secret then has to carry the same three keys.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over ytdlpWebPlayer.replicas. Does not reach the subchart — with the proxy on, set oauth2-proxy.replicaCount: 0 as well, or the proxy keeps running and answers with a gateway error. |
global |
object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open — and with a subchart present this is no longer hypothetical: whatever is set here reaches oauth2-proxy too. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.oauth2ProxySecretRef |
string | null |
Required while oauth2-proxy.enabled is true and oauth2-proxy.config.existingSecret still names this chart's Secret. Unused otherwise. |
ytdlpWebPlayer
| Key | Type | Default | Description |
|---|---|---|---|
ytdlpWebPlayer.replicas |
int | 1 |
Replica count; 0 is allowed. See More than one replica shares nothing. |
ytdlpWebPlayer.securityContext.pod |
object | non-root 20000:1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Merged onto the chart's pod hardening. See Documented deviation: the uid. The seccomp profile sits here so it covers every container including init containers. |
ytdlpWebPlayer.securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Merged onto the chart's container hardening. Everything the app writes goes to the two emptyDirs. |
ytdlpWebPlayer.livenessProbe |
object | GET /:http, period 10s, timeout 5s, 3 failures |
The app exposes no health endpoint; / is Flask's index route and touches neither ffmpeg nor the network. See A Ready pod does not mean a video will play. |
ytdlpWebPlayer.readinessProbe |
object | GET /:http, period 10s, timeout 5s, 3 failures |
|
ytdlpWebPlayer.startupProbe |
object | GET /:http, period 5s, 30 failures (= 150s) |
Gates liveness and readiness. The app opened its port in about two seconds in k3d; the budget only has to cover a slow first process start. |
ytdlpWebPlayer.env |
list | [] |
Extra environment variables appended to DATA_PATH, PORT, HOME and XDG_CACHE_HOME. Rendered through tpl; value, secretKeyRef, configMapKeyRef forms. This is where the application is configured — see The app's twenty switches are not chart values. |
ytdlpWebPlayer.ports |
list | one entry: containerPort: 5000, protocol: TCP, name: http |
Rendered verbatim as the container's port list. The entry named http is load-bearing: its number becomes PORT, the Service target and every probe target. A list without it fails the render with that message. |
ytdlpWebPlayer.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
ytdlpWebPlayer.resources.requests |
object | cpu: 0.5, memory: 512Mi, ephemeralStorage: 2Gi |
The ephemeral-storage number is the interesting one, not memory — 2Gi request yields a 6Gi limit. See The video cache is an eviction risk. |
ytdlpWebPlayer.resources.limits |
object | unset | An explicit limit always wins. No CPU limit on purpose, and this is the chart where it matters most: a CPU limit does not fail a request, it throttles ffmpeg, which arrives at the viewer as a stuttering stream with nothing in any log. |
storage
| Key | Type | Default | Description |
|---|---|---|---|
storage.dataSizeLimit |
string | null |
Optional sizeLimit on the /data emptyDir. Null means the only ceiling is the container's ephemeral-storage limit. Hitting either has the same outcome — the kubelet evicts the pod — so this buys a clearer message, not a graceful failure. |
storage.cacheSizeLimit |
string | null |
Same for the /cache emptyDir (HOME and XDG_CACHE_HOME). |
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Gates this chart's whole Ingress template. Mutually exclusive with httpRoute.enabled. It frees clusterIssuer; it frees domain only if httpRoute is off as well — this page used to say it frees both, which was true until the Gateway support landed and the route started reading the domain. What still holds is the reason: the app builds no absolute URLs and has no base-URL setting, so unlike searxng nothing else reads either value. |
ingress.domain |
string | null |
Required while either mode is enabled. Host — the Ingress's, and the HTTPRoute's while httpRoute.hostnames is empty. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. Unused and unnecessary in Gateway mode. |
ingress.className |
string | nginx |
Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
ingress.annotations |
object | {} |
Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode. Note which annotation this is not about: the 16k proxy-buffer-size this chart recommends belongs on the proxy's Ingress (oauth2-proxy.ingress.annotations), and the subchart renders an Ingress in either mode, so that one is unaffected. |
TLS is stored in tls-<release>-ytdlp-web-player-ingress. In Gateway mode the chart renders neither the secret nor a certificate.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 3 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4). Read Routing before switching it on — in the recommended configuration it changes nothing.
| Key | Type | Default | Description |
|---|---|---|---|
httpRoute.enabled |
bool | false |
Renders <release>-ytdlp-web-player-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values. |
httpRoute.parentRefs |
list | [] |
The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases. |
httpRoute.hostnames |
list | [] |
Empty means [ingress.domain], so the hostname has a single source of truth. Set it to publish under a different or additional name — which is also how the application is given a host of its own beside the proxy's. The host guard reads this list too, so a hostname set here that the proxy's Ingress also claims is caught rather than silently published twice. |
oauth2-proxy (subchart)
Everything under this key is passed to the oauth2-proxy chart, not read by this one — with three exceptions: enabled (the dependency condition) and ingress.enabled / ingress.hosts (the two-Ingress guard). The block stays fully open in values.schema.json: the chart owns neither the keys nor their meaning, and a closed copy would reject whatever upstream adds next release. Anything not listed here stays at the subchart's default.
Reaching the key from a template needs index .Values "oauth2-proxy" — .Values.oauth2-proxy parses as a subtraction. The condition: in Chart.yaml splits on dots only and needs no escaping.
| Key | Type | Default | Description |
|---|---|---|---|
oauth2-proxy.enabled |
bool | false |
The dependency condition. Off means nothing of the subchart renders and no proxy image is pulled — and that the instance has no authentication. |
oauth2-proxy.ingress.enabled |
bool | false |
With the proxy on, this is the Ingress that should be on and ingress.enabled the one that should be off. |
oauth2-proxy.ingress.className |
string | nginx |
Ingress class of the proxy's Ingress. |
oauth2-proxy.ingress.hosts |
list | unset (commented example) | The single host the app would otherwise carry. Read by this chart's guard. |
oauth2-proxy.ingress.tls |
list | unset (commented example) | The proxy owns its own TLS secret; this chart's ingress.clusterIssuer does not reach it. |
oauth2-proxy.ingress.annotations |
object | {} |
Where the cert-manager annotation and nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" belong. |
oauth2-proxy.config.existingSecret |
string | '{{ .Release.Name }}-ytdlp-web-player-oauth2-proxy-secret' |
The Secret with the three credentials. While it names this value, the chart renders the OnePasswordItem for it. The subchart runs the string through tpl, so .Release.Name resolves; .Chart.Name would not — inside the subchart that is oauth2-proxy, which is why the parent name is written out literally. |
oauth2-proxy.config.configFile |
string | email_domains = ["*"], upstreams = ["http://{{ .Release.Name }}-ytdlp-web-player-service:80"], reverse_proxy = true, session_cookie_minimal = true |
The proxy's whole configuration. Add your provider settings here. See Three load-bearing lines in configFile. |
oauth2-proxy.alphaConfig.enabled |
bool | false |
Leave this off. With it on the subchart generates a configuration without upstreams and ignores config.configFile silently — the login succeeds and the app answers 404. It is off in the subchart's defaults too; it is spelled out here because switching it on looks like an upgrade. |
networkPolicy
Inbound 5000 only. Outbound DNS plus TCP 80 and 443 to any destination — fetching media is the application.
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.probeSources |
list | [] |
CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
Operating notes
Without the proxy, this release is open to everyone who knows the URL
The application has no authentication of any kind and the chart adds none while oauth2-proxy.enabled is false. An anonymous visitor does not just read a page: they can make this pod fetch and transcode arbitrary videos from the internet — your egress, your CPU, your IP address in the logs of whatever site is being pulled from, and your ephemeral storage. Assume it is found: hosts get scanned, and certificate transparency logs publish every name cert-manager requests, so "nobody knows the URL" has a half-life of hours. NOTES.txt prints this warning whenever an Ingress is on and the proxy is off.
Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.
Read this before switching httpRoute on, because in the recommended configuration it does nothing. The oauth2-proxy subchart has no Gateway-API support and this chart cannot give it one — upstream's chart is consumed as it comes (what the dependency costs), and it renders an Ingress or nothing. So with the proxy in the data path and owning the host, the object that publishes the release is the subchart's Ingress in both of this chart's modes. Turning httpRoute.enabled on there changes nothing about how the host is served.
This chart's own route is for the other two cases:
- the proxy switched off entirely (
oauth2-proxy.enabled: false), so the chart publishes the application itself; - the application on a host of its own beside the proxy's host — allowed, and meaning exactly what it says: an unauthenticated way past the proxy.
| Rule | Why |
|---|---|
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths |
two data paths onto one Service under one hostname is a conflict at DNS level already. ingress.enabled defaults to true here, so turning Gateway mode on means setting ingress.enabled: false in the same values file. A migration runs over a second hostname |
httpRoute.parentRefs must not be empty — the render aborts |
a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer |
Who owns the host — and in Gateway mode nothing else will tell you
The proxy sits in the data path, so with oauth2-proxy.enabled: true the Ingress belongs to the proxy and this chart's own routing object should be off; the app is then reachable only through its Service.
Leaving both on for the same host is refused by the chart, and the reason to refuse it is much stronger in Gateway mode:
- In Ingress mode ignoring it would fail loudly anyway: ingress-nginx's admission webhook rejects the second Ingress, which leaves a Fleet bundle in
ErrApplieduntilforceSyncGenerationis bumped. Annoying, but reported. - In Gateway mode the webhook never fires. It compares Ingress objects with Ingress objects, and an
HTTPRouteis a different kind — so nothing rejects anything, both objects stay valid, and one hostname is served over two data paths that are not equivalent: the proxy's Ingress goes through authentication, this chart'sHTTPRoutegoes straight to the application. Which one a client reaches depends on what DNS resolves to. That is an authentication bypass that comes and goes with a DNS record, and no Kubernetes object reports it.
The guard is the helper ytdlp-web-player.published (ingress.enabled OR httpRoute.enabled), and it exists in that form because of exactly this: before the Gateway support it asked ingress.enabled, which is false by construction in Gateway mode — so the guard would have gone silent precisely where it matters most. It reads httpRoute.hostnames as well as ingress.domain, and the Gateway-mode abort carries its own message naming the bypass. Called from the Deployment, the one template that always renders, so it cannot be skipped by scaling to zero or by helm template -s.
Leaving this chart's routing object on for a different host stays allowed and still means an unauthenticated way past the proxy; NOTES.txt warns about it in both modes. And in Gateway mode that is the safe half — it is the same-host case that is dangerous, which is why the chart refuses it rather than warning.
Nothing was measured on a live Ingress of this chart, because there is none
Checked 2026-10-12: the release runs in namespace jlab-player with both Deployments Ready (jlab-player-ytdlp-web-player-service and jlab-player-oauth2-proxy), and not one of the cluster's 23 Ingress objects is in that namespace — neither this chart's nor the subchart's.
What publishes it instead could not be determined. The workspace's read-only identity may not list httproutes or secrets, so neither a hand-written HTTPRoute beside the release nor the release's own Helm values are readable from here. Recorded as the open question it is rather than guessed at — and worth resolving before anybody plans a mode switch for this host, since the answer decides whether the switch is a no-op, a change of publisher, or a bypass.
What the dependency costs
A dependency here is admissible, not free (style guide §14). The bar it clears: the proxy would otherwise be a second Helm release that only exists to serve this one, upstream is the right owner of it, and it is optional and off by default. The price, all of it:
- The subchart is invisible to everything downstream. Its version is inside our packaged
.tgz, so anything watching Helm release versions — Fleet'sfleet.yamlincluded — sees only our chart version. A new oauth2-proxy release, a security fix included, reaches a cluster only when a newytdlp-web-playerversion is published. Nothing reports the gap. Renovate's built-inhelmv3manager opens the PR (nocustomManagersentry needed) andci/renovate-bump-chart.shbumps the chart version that ships it — since Gitea #128 at the subchart's own level, so an oauth2-proxy minor makes a chart minor here; that chain is the entire safety net. The subchart version is independent ofappVersion, which is exactly why the level is taken from Renovate's update type rather than from comparingappVersion. - Upstream's chart is consumed as it comes. We do not patch its templates, do not re-harden its workload and do not re-implement its values. Its resource limits, probes and security context follow its defaults, not this repo's; set them under
oauth2-proxy.resources/.securityContext/.livenessProbeif you want this repo's shape. - This repo's mechanisms stop at the subchart boundary.
checksum/config, the 1Password auto-restart annotation andscaleDownreach our templates only. - Every pipeline needs a network fetch first.
helm lint,package,templateandinstallall exit 1 whilecharts/is unfilled, soci/helm-dependencies.shruns before each — including the resource-quantity render test that covers every chart on every PR. A third-party Helm repository's bad day can therefore redden a PR that touched nothing related.charts/*.tgzandChart.lockare not committed, which is why the pipelines runhelm dependency updaterather thanbuild. - The subchart itself has a conditional dependency on
redis-ha(off by default), which is whyhelm dependency updatehere pulls two repositories.
Three load-bearing lines in configFile
Do not remove these while trimming the default:
| Line | What goes wrong without it |
|---|---|
reverse_proxy = true |
The proxy considers every request plain http and builds its redirect back to http:// — behind a TLS-terminating Ingress that is a loop the moment a login completes. |
session_cookie_minimal = true |
id_token and access_token go into the session cookie; a user with a longer group list pushes it past the browsers' 4 kB limit, the browser drops it, and the request comes back as HTTP 502 after a successful login, with [AuthSuccess] in the proxy's log. Incompatible with pass_access_token, set_authorization_header and pass_authorization_header — switch any of those on together with it and the proxy refuses to start. This app reads no identity headers at all. |
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k" (on the proxy's Ingress) |
The other half of the same 4 kB trap: the response carrying the session cookie does not fit ingress-nginx's default 4k proxy buffer, and the browser gets a 502 after a successful login. |
upstreams is written in config.configFile rather than in the structured config.upstreams for one reason: the subchart emits config.upstreams with toJson and without tpl, so a {{ .Release.Name }} there ends up in the manifest verbatim. config.configFile is passed through tpl. A wrong upstream is not a render error — it is a 404 after a successful login, in production.
The provider is the only gate, and it is wider than it looks
The default email_domains = ["*"] admits every identity the provider hands over. Which people that is, is decided entirely by the group or policy binding on the provider application, and this app has no second login behind the proxy. A forgotten or too-wide binding is not "one more user", it is every account the provider knows. Check that binding when you set this up.
Repointing the proxy secret does not restart it
The OnePasswordItem is this chart's, but it is consumed by the subchart's pod template, which this chart cannot annotate — and Helm never sees a Secret's content anyway (style guide §9):
kubectl -n <namespace> rollout restart deployment/<release>-oauth2-proxy
The app's own Deployment carries no checksum/config, and nothing is missing: it consumes no ConfigMap and no Secret — it is configured purely through environment variables, which are part of the pod template already, so a changed value rolls the pods by itself.
A Ready pod does not mean a video will play
All three probes request /. That is the only cheap path the app has, and it keeps a slow upstream site from restarting the pod. The price is that egress problems are invisible to Kubernetes: with DNS broken or outbound traffic blocked, / still answers 200, the rollout goes green, and every playback fails. A failing fetch shows up as a yt-dlp error line in the container log, never as a probe failure:
kubectl -n <namespace> logs deployment/<release>-ytdlp-web-player-deployment
The video cache is an eviction risk, not a disk
DATA_PATH is /data, backed by an emptyDir. Nothing survives a restart, a node drain, an eviction or a helm upgrade; every cached video is fetched again on demand. That is the intended shape — the app expires files itself after MAX_VIDEO_AGE — but do not treat it as storage.
An emptyDir counts against the container's ephemeral-storage limit. Going over it is not an application error: the kubelet evicts the whole pod, the reason sits on the pod rather than in any log, and everything in flight goes with it. Raising the limit is the wrong first move; the knobs that decide how much lands there are in ytdlpWebPlayer.env:
| Variable | Upstream default | What it decides |
|---|---|---|
MAX_VIDEO_DURATION |
36000 |
Ten hours — one enormous single file |
MAX_VIDEO_AGE |
3600 |
Seconds a file survives after the last access |
MAX_PROCESSES |
5 |
How many concurrent ffmpeg/yt-dlp runs can arrive at once |
/tmp is not mounted, /cache is — both measured
Two runs of the image at uid 20000 with a read-only root filesystem, driven through metadata extraction, HLS transcoding, sprite generation and audio extraction:
/tmpis never written to. Not once. The usual "read-only rootfs, so mount an emptyDir on/tmp" is dead weight for this image and deliberately absent.$HOME/.cacheis written to. Without it the log carries tenOSError: [Errno 30] Read-only file system: '/.cache'lines. Both bundled tools use it: deno for its module cache, yt-dlp for the JavaScript challenge-solver library it fetches from github.com. The failure is a warning, not an error — and what it costs is silent: that library is re-downloaded on every single extraction, which turns a github.com outage, or an egress rule that does not expect it, into broken playback with a warning nobody read.
HOME and XDG_CACHE_HOME are set to /cache rather than relying on a mount at /.cache, so the path does not depend on which uid the pod ends up running as.
yt-dlp does not update itself here
On start and periodically the app runs pip install --upgrade yt-dlp into its own site-packages. With readOnlyRootFilesystem: true that write cannot happen; the app catches the failure and carries on with the version baked into the image (verified — it starts, serves and transcodes normally). That is the right trade in a cluster, but know the consequence: when a video site changes its extraction and playback breaks, waiting does not fix it. The fix arrives as a new app image, i.e. a new appVersion of this chart. There is no in-place update path.
The app's twenty switches are not chart values
The upstream offers around twenty environment switches (APP_TITLE, THEME_COLOR, GENERATE_SPRITE_BELOW, AMOLED_BG, MAX_VIDEO_AGE, MAX_VIDEO_DURATION, DEFAULT_QUALITY, MAX_QUALITY, AUTOPLAY, MIN_LIVE_BUFFER, ALWAYS_TRANSCODE, DISABLE_TRANSCODING, MAX_PROCESSES, AUTOSKIP_SB_SEGMENTS, COOKIES_ONLY_ON_FAILURE, PLAYLIST_SUPPORT, AUTO_BG_PLAYBACK, AUDIO_VISUALIZER, PROXY as of v1.2.0). The chart mirrors none of them: a chart that rebuilds every option is a lie one upstream release later, and every one would have to be kept in values.schema.json too. src/example.env upstream is the authoritative list.
Do not set DATA_PATH, PORT, HOME or XDG_CACHE_HOME in ytdlpWebPlayer.env. The entries are appended, and Kubernetes applies the last entry of the list — a duplicate silently wins and points the app away from its writable volume, off the port the Service and all three probes use, or its caches back onto the read-only root filesystem.
Documented deviation: the uid
The upstream image carries no USER instruction and runs as root by default (python:3.13-alpine, apk add ffmpeg deno, no USER). That is a default, not a requirement: measured, the container runs as an arbitrary uid with readOnlyRootFilesystem: true and the full hardening baseline, serving the UI and completing a real transcode. The chart therefore pins a non-root uid rather than following the image, and the deviation is about the number only — no part of the hardening baseline is relaxed. 20000 is arbitrary on purpose: nothing in the image belongs to it, /app is world-readable and read-only, and no PersistentVolume ties data to the number.
More than one replica shares nothing
Each pod keeps its own /data emptyDir, so a video fetched by one is invisible to the other, and without session affinity the HLS segment requests of a single playback are spread across both — which looks like a stuttering or failing stream rather than an error. Pin clients to a pod at the ingress, or stay at one replica.
Not measured
HLS playback through the proxy. Rendering and wiring are verified; a long stream through proxy and ingress is not. Three places to look first if playback stalls where a direct ingress works: proxy-buffering in ingress-nginx, its 60-second proxy-read/send timeouts, and the proxy's own upstream timeout. Browser extension and PWA are unverified too — an expired session answers with a redirect to the provider, not a 401, and a fetch() sees only an opaque redirect.
The oauth2-proxy subchart is not covered by this policy
This is the sharpest case of a general limit, so it is worth stating rather than inferring: the policy selects this chart's workload by its app label. The oauth2-proxy subchart renders its own Deployment with its own labels and ships no policy of its own.
So with the proxy enabled, the pod that actually faces the Ingress is unrestricted in both directions. Nothing in this chart can change that — an upstream chart is consumed as it comes — so do not read "this chart has a NetworkPolicy" as "this release is covered".
The probed port is open inside the cluster by default
networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.
It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.
Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- Nothing to back up. No PVC, no database, no persisted state; both volumes are caches refilled from the network. A rollback carries no data risk.
- Raising the subchart version is the only path a proxy update has. Keep that line moving — see What the dependency costs.
- An app-version bump is the only way a yt-dlp fix reaches the cluster.
What this chart deliberately does not do
No cookies.txt: videos that require a login on the source site do not work, and there is no value to make them work. No PersistentVolume. No mirror of the upstream's environment switches as chart values.
CI
ci/ytdlp-web-player/ holds only test-values.yaml — the app has no secrets, no PVC and no external dependency to fake, so there is no fixtures.yaml.
The proxy stays off in CI, and that is not an oversight. Two independent reasons, either one sufficient: enabling it would test somebody else's chart (what this repo owns is the wiring, and wiring is a render-time property checked by rendering, not by installing); and it could not pass anyway, because oauth2-proxy fetches its provider's OIDC discovery document at startup and never becomes Ready without one. There is no provider in the CI cluster, and standing one up is a test of the fixture, not of the chart.
Because the chart declares a dependency, ci/helm-dependencies.sh runs before lint, package, render and install — see CI/CD.
The Gateway mode is held at render level, by ci/httproute/ytdlp-web-player.yaml plus ci/run-httproute-match-test.sh, which runs over every chart on every PR (CI/CD). The overlay switches this chart into Gateway mode; 2 assertions — parentRefs not empty, and the backendRefs entry resolving to the application Service of the same render by port number, because backendRefs takes a PortNumber and cannot name the port http the way the Ingress does. That number also has to agree with ytdlp-web-player.appServiceAddress, which is what the proxy gets as its upstream — three places, and the overlay holds two of them together.
The subchart stays disabled in the overlay too, and deliberately rather than lazily: it has no Gateway-API support, so switching it on would add a second object this test does not look at, while the thing worth exercising in Gateway mode is this chart's own route. The interesting interaction — one hostname claimed by the proxy's Ingress and by this chart's HTTPRoute, which the admission webhook cannot catch because the kinds differ — is a render-failure path: the chart refuses it. This test only runs renders it expects to succeed, so that guard was exercised by hand instead, in both modes, including the collision reached through httpRoute.hostnames rather than through the ingress.domain fallback, and with only the Deployment rendered.
What that does not cover: that the hostname answers (no Gateway controller runs in CI, so no route reaches Accepted: True); that authentication is in front of the application — with the proxy off, that route is the unauthenticated path, which is correct for a fixture and is exactly what NOTES.txt warns about for a real installation; and anything about how this release is published in the real cluster, since it has no live Ingress to compare against. Nothing in CI reads NOTES.txt either: helm template does not emit it and no script under ci/ reads it, so its text carries review-only assurance.
Renovate
Two blocks watch this chart. The built-in helmv3 manager reads the dependencies: block and opens the subchart PR (labelled subchart); no customManagers entry is needed or would help, because the repository URL and the version sit in one file. A customManagers block tracks appVersion against docker.io/matszwe02/ytdlp_web_player with an anchored versioning regex: the registry carries four versioned tags and the two floating ones, latest and stable, both of which move — stable was republished at the same minute as v1.2.0. The anchors requiring a v prefix and three numeric parts are what keeps them out of the value that selects the image. The v itself stays outside every capture group: it carries no ordering, and a capture group is a claim about how a value is compared. See Renovate.
See also
- Chart style guide — the rules this chart implements, and §14 on dependencies
- Versioning — what a bump of
1.0.0+upv1.2.0means - Renovate
- Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline