Table of Contents
- calibre-web-automated
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- The hardening here is deliberately not the repo standard
- Everything else shares one claim
- The ingest folder deletes what you put into it
- This release is single-instance by construction
- NETWORK_SHARE_MODE is pinned to false
- Changing the item's content restarts nothing
- What a Ready pod does not prove
- A send-to-Kindle or e-mail setup needs its SMTP port
- 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
calibre-web-automated
Deploys Calibre-Web Automated, a Calibre-Web fork that ingests, converts and files ebooks by itself, as a single-replica StatefulSet with two volumes: one ReadWriteOnce claim for config, library and plugins, and a small ReadWriteMany drop box for the ingest folder.
| Image | ghcr.io/crocodilestick/calibre-web-automated |
| Versions | not listed on purpose — read the current pair off calibre-web-automated/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC (cwa.storage, mounted three times by subPath) + one RWX consume PVC fixed at 1Gi |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled) — see Routing: Ingress or Gateway API. The upload cap is not enforced in Gateway mode |
| External dependencies | none |
| Secrets | 1 × OnePasswordItem (two fields) |
| Install-test | ci/calibre-web-automated/ (fixtures + test values, INSTALL_TIMEOUT=600) |
Prerequisites
- 1Password operator in the cluster, plus one 1Password item — see the fields below.
- A storage class that can provision ReadWriteMany. Both PVCs share
cwa.storageClass, and the ingest claim is RWX. The default islonghorn; on a cluster without it, set the value — a class that cannot serve RWX leaves the consume claimPendingand the pod with it. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller — only if the Ingress stays enabled. In Gateway mode neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the Gateway-API CRDs plus a Gateway whose listener admits routes from this namespace. - Time for the first boot. Against an empty volume the app builds a Calibre library from scratch while the s6 init fixes ownership of
/config; that first start takes minutes where every later one takes seconds.
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 cwa jlab/calibre-web-automated \
--namespace cwa --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
cwaSecretRef: op://vault/cwa
cwa:
storageClass: longhorn
storage: 100Gi
ingress:
domain: books.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: books.example.com # 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. And cwa.maxUploadFileSize stops being enforced — for an e-book library that is the one thing to check before switching. 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.
The default login is public. Username admin, password admin123 — upstream's defaults, which this chart does not change and the 1Password item does not carry. Until it is changed under Admin → Users, everyone who reaches the address is one guess away from being administrator.
What the 1Password item must contain
The chart renders one OnePasswordItem; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item. The field names are decided by this chart and appear nowhere in 1Password.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.cwaSecretRef |
<release>-calibre-web-automated-secret |
hardcover-api-token → the HARDCOVER_TOKEN environment variable; calibre-plugins → mounted as /config/.config/calibre/customize.py.json, Calibre's plugin registry ({} is a valid empty value) |
Both fields are required even if Hardcover is never used: the CR, the environment reference and the volume are created unconditionally. The two missing-field failures differ — a missing hardcover-api-token parks the pod in CreateContainerConfigError, a missing calibre-plugins fails the mount (couldn't find key calibre-plugins) and the container is never created at all. secrets.cwaSecretRef itself is guarded with required() since chart 7.0.0: left unset, the render aborts. Until then it produced a CR with an empty itemPath — the operator had nothing to resolve, the Secret never appeared and the pod could not start, with nothing in the install output saying so.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
global |
object | unset | Helm's conventional cross-chart values. Unused by this chart; the schema keeps it open so an umbrella install cannot break on it. |
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over cwa.replicas. Both PVCs, the Secret, the Service and the Ingress stay, so no data is lost and the certificate is not re-issued. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.cwaSecretRef |
string | null |
Required, guarded by required() since 7.0.0 — unset, the render aborts with A 1Password item reference must be provided (secrets.cwaSecretRef). op:// path of the item carrying hardcover-api-token and calibre-plugins. |
cwa
| Key | Type | Default | Description |
|---|---|---|---|
cwa.replicas |
int | 1 |
Replica count; 0 is allowed. scaleDown wins over it. Raising it above 1 buys nothing — see Single-instance by construction. |
cwa.storage |
string | 10Gi |
Size of the library PVC <chart>-<release>-cwa-pvc. Deliberately not null-safe: an erased value yields an invalid PVC rather than a silently degraded workload. |
cwa.storageClass |
string | longhorn |
Storage class of both PVCs. The consume claim is RWX, so the class has to serve that mode. |
cwa.timezone |
string | "UTC" |
Passed to the image as TZ; decides log timestamps and when CWA's scheduled tasks run. |
cwa.maxUploadFileSize |
string | "10m" |
Rendered into the nginx.ingress.kubernetes.io/proxy-body-size and nginx.org/client-max-body-size annotations. It is an Ingress limit, not an application one: a bigger book is rejected with 413 by the controller and nothing about it reaches the app log. Both annotations are understood by nginx-family controllers only, the value does nothing at all while ingress.enabled is false, and it is not enforced in Gateway mode either — see Routing. |
cwa.livenessProbe |
object | GET / on port http, period 10s, timeout 5s, 3 failures |
See What a Ready pod does not prove. |
cwa.readinessProbe |
object | GET / on port http, period 10s, timeout 5s, 3 failures |
|
cwa.startupProbe |
object | GET / on port http, period 10s, timeout 5s, 30 failures (= 300s) |
Gates liveness and readiness and is what times the first boot. On a slow volume the container is killed mid-scaffold and presents as CrashLoopBackOff — raise failureThreshold and watch the boot. |
cwa.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
cwa.resources.requests |
object | cpu: 0.1, memory: 512Mi, ephemeralStorage: 100Mi |
|
cwa.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
cwa.securityContext.pod |
object | fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. fsGroup: 1000 is what matches the PUID/PGID the template pins — changing it on its own takes write access away from the app. The seccomp profile sits in the pod context on purpose (it covers init containers too) and is orthogonal to the read-only deviation below: it restricts syscalls, not mount flags. |
cwa.securityContext.container |
object | allowPrivilegeEscalation: false, readOnlyRootFilesystem: false, caps drop ALL and add CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID |
Passed verbatim into the container spec. A documented deviation from the repo hardening baseline — do not "restore" the standard here, see below. |
cwa.env |
list | [] |
Extra environment variables appended to the chart's own (TZ, PUID, PGID, HARDCOVER_TOKEN, CALIBRE_CONFIG_DIR, NETWORK_SHARE_MODE). Rendered through tpl; value, secretKeyRef, configMapKeyRef forms. Additive, not overriding — see Environment variables. |
The whole resources and securityContext blocks, and each probe, may also be null or empty: the chart then falls back to exactly the defaults above — including the deviation, which the fallbacks reproduce verbatim (style guide §12).
There is no value for the size of the ingest claim: it is fixed at 1Gi inside the chart.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Gates the whole Ingress template. Mutually exclusive with httpRoute.enabled. Nothing inside the pod reads ingress.domain, so switching the Ingress off makes both domain and clusterIssuer unnecessary — but only while the Gateway mode is off as well: the HTTPRoute is a second reader of domain whenever httpRoute.hostnames is empty. |
ingress.domain |
string | null |
Required while ingress.enabled is true, and again in Gateway mode with empty hostnames. Host and TLS subject. Read by the two routing templates and by nothing else in this chart — which is what makes a second hostname cheap here (see Routing). |
ingress.clusterIssuer |
string | null |
Required while ingress.enabled is true. 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 and the two body-size annotations the chart always sets. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone there — including the chart's own upload cap. |
TLS is stored in tls-<release>-calibre-web-automated-ingress. With neither routing mode on, the app is reachable in-cluster as <release>-calibre-web-automated-service:80, or locally via kubectl -n <namespace> port-forward svc/<release>-calibre-web-automated-service 8083:80.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 2 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>-calibre-web-automated-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. Nothing inside the pod follows the hostname in this chart, so a name set here needs no second value changed with it. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-calibre-web-automated-service on port 80 — the port number, because backendRefs cannot name a port the way the Ingress backend does.
networkPolicy
Inbound 8083 only. Outbound DNS and TCP 443 for metadata lookups (covers and book details from public providers).
| 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.
Your upload limit becomes the Gateway's, and this is a library. cwa.maxUploadFileSize exists only as an ingress-nginx annotation, and the Gateway API has no counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. So in Gateway mode the cap is whatever the Gateway enforces: a book above it is rejected before CWA sees a byte, the reader gets the proxy's error rather than a message from the application, and nothing anywhere says that a chart value was ignored. Set the limit at the Gateway, or through its implementation's own policy object next to the Gateway.
ingress.domain behaves differently here than in most charts, and in your favour. Nothing inside the pod is told its public address — no environment variable, no probe Host header, no URL CWA builds itself. So httpRoute.hostnames can be set to a second name without a single other value moving with it, which is exactly what a migration off the Ingress needs. The flip side is the claim that had to be corrected: values.yaml and the schema used to say ingress.domain is read nowhere but in the Ingress template, which stopped being true when the route became its second reader.
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>-calibre-web-automated-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.
The hardening here is deliberately not the repo standard
The image boots through the s6-overlay init as root and only then drops the application to PUID/PGID 1000. Hence no runAsNonRoot/runAsUser, a writable root filesystem, and a capabilities.add that is not empty. Two of those look like oversights and are not:
readOnlyRootFilesystemmust stayfalse. In read-only mode the image switches its PUID/PGID remapping off. The application then runs as the built-in uid 911 instead of 1000 and no longer has access to what uid 1000 wrote — an existing library goes quietly unreadable, and the init additionally fails to write the ImageMagick policy under/etc. This is the worked example behind style guide §12.4: a fallback reproduces this chart's defaults, not the repo standard, and "cleaning up" the deviation is an outage rather than tidying.KILLmust stay in the capability set. Without it the root supervisor cannot signal its own uid-1000 services; the pod stops shutting down cleanly and is SIGKILLed when the grace period ends.
PUID and PGID are pinned to 1000 in the template, not in the values. The pod's fsGroup default of 1000 is what matches them.
Everything else shares one claim
<chart>-<release>-cwa-pvc (cwa.storage on cwa.storageClass) is mounted three times by subPath:
subPath |
Mount | Holds |
|---|---|---|
config/ |
/config |
application state, the CWA database |
calibre-library/ |
/calibre-library |
the books and metadata.db |
calibre-plugins/ |
/config/.config/calibre/plugins |
Calibre plugins |
A growing library and the application's own database therefore draw on one quota, and a full volume does not merely stop imports — it takes the app down. That claim is also the entire backup: metadata.db without the book files is an index of nothing.
Both PVC names are a deliberate naming exception to §1 of the style guide: they keep the historical <chart>-<release>-cwa-pvc / <chart>-<release>-cwa-consume-pvc order, because renaming a PVC provisions a new empty volume and orphans the existing data.
The ingest folder deletes what you put into it
/cwa-book-ingest is the ReadWriteMany claim <chart>-<release>-cwa-consume-pvc. Books copied in are imported, converted and then removed — upstream: "All files within this folder will be DELETED after being processed." It is a drop box, never a second copy of the library; put nothing there that does not exist somewhere else.
Downloading straight into it is unsupported for the reason it sounds convenient: a half-written file gets picked up, which upstream warns "can cause duplicate imports and potentially a corrupt database". Copy finished files in. The claim's size is fixed at 1Gi in the chart — an import batch larger than that fills the volume, and raising it means changing the chart, not the values file.
This release is single-instance by construction
The StatefulSet mounts both claims by fixed name instead of using volumeClaimTemplates, so every replica would mount the same volumes — and the library claim is ReadWriteOnce. A second pod stays Pending unless it happens to land on the node holding the volume, and where it does start, two CWA processes write one SQLite metadata.db.
NETWORK_SHARE_MODE is pinned to false
That leaves SQLite write-ahead logging switched on. Upstream's instruction for the other case is explicit — "If you are deploying on a network share, set the following environment variable to disable WAL: NETWORK_SHARE_MODE=true" — so a library claim backed by NFS or SMB, which is what cwa.storageClass decides, runs into exactly the locking problems that setting exists to avoid. Overriding it through cwa.env repeats a name the chart already sets, so it only takes effect at install time (style guide §10).
Changing the item's content restarts nothing
Helm never sees what is inside a OnePasswordItem, so it cannot hash it into the pod template (style guide §9). Editing the item leaves helm upgrade with no change to make. Pointing secrets.cwaSecretRef at a different item does not help either here: the Kubernetes Secret's name is built from the release and chart name, so the pod spec stays byte-identical. Roll it by hand — and note that the operator's operator.1password.io/auto-restart annotation would be no substitute, since it only ever restarts Deployments and this is a StatefulSet:
kubectl -n <namespace> rollout restart statefulset/<release>-calibre-web-automated-sfs
For the plugin file that is the only way: it is mounted with subPath, and a subPath mount is never refreshed by the kubelet even when the Secret has been updated. HARDCOVER_TOKEN is an environment variable and is likewise read once, at start.
What a Ready pod does not prove
All three probes GET /, and CWA answers that with its login page no matter what else is wrong. Ready therefore says the web server is up — not that the library was found, not that one import has ever succeeded, and not that the Hardcover token is accepted. Those show in the log, and only there:
kubectl -n <namespace> logs <release>-calibre-web-automated-sfs-0 -f
A send-to-Kindle or e-mail setup needs its SMTP port
Only 443 is allowed outbound, so a mail or send-to-Kindle configuration needs its submission port (587 or 465) in networkPolicy.egress.extraPorts. Without it the send fails from this side with nothing naming the policy.
A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.
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
- Back up the library claim before an app-version bump. It carries the books,
metadata.dband the application state together; there is no external database to fall back on. helm uninstalldeletes both PVCs — they are ordinary resources of the release with no resource policy. With aDeletereclaim policy the library goes with them.- The first start after a bump may exceed the startup budget if the new version migrates its database or rebuilds the library; raise
cwa.startupProbe.failureThresholdbefore, not after the crash loop. - Changing
cwa.securityContext.container.readOnlyRootFilesystemtotrueis not an upgrade, it is data loss by uid change — see above. - App major jumps go stepwise: last release of the old major line first, then the newest major — see Versioning.
CI
ci/calibre-web-automated/ holds test-values.yaml (op:// ref against the stub CRD, host cwa.ci.local, storageClass: ci-static, storage: 2Gi), fixtures.yaml (the dummy secret with both fields plus a no-provisioner StorageClass and two pre-bound static hostPath PVs — k3d's local-path provisioner cannot serve the RWX consume claim, and the PVs' claimRefs carry the historical PVC names) and settings.env with INSTALL_TIMEOUT=600 for the library scaffold on first start. 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 runs 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, which is where a malformed parentRefs or backendRefs is caught. Instead ci/httproute/calibre-web-automated.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 also
- Chart style guide — the rules this chart implements, and §12.4, whose worked example it is
- Lessons learned — the uid-911 finding in general form
- Versioning — what a bump of
7.0.0+upv4.0.6means - Renovate — how
appVersiongets updated - 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