Table of Contents
- bentopdf
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- A green pod only proves delivery
- Two headers decide whether Office conversion works
- Disabling tools is the one runtime lever
- Three environment variables that kill the pod
- A public instance gets indexed
- "Runs in the browser" is not "no outbound connections"
- What this chart cannot do
- Non-root without sysctls
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
bentopdf
Deploys BentoPDF, a browser-side PDF toolbox, as a stateless Deployment behind nginx, published through an Ingress or a Gateway-API HTTPRoute. The pod serves static files and nothing else — every one of the roughly 130 tools runs in the visitor's browser.
| Image | ghcr.io/alam00000/bentopdf-simple |
| Versions | not listed on purpose — read the current pair off bentopdf/Chart.yaml, see Chart index |
| Workloads | Deployment (1 replica) |
| Storage | none — two emptyDirs for nginx, no PVC |
| Routing | two mutually exclusive modes. Ingress (default): ingress.domain + ingress.clusterIssuer required while ingress.enabled. Gateway API: httpRoute.enabled with httpRoute.parentRefs — see Routing: Ingress or Gateway API |
| External dependencies | none in the cluster; the browser fetches some WASM modules from the internet |
| Secrets | none — no OnePasswordItem |
| Install-test | ci/bentopdf/ (test values only, no fixtures) |
The -simple image variant is deliberate: it is built from the same Dockerfile as ghcr.io/alam00000/bentopdf with the SIMPLE_MODE build argument, which strips the marketing surface of the commercial site.
Prerequisites
- cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. Both are only needed whileingress.enabledistrue— unlike most charts here, nothing outside the Ingress template reads them. - In Gateway mode (
httpRoute.enabled: true) instead: the Gateway-API CRDs (standard channel — the chart cannot detect them, so without them the install fails withno matches for kind HTTPRoute), a Gateway whose listener admits routes from this namespace (allowedRoutes) and is named inhttpRoute.parentRefs, and the certificate on that Gateway's listener. The chart renders none of the three;ingress.clusterIssueris unused here. - A proxy that passes the two cross-origin headers through, in either mode. Without them the Office conversions break; see Two headers decide whether Office conversion works.
- No database, no cache, no 1Password item, no volume. This chart has no secrets 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 bentopdf jlab/bentopdf \
--namespace bentopdf --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
ingress:
domain: pdf.example.com
clusterIssuer: letsencrypt
The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
ingress:
enabled: false
domain: <the public hostname> # the HTTPRoute's hostname, see below
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. See Routing: Ingress or Gateway API.
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over bentopdf.replicas. Service and Ingress stay, so the certificate is not re-issued. |
global |
object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it. |
bentopdf
| Key | Type | Default | Description |
|---|---|---|---|
bentopdf.replicas |
int | 1 |
Replica count; 0 is allowed. The app is stateless, so raising it is safe. |
bentopdf.config |
object / null | {} |
Body of /config.json, passed through verbatim as JSON. Today upstream reads disabledTools and editorDisabledCategories; the chart does not interpret either, so a key upstream adds tomorrow is a values change here, not a chart change. See Disabling tools. A scalar is rejected with an error naming the values path. |
bentopdf.securityContext.pod |
object | non-root 101, runAsGroup: 101, seccompProfile: RuntimeDefault |
Passed into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
bentopdf.securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Passed into the container spec. readOnlyRootFilesystem: false is the deliberate escape hatch for the three image env switches — see Three environment variables that kill the pod. |
bentopdf.livenessProbe |
object | GET /:http, period 10s, timeout 5s, 3 failures |
The image has no health endpoint; / is the only path answering 200 without a special rule. |
bentopdf.readinessProbe |
object | GET /:http, period 10s, timeout 5s, 3 failures |
|
bentopdf.startupProbe |
object | GET /:http, period 5s, timeout 5s, 30 failures (= 150s) |
Gates liveness and readiness. |
bentopdf.env |
list | [] |
Extra environment variables. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. The served bundle reads none of them — they reach only nginx and its entrypoint scripts. See Environment variables. |
bentopdf.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
bentopdf.resources.requests |
object | cpu: 0.1, memory: 128Mi, ephemeralStorage: 16Mi |
Measured in k3d at 1m CPU / 2Mi working set, idle and while serving the 48 MB LibreOffice WASM asset. The requests sit far above that on purpose: gzip_comp_level 9 compresses at request time for anything without a pre-compressed twin, and that cost scales with concurrency, which a one-pod run cannot show. |
bentopdf.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply", not "no probe" or "unhardened container".
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled. Switching it off frees clusterIssuer outright — but domain only while both routing modes are off, because the HTTPRoute reads it as its hostname. No application value reads either (upstream's BASE_URL counterpart is a build-time argument and empty in the published image). |
ingress.domain |
string | null |
Required while ingress.enabled is true. Host of the Ingress and of its TLS entry. |
ingress.clusterIssuer |
string | null |
Required while ingress.enabled is true. cert-manager ClusterIssuer. |
ingress.className |
string | nginx |
Ingress class. |
ingress.annotations |
object | {} |
Merged with the cert-manager annotation the chart always sets. The place for a X-Robots-Tag header — see A public instance gets indexed. No counterpart in Gateway mode: the Gateway API has no annotation channel, so everything set here is gone in that mode. |
TLS is stored in tls-<release>-bentopdf-ingress.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 1 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).
| Key | Type | Default | Description |
|---|---|---|---|
httpRoute.enabled |
bool | false |
Renders <release>-bentopdf-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 explicitly only to publish under a different or an additional name — which is how a migration off the Ingress runs. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-bentopdf-service on port 80.
networkPolicy
Inbound 8080 only. Outbound DNS and nothing else — BentoPDF processes documents in the browser and the chart has no secrets and no volumes, so the pod needs nothing more.
| 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
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.
| Rule | Why it is a hard failure and not a default |
|---|---|
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: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel. |
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. |
| the mode is a value, never auto-detected | .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config. ingress.clusterIssuer is unused and unnecessary in this mode.
What this mode costs this chart, and it is not nothing. ingress.annotations is BentoPDF's only lever for two things it cannot do itself, and in Gateway mode the chart has no channel for either: the X-Robots-Tag header that keeps a public instance out of search indexes, and the repair for a proxy that overrides the .mjs MIME type. The NOTES.txt of a Gateway-mode release says so, because "set it through ingress.annotations" is advice that silently stops applying. The two cross-origin headers are a different case — the image sends them itself, so they still arrive in both modes unless something strips them; that is a thing to check (the curl in NOTES.txt) rather than to configure.
Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:
kubectl -n <namespace> get httproute <release>-bentopdf-httproute -o yaml
Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.
A green pod only proves delivery
All three probes hit /, and what they prove is that nginx is handing out index.html. They say nothing about the tools: BentoPDF computes entirely in the visitor's browser, and not one of its tools touches this pod. A healthy release is a delivery receipt, not a statement about whether PDF processing works.
Two headers decide whether Office conversion works
The image sends Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless. Together they make the page crossOriginIsolated, which is the precondition for SharedArrayBuffer — and without SharedArrayBuffer the LibreOffice-backed conversions (Word, Excel, PowerPoint) do not work. An ingress controller is a reverse proxy and therefore a place where those headers get lost or overwritten. The chart cannot enforce them; it only names the failure picture, because that picture points at the application while the cause sits in the ingress: Office conversion fails, everything else keeps working. Check through the ingress, not at the pod:
curl -sI https://pdf.example.com/ | grep -i cross-origin
The second trap of the same kind: some controllers override the MIME type for .mjs files, which leaves the app stuck on a module loading error. The image itself maps mjs to application/javascript.
Disabling tools is the one runtime lever
bentopdf.config is rendered into a ConfigMap and subPath-mounted at /usr/share/nginx/html/config.json. Four things are worth knowing before debugging it:
- The ConfigMap is always rendered, even for an empty config, which serializes to
{}— byte-identical to theconfig.jsonthe image ships. A file that sometimes exists and sometimes does not would mean the path through checksum, mount andsubPathis normally never exercised and first fails when someone actually uses it. - The mount replaces the image's file. Whatever stands under
bentopdf.configis the complete file, not a patch on top of it. disabledToolstakes tool IDs, and a tool id is its page URL without the.html—merge-pdf,encrypt-pdf,word-to-pdf. The full list is deliberately not copied into the chart, because a copy is wrong from the next upstream release on; it lives indocs/self-hosting/docker.mdupstream. A misspelled id disables nothing and reports nothing — there is no validation anywhere, so an unknown id is simply an id that matches no tool.editorDisabledCategoriesis hierarchical. Disabling a parent disables all its children, soannotationalso takes outannotation-highlight,annotation-ink,annotation-stampand the rest. That is where more disappears than expected.
The pod template carries checksum/config, so a changed config rolls the pods (style guide §9). That is mandatory rather than nice to have: subPath mounts are never refreshed by the kubelet.
What the chart cannot control is the browser. The image's nginx serves .json with Cache-Control: max-age=604800, so a visitor who already fetched the old file can keep using it for up to seven days. Where a change has to be visible immediately, reload hard, or override the caching for that one path in the ingress. To see what is actually being served:
kubectl -n <namespace> exec deploy/<release>-bentopdf-deployment -- wget -qO- localhost:8080/config.json
Three environment variables that kill the pod
PORT, DISABLE_IPV6 and ROBOTS_NOINDEX are all implemented as sed -i against files in the image — the first two against /etc/nginx/nginx.conf, the third against every .html file in the web root. The scripts run under set -e inside /docker-entrypoint.d/, and the base image's entrypoint aborts when one fails. With readOnlyRootFilesystem: true each of them produces sed: can't create temp file ...: Read-only file system and exit 1, which in Kubernetes is a CrashLoopBackOff without a single line from nginx. Measured against 2.8.7.
None of the three is offered as a first-class value, because a value that looks like it works and instead kills the pod is worse than no value. Whoever really needs one sets it under bentopdf.env and bentopdf.securityContext.container.readOnlyRootFilesystem: false — a deliberate, visible decision. The container port stays 8080 either way; the Service maps onto it by name.
A public instance gets indexed
BentoPDF has no login and this chart adds none. That is defensible here — the server takes nothing in, stores nothing and has no session — but a publicly reachable instance gets indexed, and its pages carry a canonical link to the vendor's site (SITE_URL is a build-time argument defaulting to the vendor domain). ROBOTS_NOINDEX cannot run with a read-only root filesystem, so the ingress is the only lever left, for example:
ingress:
annotations:
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header X-Robots-Tag "noindex, nofollow";
Documented as an example, not set as a default. Mind that ingress-nginx disables snippet annotations by default since v1.9 (allow-snippet-annotations: false); where that stands, the controller-wide add-headers ConfigMap is the alternative.
"Runs in the browser" is not "no outbound connections"
Several tools fetch their WASM modules from cdn.jsdelivr.net at runtime (pymupdf-wasm, gs-wasm, coherentpdf), and the navbar fetches a star count from api.github.com on page load. The image's own CSP allows exactly these sources. The PDFs themselves never leave the device — what is loaded is program code, not documents.
What this chart cannot do
Almost everything else about BentoPDF is a build-time argument and therefore out of reach for any chart: BASE_URL (running under a sub-path), SIMPLE_MODE, VITE_BRAND_NAME / VITE_BRAND_LOGO / VITE_FOOTER_TEXT, VITE_DEFAULT_LANGUAGE, SITE_URL, DISABLE_GITHUB_STARS and all the WASM/OCR source URLs. Changing any of them needs a self-built image; this repository publishes charts, not images.
Non-root without sysctls
The image is based on quay.io/nginx/nginx-unprivileged: nginx runs as uid/gid 101 and listens on 8080, so the workload is fully non-root with no sysctl tweaks (same situation as cyberchef). Two emptyDirs keep the root filesystem read-only: /etc/nginx/tmp (the pid file) and /var/cache/nginx (not optional — nginx creates its temp directories at startup and the image ships the directory without them, so the master process aborts with mkdir() "/var/cache/nginx/client_temp" failed). /var/run is deliberately not mounted, unlike in the upstream chart. The container port name http is load-bearing: Service and all three probes select it by name.
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 and nothing to migrate. No database, no volumes, no uploads — documents never leave the visitor's device, so app-version upgrades and helm rollback carry no data risk. Renovate tracks ghcr.io/alam00000/bentopdf-simple and pins the suffix-free tag family (2.8.8, not v2.8.8 or v2.8.8-amd64), because that is the form appVersion can carry unchanged — see Renovate.
CI
ci/bentopdf/test-values.yaml holds the two ingress values (bentopdf.ci.local, issuer ci) and — deliberately — a non-empty bentopdf.config. An empty config renders {}, which is byte-identical to the file the image already ships, so the install-test would pass without ever proving that the ConfigMap, the subPath mount into the web root and the checksum/config annotation work. There is no fixtures.yaml: nothing to stand up, no secrets (cyberchef is the precedent). The install-test runs on every PR that touches the chart — see CI/CD.
The install-test stays in Ingress mode; the route is held at render level. The install-test installs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. cyberchef is the chart that pays that price and installs in Gateway mode against the real Gateway-API CRDs (ci/gateway-api-crds.sh, pinned, applied --server-side), which is where a malformed parentRefs or backendRefs is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead ci/httproute/bentopdf.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI. See CI/CD.
See also
- Chart style guide — the rules this chart implements
- Versioning — what a bump of
1.0.2+up2.8.8means - Renovate — how the app version is tracked
- 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