# Scale the whole chart down without uninstalling it: scaleDown: true renders # every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and # takes precedence over any per-workload replicas value. PVC, Secret, Service # and Ingress stay in place; scaleDown: false brings the workload back up. scaleDown: false secrets: # op:// item path for the JWT signing key. REQUIRED - the chart aborts the # render without it, and that is deliberate rather than convenient. # # ConvertX signs its session cookies with JWT_SECRET and falls back to # randomUUID() when the variable is unset (src/services/user.ts: # "secret: process.env.JWT_SECRET ?? randomUUID()"). That fallback is # per PROCESS, not per install: every restart - a node drain, an image # update, an OOM kill - mints a fresh secret and silently invalidates every # session. Nothing logs it; users simply find themselves logged out. A value # that has to survive a restart is a secret, so it is one here. # # The item must carry this field, spelled exactly like this, because the # 1Password operator copies an item's FIELD NAMES into the Secret's keys: # # jwt-secret a long random string (openssl rand -hex 32) # # A field spelled differently does not fail the install: the operator writes # a Secret without that key and the pod stays in CreateContainerConfigError, # which looks like a broken chart rather than a typo in a vault item. jwtSecretRef: null convertx: # Replica count for this workload; 0 is allowed and scales it down. # The chart-global scaleDown flag takes precedence. # # Raising this above 1 does not work and the chart does not stop you, so the # reason is here: the SQLite database, the uploads and the converted output # all live on the single ReadWriteOnce PVC below, which is a plain chart # object rather than a volumeClaimTemplate. A second pod normally never # schedules ("Multi-Attach error for volume"); on a genuinely RWX storage # class it would schedule and then corrupt the WAL-mode SQLite file that two # processes are now writing. On top of that each pod would sign cookies with # its own JWT_SECRET only if the secret were unset - which is exactly why # secrets.jwtSecretRef above is required. replicas: 1 # --- Two switches that open the instance up. Both default to false. --- # # UPSTREAM WARNS EXPLICITLY AGAINST ENABLING EITHER ON AN INTERNET-FACING # INSTANCE, and this chart puts an Ingress with a public DNS name in front # by default, so that warning applies to the standard install here. # # Both are parsed strictly by the application: it compares the lowercased # string against "true" and treats everything else as false. The chart # therefore accepts only a boolean or the strings "true"/"false" (any case) # and ABORTS THE RENDER on anything else - "yes" or "1" would otherwise be # filed under "off" without a word, which is the wrong direction to fail in # for a value whose whole job is to open the service up. # Lets anyone with the URL create an account. Note what it does NOT gate: # the FIRST account is always creatable, because a ConvertX with no users # serves a setup form to whoever opens the page first. Keeping this false # protects accounts two and onwards, not account one. accountRegistration: false # Drops authentication entirely - anyone reaching the Service can upload # files and read every conversion. Upstream describes this as local-only. allowUnauthenticated: false # Clears the "secure" flag on the session cookies (the app computes # "secure: !HTTP_ALLOWED"). Only needed when ConvertX is served over plain # http, and that is NOT our case: the Ingress terminates TLS, so the browser # speaks https and sets the cookie happily while only the in-cluster hop # between ingress controller and pod is http - which the browser never sees. # Turning this on behind our Ingress weakens the cookies for no gain. # (Same shape as outline's forceHttps, and the same reason to leave it off.) httpAllowed: false # How often ConvertX deletes jobs - uploads and converted output - older # than this many hours. 0 disables the cleanup entirely. # # This is the retention policy for the PVC below, which is why it is a value # and not left at the upstream default. With cleanup disabled the volume # grows with every conversion and nothing reclaims it; size storage.amount # for that case, or accept that the pod eventually fills its volume. # # An explicit 0 is honoured (the chart uses a nil check, not Helm's # "default", which would swallow the 0 and silently restore 24-hour # deletion on a value set precisely to switch it off). autoDeleteEveryNHours: 24 # DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup. # # The deviation is about the NUMBER only: nothing in the baseline is # relaxed. The repo default is an arbitrary uid (20000); this chart pins # 1000, and the reason is the image, not a preference. # # ghcr.io/c4illin/convertx carries NO USER instruction - verified in the # published v0.19.0 image config, where "User" is empty - so without a # securityContext the pod would run as root. Its Dockerfile creates # "convertx" with uid 1000, gid 1000 and home /home/convertx (useradd -u # 1000 -g convertx -d /home/convertx -m), and the image's entrypoint exists # only to drop to that user with gosu. # # 1000 rather than 20000 for two reasons that both have to hold: # - /home/convertx belongs to 1000:1000 in the image, and HOME is where # LibreOffice, Calibre and fontconfig put their profiles. An arbitrary # uid gets a HOME of "/" from the runtime's passwd lookup instead. # - the uid owns data on a PersistentVolume, so changing it later makes # existing conversions inaccessible. This number is not free to tidy. securityContext: pod: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 # fsGroup is what replaces the entrypoint's recursive chown of # /app/data: the kubelet applies the group to the mounted volume, so # the container can write it as uid 1000 without any privileged step. fsGroup: 1000 fsGroupChangePolicy: Always # RuntimeDefault applies the container runtime's default seccomp filter, # which blocks the syscalls a normal workload never makes. It sits in the # pod context on purpose: from here it covers every container in the pod # including init containers, while a container-level seccompProfile would # have to be repeated for each one (and silently missed on the next one # added). A container that genuinely needs a different filter can still # override it under securityContext.container, which wins over this value. seccompProfile: type: RuntimeDefault container: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL add: [] # ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"}, # explicitly exempted from authentication (src/pages/healthcheck.tsx, # "auth: false"). All three probes use it. # # Why NOT "/", the obvious default: with the chart defaults "/" is never a # 200. It answers 302 to /setup while no user exists and 302 to /login # afterwards, so a probe against it would pass on a redirect whose target # changes the moment somebody registers - a green signal that means # something different on day two than on day one. httpGet does count 3xx as # success, which is what makes that the quiet kind of wrong. # # What the endpoint proves, and what it does not: the handler touches # neither the database nor the volume, so it cannot report a broken PVC. # It is still the right readiness target here, because the SQLite database # is opened at module import - before app.listen() is reached - so a process # that answers /healthcheck at all has already opened its database. There is # no external dependency in this chart to hang readiness on; the "database" # is a file on the pod's own volume. # # WEBROOT SHIFTS THIS PATH. The application mounts every route under the # WEBROOT prefix, /healthcheck included. This chart does not offer WEBROOT # as a value (see the env block below), and setting it through env without # also moving these three paths leaves the pod probing a 404 forever. livenessProbe: httpGet: path: /healthcheck port: http periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /healthcheck port: http periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness, so those need no # initialDelaySeconds. Budget = periodSeconds * failureThreshold = 300s. # # Generous on purpose. At import time - under NODE_ENV=production, which # the image sets - ConvertX shells out to roughly twenty converter binaries # to log their versions (soffice, ebook-convert, xelatex, magick, ffmpeg, # markitdown and the rest). Those are fire-and-forget and do not block # app.listen(), but they all contend for the pod's CPU at exactly the moment # the first probe arrives, and the first soffice/ebook-convert run also # builds its profile under HOME. A tight budget turns a slow node into a # crash loop. startupProbe: httpGet: path: /healthcheck port: http periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 30 # Extra environment variables, appended to the ones the chart always sets. # Values are rendered through tpl; value, secretKeyRef and configMapKeyRef # forms are supported. # # This is where the upstream variables this chart does not expose as values # belong - BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS, # UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. They # change the UI or a converter's behaviour and nothing this chart renders, # so they need no value of their own. # # THREE VARIABLES DO NOT BELONG HERE, and they fail in three different ways: # # WEBROOT moves EVERY route, /healthcheck included, so the three # probes above start failing against a 404 while the app is # perfectly healthy - and this chart's Ingress still maps "/". # Serving under a sub-path needs the probe paths and the # Ingress path changed together; the chart does not do that # for you. # PUID/PGID are read by the image entrypoint, which this chart bypasses # /UMASK (see the command: override in the StatefulSet). They reach # nothing and configure nothing here. Set the uid through # securityContext.pod above, which is what actually applies. # PORT the app would listen on it, but containerPort, the Service # and the probes all still name 3000. Moving the port means # moving all four, so the chart pins it instead. # # Additive means additive: an entry repeating a name the chart already sets # does not replace it. At install the later entry - yours - wins; on an # EXISTING release Kubernetes merges env by name, the pair collapses to the # chart's own value, and helm upgrade still reports success. Read the value # back off the StatefulSet rather than trusting a green upgrade. env: [] resources: # Limits are optional: memory and ephemeral-storage limits default to # limitFactor x the request, an explicitly set limit always wins, and a # CPU limit is only set when configured explicitly under "limits". # # The numbers are sized for conversions, not for an idle web server, and # each one has a reason: # # cpu 0.25 An idle Bun process uses almost nothing, but a # conversion saturates whatever it can get and the startup # fires ~20 version probes at once. Per repo convention no # CPU LIMIT is computed, so this is the scheduling floor # and the throttling share under contention, not a cap - # a conversion may burst well past it on an idle node. # # memory 1Gi soffice --headless is a few hundred MB on its own, # ebook-convert pulls in Qt WebEngine, and ImageMagick on # a large raster can want more than either. 1Gi requested, # 3Gi limit (limitFactor 3). Memory is NOT compressible: # a conversion that crosses the limit is an OOM kill of # the whole pod, so raise the request before feeding the # instance large video or raw-image work. # # ephemeral- 1Gi, and this one is easy to get wrong: EMPTYDIR VOLUMES # storage COUNT AGAINST IT. The chart mounts two (/tmp and # /home/convertx) for the converter toolchain, and # Ghostscript, TeX and Calibre all scratch to /tmp. The # uploads and the converted output do NOT count - those # live on the PVC - so the figure covers scratch space # only. Crossing the 3Gi limit evicts the pod. limitFactor: 3 requests: cpu: 0.25 memory: 1Gi ephemeralStorage: 1Gi # The volume behind /app/data: the SQLite database, every uploaded file and # every converted result. There is no second store and no object storage. # # Not null-safe on purpose, following the repo's documented exception: an # erased block yields a PVC without a size, which the API server rejects at # apply time. That is louder than a volume quietly sized by the chart. storage: amount: 10Gi storageClass: null ingress: # Gates the whole Ingress template. Checked in both directions as the style # guide requires: NOTHING outside the Ingress reads ingress.domain in this # chart - ConvertX has no base-URL, public-URL or redirect-URI variable at # all (verified against the complete env list in src/helpers/env.ts) - so # switching the Ingress off frees BOTH domain and clusterIssuer here. enabled: true # THE ONLY UPLOAD CEILING THERE IS. # # ConvertX sets no size limit of its own: it starts Elysia with # maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload # variable of any kind. So unlike outline - where an app limit and an # ingress limit sat a factor of ten apart and the smaller one won # confusingly - there is exactly one number here, and it is this one. # # Two consequences worth knowing before changing it: # - A file above this limit never reaches ConvertX. The user gets the # ingress controller's 413 page, not a message from the application, # and nothing appears in the pod log. # - With ingress.enabled: false there is NO ceiling from this chart at # all, and whatever proxy you put in front brings its own - usually # ingress-nginx's 1m default, which is small enough to look like a # broken upload button. # # Defaulted generously because the point of the application is converting # video and large documents. What it costs: the body transits the ingress # controller and then lands on the PVC, so raising it means checking # storage.amount as well. maxUploadFileSize: 1024m clusterIssuer: null domain: null className: nginx # Extra annotations for the Ingress; merged with the cert-manager # cluster-issuer and proxy-body-size annotations the chart always sets. annotations: {}