Table of Contents
- convertx
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Whoever opens the page first becomes the administrator
- This chart bypasses the image entrypoint
- Why uid 1000 and not 20000
- What has to be writable
- Why the probes use /healthcheck and not /
- Two switches that open the instance up
- Why yes is a render error
- The ingress is the only upload ceiling
- Retention is a value
- One replica, one volume
- Sizing: the numbers are for conversions
- What does not belong in convertx.env
- Rotating the secret does not restart the pod by itself
- Upgrades and backup
- CI
- See also
convertx
Deploys ConvertX, a self-hosted file converter with a web UI, as a single-replica StatefulSet. Everything it persists — the SQLite database, the uploaded files and the converted results — lives on one PVC. There is no external database, cache or object storage.
| Image | ghcr.io/c4illin/convertx |
| Versions | not listed on purpose — read the current pair off convertx/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC at /app/data, plus two emptyDirs (/tmp, /home/convertx) |
| Ingress | yes, ingress.domain + ingress.clusterIssuer required while enabled |
| External dependencies | none |
| Secrets | 1 × OnePasswordItem |
| Install-test | ci/convertx/ (test values, the JWT Secret fixture, raised INSTALL_TIMEOUT) |
Prerequisites
- 1Password operator in the cluster, plus one 1Password item — see the fields below.
- cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. - Disk and patience on the node. The image is ~1.4 GiB compressed (one layer alone is 1354 MiB) because LibreOffice, Calibre, ImageMagick, ffmpeg, Inkscape and a TeX Live installation are inside it. On a node that has not seen it, the first pod sits in
ContainerCreatingfor minutes before the startup probe budget even begins. - Nothing else. No Postgres, no Redis, no S3 — which is why one PVC snapshot is a complete backup of a release.
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 convertx jlab/convertx \
--namespace convertx --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
jwtSecretRef: op://vault/convertx
ingress:
domain: convert.example.com
clusterIssuer: letsencrypt
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.
Then open the URL yourself, immediately. See Whoever opens the page first becomes the administrator — this is the one step of a ConvertX install that cannot be repaired afterwards.
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 pod then hangs in CreateContainerConfigError naming the key it could not find.
| Values key | Kubernetes Secret | Field |
|---|---|---|
secrets.jwtSecretRef |
<release>-convertx-secret |
jwt-secret — a long random string, openssl rand -hex 32 |
Why this is required rather than optional. ConvertX signs its session cookies with JWT_SECRET and falls back to randomUUID() when the variable is unset — a fresh secret per process, not per install. Every restart (a node drain, an image update, an OOM kill) would then invalidate every session, with no log line and nothing in the UI to say so. A value that has to survive a restart is a secret, so the chart makes it one and guards it with required().
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over convertx.replicas. PVC, Secret, Service and Ingress stay. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.jwtSecretRef |
string | null |
Required. op:// path of the item carrying jwt-secret. |
convertx
| Key | Type | Default | Description |
|---|---|---|---|
convertx.replicas |
int | 1 |
Replica count; 0 is allowed. See One replica, one volume — anything above 1 does not work. |
convertx.accountRegistration |
bool | false |
ACCOUNT_REGISTRATION. Lets anyone with the URL create an account. Does not gate the first one. See Two switches that open the instance up. |
convertx.allowUnauthenticated |
bool | false |
ALLOW_UNAUTHENTICATED. Removes the login entirely. Upstream describes this as local-only. |
convertx.httpAllowed |
bool | false |
HTTP_ALLOWED. Clears the secure flag on the session cookies. Only needed without TLS in front; behind this chart's Ingress the browser speaks https and the default is correct. |
convertx.autoDeleteEveryNHours |
int | 24 |
AUTO_DELETE_EVERY_N_HOURS. Deletes jobs older than this many hours — uploads and output. 0 disables it and is honoured as such. This is the retention policy for the PVC. |
convertx.securityContext.pod |
object | non-root 1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. The uid is pinned deliberately — see Why uid 1000 and not 20000. |
convertx.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation |
Passed verbatim into the container spec. See What has to be writable. |
convertx.livenessProbe |
object | GET /healthcheck on port http, period 10s, timeout 5s, 3 failures |
See Why the probes use /healthcheck and not /. |
convertx.readinessProbe |
object | same | |
convertx.startupProbe |
object | GET /healthcheck, period 10s, 30 failures (= 300s) |
Gates liveness and readiness. Generous because ConvertX shells out to ~20 converter binaries at startup just to log their versions. |
convertx.env |
list | [] |
Extra environment variables appended to the chart's own. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables, and What does not belong in convertx.env. |
convertx.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
convertx.resources.requests |
object | cpu: 0.25, memory: 1Gi, ephemeralStorage: 1Gi |
Sized for conversions, not for an idle web server — see Sizing. |
convertx.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
The three booleans also accept the strings "true" / "false" (any case). Anything else — yes, 1, on — aborts the render with a message naming the values path; see Why yes is a render error.
storage
| Key | Type | Default | Description |
|---|---|---|---|
storage.amount |
string | 10Gi |
Size of the data PVC. |
storage.storageClass |
string | null |
Storage class; null uses the cluster default. |
Deliberately not null-safe, following the documented exception (§12.2): 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.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Switches the Ingress object off. Unlike outline or searxng, this frees both domain and clusterIssuer — ConvertX has no base-URL, public-URL or redirect-URI variable, so nothing outside the Ingress reads the domain. Checked in both directions, as §12.1 requires. |
ingress.domain |
string | null |
Required while the Ingress is enabled. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. |
ingress.className |
string | nginx |
Ingress class. |
ingress.maxUploadFileSize |
string | 1024m |
proxy-body-size annotation — and the only upload ceiling that exists. See The ingress is the only upload ceiling. |
ingress.annotations |
object | {} |
Merged with the cert-manager and body-size annotations the chart always sets. |
TLS is stored in tls-<release>-convertx-ingress.
Operating notes
Whoever opens the page first becomes the administrator
A ConvertX with no users serves a setup form to every visitor — no password, no invitation, nothing to bypass. The first account created that way owns the instance.
convertx.accountRegistration does not gate this. That value decides whether accounts two-and-onwards can be created; the first one is always creatable, because otherwise the instance could never be set up at all.
So register yourself before the DNS name is reachable by anyone else. There is no "reset admin" command: the repair is deleting the users row from the SQLite database on the PVC.
This chart bypasses the image entrypoint
The image carries no USER, so it starts as root, and /entrypoint.sh exists solely to drop to uid 1000 with gosu. The chart sets command to the image's own CMD (bun run dist/src/index.js), which replaces the entrypoint, and lets Kubernetes do the privilege work instead: runAsUser/runAsGroup: 1000, and fsGroup: 1000 in place of the entrypoint's recursive chown of /app/data. The hardening baseline is therefore kept, not relaxed — this is not a documented deviation from §5, it is the opposite.
What the entrypoint actually does, read rather than assumed: nothing but uid/gid plumbing. No migration, no config generation, no secret generation. ConvertX creates and migrates its SQLite schema in-process at import time, so starting it directly is migration-safe. At v0.19.0 its first branch is already a plain pass-through when PUID is unset (exec "$@"), so today the bypass costs nothing at all.
The price, and it is in the future tense: if upstream ever moves real work into that entrypoint — a data migration is the obvious candidate — this chart skips it silently. No error, no log line, no failed probe; the application simply starts against an unmigrated volume. So:
Re-read the upstream
entrypoint.shat every app-version update, not only at major jumps. If it has grown anything beyond uid/gid handling, stop and reconsider the override.
That instruction is also a comment at the command: line in the StatefulSet, which is where somebody bumping the version will actually be looking.
The one genuinely load-bearing line the bypass loses is export HOME=/home/convertx. The chart sets HOME itself rather than relying on the container runtime's /etc/passwd lookup — that lookup does resolve correctly in this image, but "happens to" is not a basis for the directory LibreOffice writes its profile into.
Why uid 1000 and not 20000
The repo default is an arbitrary uid (20000). This chart pins 1000, and the deviation is about the number only — no part of the hardening baseline is relaxed. Two reasons, and both have to hold:
/home/convertxbelongs to1000:1000in the image (useradd -m), and that isHOME, where LibreOffice, Calibre and fontconfig put their profiles. An arbitrary uid gets aHOMEof/from the runtime's passwd lookup instead.- The uid owns data on a PersistentVolume. Changing it later makes existing conversions inaccessible, so the number is not free to tidy.
The fallback defaults in _helpers.tpl reproduce the 1000, per §12.4 — restoring the "clean" repo standard there would be an outage, not housekeeping.
What has to be writable
readOnlyRootFilesystem: true holds, with two emptyDirs. Neither is a precaution; both were derived from what the code actually does.
| Path | Kind | Why |
|---|---|---|
/app/data |
PVC | SQLite database, uploads, converted output — every write the application makes |
/home/convertx |
emptyDir | HOME. LibreOffice is invoked without -env:UserInstallation, so its user profile can only be created under $HOME; Calibre, Inkscape, ImageMagick and fontconfig do the same with their config and cache directories |
/tmp |
emptyDir | scratch space for Ghostscript, TeX Live and Calibre during a conversion |
The application itself writes nowhere outside /app/data. The emptyDirs exist for the converter subprocesses: all twenty converters call execFile(cmd, args, cb) without a cwd or env option, so every one of them inherits the app's HOME and working directory.
/app itself stays read-only, and workingDir: /app is pinned in the StatefulSet for a reason that is easy to miss: uploadsDir and outputDir are the relative literals ./data/uploads/ and ./data/output/, configurable by no environment variable. A different working directory would put every upload somewhere other than the PVC — and the application would report no error at all.
Why the probes use /healthcheck and not /
ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"}, explicitly exempt from authentication. All three probes use it.
/ would have been the obvious default and is the wrong one. With this chart's defaults it is never a 200: it answers 302 to /setup while no account exists and 302 to /login afterwards. httpGet counts 3xx as success, so a probe against / would have passed — on a redirect whose target changes the moment somebody registers. That is the quiet kind of wrong, which is why the reason is written into the chart rather than only fixed.
What a green pod proves, and what it does not. The handler touches neither the database nor the volume, so it cannot report a broken PVC — a green pod means the process is serving, not that a conversion works. It is still the right readiness target here: the SQLite database is opened at module import, before app.listen() is reached, so a process that answers at all has already opened its database. There is no external dependency in this chart to hang readiness on (§12.3); the "database" is a file on the pod's own volume.
Two switches that open the instance up
convertx.accountRegistration and convertx.allowUnauthenticated, both false by default. Upstream warns explicitly against enabling either on an instance reachable from the internet, and this chart puts a public DNS name in front by default, so that warning applies to the standard install here.
accountRegistration: true— anyone with the URL can sign up.allowUnauthenticated: true— there is no login at all. Anyone who reaches the Service can upload files and read every conversion on the volume.
NOTES.txt prints a block warning if either is on, naming which.
Why yes is a render error
ConvertX parses each of these variables as process.env.X?.toLowerCase() === "true", so anything that is not the literal true means false — yes, 1, on, a trailing space. Two of the three are the switches above, and a value written to turn one on that silently lands on off is a failure nobody goes looking for, because the symptom is the safe state.
So the chart renders these through a helper that emits the literal "true"/"false" and aborts the render on anything else, naming the values path. This is §12.6 applied to a pass-through value: a value that cannot be read as either boolean belongs in a fail, not in an implicit branch.
The ingress is the only upload ceiling
ConvertX enforces no size limit of its own — it starts its HTTP server with maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload variable of any kind. So unlike outline, where an app ceiling and an ingress ceiling sat a factor of ten apart, there is exactly one number here: ingress.maxUploadFileSize, defaulted generously to 1024m.
Two consequences worth knowing before changing it:
- A file above the limit never reaches ConvertX. The user gets the ingress controller's 413 page and nothing appears in the pod log, which looks like a broken upload button rather than a limit.
- With
ingress.enabled: falsethe chart sets no ceiling anywhere, and whatever proxy is in front brings its own — ingress-nginx defaults to1m. - Raising it has a second cost: the upload lands on the PVC, so a ceiling far beyond
storage.amountonly moves the failure from a 413 to a full volume.
Retention is a value
convertx.autoDeleteEveryNHours (default 24) is what empties the volume, and it is the only thing that does. It is a hard delete of the job's uploads and output, not an archive: a conversion nobody downloaded in time is gone.
0 switches the cleanup off, and it is honoured as such — the chart uses an explicit nil check, because Helm's default swallows a 0 and would restore 24-hour deletion on the one install that asked for none. With the cleanup off, size storage.amount for unbounded growth.
One replica, one volume
The SQLite database, the uploads and the output all live on the single ReadWriteOnce PVC, 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 two processes are now writing. convertx.replicas above 1 does not work, and NOTES.txt says so if it is set.
Sizing: the numbers are for conversions
cpu: 0.25— an idle Bun process uses almost nothing, but a conversion saturates whatever it can get. Per repo convention no CPU limit is computed, so this is the scheduling floor and the throttling share under contention, not a cap.memory: 1Gi(limit 3Gi) —soffice --headlessis a few hundred MB on its own,ebook-convertpulls in Qt WebEngine, and ImageMagick on a large raster can want more than either. Memory is not compressible: a conversion crossing 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-storage: 1Gi(limit 3Gi) — and this is the one that is easy to get wrong: emptyDir volumes count against it. The chart mounts two, and Ghostscript, TeX and Calibre all scratch to/tmp. Uploads and output do not count — those are on the PVC — so the figure covers scratch space only. Crossing the limit evicts the pod.
What does not belong in convertx.env
The block is the right place for the upstream variables the chart does not expose: BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS, UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. Three variables do not belong there, and they fail in three different ways:
| Variable | What happens |
|---|---|
WEBROOT |
Moves every route of the application, /healthcheck included, so all three probes 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, which the chart does not do for you. |
PUID / PGID / UMASK |
Read by the image entrypoint, which this chart bypasses. They reach nothing and configure nothing. Set the uid through convertx.securityContext.pod. |
PORT |
The app would listen on it, but containerPort, the Service's targetPort and the three probes all still name 3000. Moving the port means moving all four, so the chart pins it. |
Rotating the secret does not restart the pod by itself
Helm never sees a OnePasswordItem's content, so it cannot hash it (§9). What the chart can cover it does: the pod template carries checksum/secret-ref over the rendered OnePasswordItem, so pointing secrets.jwtSecretRef at a different item rolls the pod. Editing the value inside the same item does not. After such a rotation:
kubectl -n <namespace> rollout restart statefulset/<release>-convertx-sfs
Rotating jwt-secret invalidates every existing session — which is the intended behaviour of a rotation, but worth announcing first.
operator.1password.io/auto-restart is deliberately not set: the operator only restarts Deployments, so on a StatefulSet it is a silent no-op that advertises a guarantee it does not give.
Upgrades and backup
- One PVC snapshot is a complete backup. No external database, no object storage — which also means losing the volume loses the accounts along with the files.
helm uninstalldeletes the PVC (it is a chart object), unless the storage class reclaim policy says otherwise.- Re-read the upstream
entrypoint.shat every app-version bump. See the bypass section — this is the one recurring obligation this chart creates. - Expect a slow first pull after every bump. ~1.4 GiB, so the node re-pulls a large layer whenever the tag moves.
CI
ci/convertx/ holds test-values.yaml (an op:// ref against the stub CRD, convertx.ci.local, a 1Gi volume), fixtures.yaml (the jwt-secret Secret the stub OnePasswordItem CRD cannot materialize — without it the pod hangs in CreateContainerConfigError) and settings.env with INSTALL_TIMEOUT=900, because the rollout wait has to cover the ~1.4 GiB pull and not just the start. The install-test runs on every PR that touches the chart — see CI/CD.
The raised timeout turned out to be headroom rather than need: the first green leg measured 2m35s including cluster creation (Gitea PR #61), against an estimate of roughly five minutes for the pull alone. It stays at 900s anyway — a rollout-wait timeout is a ceiling, not a cost, so the headroom is free on a good day and only spends time on a chart that is already failing.
Renovate tracks appVersion with a regex anchored on the v prefix. The registry carries the versioned family next to three moving tags — latest, main and one pr-<n> per open pull request — and v0.19.0 currently shares a digest with latest, which is exactly the trap the anchor exists for. See Renovate.
See also
- Chart style guide — the rules this chart implements
- Versioning — what a bump of
1.0.0+upv0.19.0means - 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