Table of Contents
- gotenberg
- Prerequisites
- Installation
- Configurable values
- Operating notes
- No authentication, and this chart cannot add any
- Requests time out after 30 seconds
- Why the limits are explicit, and what each failure looks like
- It shows up as "helper not reached" in the quantity-render test
- Egress is DNS-only on purpose, and it costs the remote-asset half of HTML conversion
- 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
gotenberg
Deploys Gotenberg, a stateless HTTP API that converts HTML, Markdown and office documents to PDF via Chromium and LibreOffice. Deployment and Service only — no Ingress, and none should be added. In this cluster it is a backend for paperless-ngx, which points PAPERLESS_TIKA_GOTENBERG_ENDPOINT at its Service.
| Image | gotenberg/gotenberg |
| Versions | not listed on purpose — read the current pair off gotenberg/Chart.yaml, see Chart index |
| Workloads | Deployment (1 replica) |
| Storage | none — emptyDirs on /tmp and /home/gotenberg |
| Ingress | no — ClusterIP Service only |
| External dependencies | none |
| Secrets | none |
| Install-test | ci/gotenberg/ (empty test values, no fixtures) |
Prerequisites
None beyond a cluster. No vault item, no database, no volume, no ingress controller. The chart is installable with zero values.
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 gotenberg jlab/gotenberg \
--namespace documents --create-namespace
helm install prints the chart's NOTES.txt, which renders the same information — including the limits this release actually got — against the values actually set. Read it — it is the per-release version of this page.
How it is reached, and by whom
There is no Ingress and no certificate. The API is cluster-internal, on a ClusterIP Service:
http://<release>-gotenberg-service.<namespace>.svc.cluster.local
Mind the port. The Service listens on 80 and forwards to the container's 3000, so consumers are configured without a port — e.g. paperless-ngx:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://<release>-gotenberg-service.<namespace>.svc.cluster.local
From your own machine:
kubectl -n <namespace> port-forward svc/<release>-gotenberg-service 3000:80
curl http://localhost:3000/health
/health reports Chromium and LibreOffice separately — the quickest way to tell a broken converter from a broken caller.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over gotenberg.replicas. The Service keeps its ClusterIP but loses its endpoints, so callers fail with "connection refused" rather than a timeout. |
global |
object | unset | Helm's conventional cross-chart values. Unread by this chart; kept open in the schema so an umbrella install cannot break on it. |
gotenberg
| Key | Type | Default | Description |
|---|---|---|---|
gotenberg.replicas |
int | 1 |
Replica count; 0 is allowed. The app is stateless, so raising it is safe and is the way to serve concurrent conversions. |
gotenberg.securityContext.pod |
object | non-root 1001:1001, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. The profile was verified against both engines — a browser sandbox is the classic case where a syscall filter bites; Chromium HTML→PDF and LibreOffice document→PDF both still return real PDFs and /health reports both as up. |
gotenberg.securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Passed verbatim into the container spec. Chromium and LibreOffice need writable /tmp and /home/gotenberg, which the chart mounts as emptyDirs. |
gotenberg.livenessProbe |
object | GET /health on port gotenberg-port, period 10s, timeout 5s, 3 failures |
Gotenberg's documented health route; no external dependency hangs on it. |
gotenberg.readinessProbe |
object | GET /health on port gotenberg-port, period 10s, timeout 5s, 3 failures |
|
gotenberg.startupProbe |
object | GET /health on port gotenberg-port, period 5s, timeout 5s, 30 failures (= 150s) |
Gates liveness and readiness. |
gotenberg.env |
list | [] |
Extra environment variables. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables. Note that Gotenberg's own knobs are flags, not variables — see below. |
gotenberg.resources.limitFactor |
number | 3 |
Only nominally in effect here: both computable limits are already set explicitly, so nothing is multiplied. |
gotenberg.resources.requests |
object | cpu: 0.1, memory: 512Mi, ephemeralStorage: 10Mi |
Deliberately small: Gotenberg idles at a few dozen MB, so reserving conversion-sized capacity on the node would waste it around the clock. Conversions are short bursts and the limits cover them. |
gotenberg.resources.limits |
object | memory: 3Gi, ephemeralStorage: 256Mi |
Set explicitly, not computed — the computed pair (1536Mi / 30Mi) was too small for real documents. No CPU limit. |
An erased block (gotenberg.securityContext: with no children, a null probe, a null resources) falls back to the chart's own defaults instead of disappearing — style guide §12.2.
networkPolicy
Inbound 3000 only. Outbound DNS and nothing else by default — a deliberate, costly trade rather than an oversight, and networkPolicy.egress.remoteAssets is the named switch that widens it when a release needs remote images; see the operating note for both halves. There is no ingressController value, because this chart renders no Ingress.
| 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.egress.remoteAssets |
bool | false |
Lets Chromium fetch over TCP 80 and 443 to any destination, for the remote images in a rendered document. Off by default — see the operating note. Use this rather than putting 80/443 in extraPorts: the named value says why the ports are open. |
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
No authentication, and this chart cannot add any
Gotenberg's basic auth and OIDC, its SSRF guards for the downloadFrom feature, and its request timeout are all command-line flags. This chart passes no flags — it offers only gotenberg.env — so none of them can be switched on through values. Everything that reaches the Service may convert, and the URL routes make the server fetch whatever URL they are handed. Keep it cluster-internal; do not put an Ingress in front of it without authentication in between.
Requests time out after 30 seconds
That is Gotenberg's own default and, since the chart passes no flags, it cannot be changed here. It is the ceiling large conversions actually meet: an 18 MB HTML needs about 22 seconds, a 25 MB one does not finish inside the 30. Callers that need longer must split the document.
Why the limits are explicit, and what each failure looks like
Chromium and LibreOffice hold the whole document in memory, so a conversion is a short, memory-hungry burst. The numbers come from measurements with the official image:
- 18 MB HTML: OOMKilled at 2Gi and at 2560Mi, converts at 3Gi in ~22 s (21 MB PDF). 12 MB HTML already OOM-kills at the old computed 1536Mi.
- Raising memory alone only moves the failure: with 3Gi memory and the old 30Mi ephemeral limit,
/tmppeaked at 40 MB on a single conversion and ~125 MB over a short series, and the pod was evicted instead of OOM-killed. HenceephemeralStorage: 256Mi.
Memory beyond 3Gi buys nothing, because the 30-second timeout is what a too-large document hits first. Neither failure reports an HTTP error to the caller:
- Out of memory — the connection is dropped mid-request and the pod's
RESTARTScounter goes up. Confirm withwhich then printskubectl -n <namespace> get pod -l app=<release>-gotenberg-deployment \ -o jsonpath='{.items[*].status.containerStatuses[*].lastState.terminated.reason}'OOMKilled. - Out of ephemeral storage —
/tmpand/home/gotenbergare emptyDirs and count against the ephemeral-storage limit (Chromium's profile alone takes about 4 MB, plus the uploaded file and the rendered PDF). The pod does not restart, it disappears: a fresh one takes its place and the old one is left in statusEvicted, with the message naming ephemeral-storage.
Since both limits are set explicitly, raising the matching request no longer drags the limit along — change gotenberg.resources.limits.memory / .limits.ephemeralStorage directly.
It shows up as "helper not reached" in the quantity-render test
The repo's resource-quantity render test injects a bare integer memory request into every resources block and checks the multiplied limit. Gotenberg sets every computable limit explicitly in its fallback defaults, and since a null never deletes a default there, no override can free them — nothing is multiplied, so nothing is proven. The job therefore lists this chart under "helper not reached" rather than counting it as a green tick for a code path that was never entered. That is the intended outcome, not a defect; see CI/CD.
Egress is DNS-only on purpose, and it costs the remote-asset half of HTML conversion
Gotenberg drives a headless Chromium, and 3000 is the port the probes address — so it is reachable from the whole cluster by default. An egress rule for 80/443 would therefore turn a service anyone can POST to into a way of fetching arbitrary URLs: a textbook SSRF pivot.
The price of blocking it is real and silent, and the two halves were measured rather than guessed. paperless-ngx splits a mail into two steps with two separate cluster-internal endpoints: the .eml goes to Tika for parsing (PAPERLESS_TIKA_ENDPOINT) and the resulting HTML comes here for rendering (PAPERLESS_TIKA_GOTENBERG_ENDPOINT). Of the images in that HTML:
- Embedded images are MIME parts inside the file. Neither Tika nor Gotenberg needs the network for them — which is why apache-tika stays DNS-only, and why a mail that carries its pictures with it needs nothing from the switch.
- Remote images (
<img src="https://…">in the body) are fetched by Chromium, here, at render time. With the switch off they are silently dropped: the PDF is produced and is simply missing them. Nothing fails, nothing is logged by this chart, no probe notices.
So a release that renders mail bodies referencing remote pictures sets
networkPolicy:
egress:
remoteAssets: true
and not extraPorts: [80, 443] — the named value records the decision, a port list cannot. The SSRF argument above is unchanged by it and is exactly why the default is off.
One thing deliberately not claimed: whether paperless-ngx re-embeds the images Tika extracted before handing the HTML over. If it does not, embedded images would be lost as well — but that would be pre-existing paperless behaviour and not this policy, so do not read the switch as a fix for it. Unverified.
Same as Tika: this chart has no Ingress, so narrowing probeSources means putting the consumers' namespaces in allowedNamespaces in the same change, or every conversion stops.
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 volume, no database, no state that survives a pod. An app-version bump is an image swap, and helm rollback carries no data risk. After a bump, curl /health is the one check worth running — it names the engine that did not come back. Renovate follows gotenberg/gotenberg through this chart's customManagers block with default docker versioning; it was one of the two charts whose PRs proved the Gitea-side Renovate instance really reads our config — see Renovate.
CI
ci/gotenberg/test-values.yaml is literally {} — no secrets, no ingress, no persistence — and there is no fixtures.yaml. The install-test waits for the Deployment to roll out, and since the probes are the app's own /health, "Ready" means both converters answered. See CI/CD.
See also
- Chart style guide — §7 on the resources helper this chart deliberately bypasses
- Versioning — what a bump of
8.0.2+up8.37.0means - apache-tika — the second half of the paperless-ngx document pipeline
- 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