Table of Contents
- zipline
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- Liveness does not check the database — on purpose
- The first visitor chooses the administrator
- Startup runs the database migrations
- Two upload ceilings, and the one that actually bites
- Chunks go through /tmp, and /tmp is accounted for
- One replica, one volume
- Rotating a secret does not restart the pod
- The egress ports are the standard ones, not your configured ones
- 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
zipline
Deploys Zipline, a file- and image-sharing server, as a single-replica StatefulSet with one upload volume. Postgres is not part of this chart. Zipline is also the repo's reference case for a probe split that does not lie: liveness probes /, readiness and startup probe /api/healthcheck.
| Image | ghcr.io/diced/zipline |
| Versions | not listed on purpose — read the current pair off zipline/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC, three subPath mounts (uploads, public, themes) + /tmp emptyDir |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.domain required always, ingress.clusterIssuer only while the Ingress is enabled — see Routing: Ingress or Gateway API. In Gateway mode the upload cap is not enforced |
| External dependencies | PostgreSQL |
| Secrets | 2 × OnePasswordItem |
| Install-test | ci/zipline/ (fixtures + test values) |
Prerequisites
- 1Password operator in the cluster, plus two 1Password items — see the fields below.
- A reachable PostgreSQL server before the first start. The chart deploys none. Readiness and startup hit
/api/healthcheck, which checks the database, so a missing server shows up as a pod that never turns Ready — and, because liveness does not check it, as a pod that never restarts either. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. 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. - A StorageClass for
config.storageClass(defaultlonghorn), ideally one that supports volume expansion.
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 zipline jlab/zipline \
--namespace zipline --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
databaseSecretRef: op://vault/zipline-db
ziplineSecretRef: op://vault/zipline-core
ingress:
domain: share.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: share.example.com # still required: it is CORE_DEFAULT_DOMAIN
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. ingress.domain is still required — it is also CORE_DEFAULT_DOMAIN, the host in every share link. And config.maxUploadFileSize stops being enforced, which for an upload service is the value to check first; 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.
What the 1Password items must contain
The chart renders two OnePasswordItem CRs; the operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing or misspelled field does not fail the install: the Secret exists, so the operator looks like it did its job, and the pod then hangs in CreateContainerConfigError naming the key it could not find.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.databaseSecretRef |
<release>-zipline-db-secret |
connection-url → DATABASE_URL, a full PostgreSQL URL, e.g. postgresql://zipline:password@postgres.example.com:5432/zipline |
secrets.ziplineSecretRef |
<release>-zipline-secret |
server-secret → CORE_SECRET, signs sessions and tokens (openssl rand -base64 32) |
What the operator actually delivered:
kubectl -n <namespace> get secret <release>-zipline-db-secret -o jsonpath='{.data}'
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over zipline.replicas. PVC, both Secrets, Service and Ingress stay. |
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. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.databaseSecretRef |
string | null |
Required. op:// path of the Postgres item. |
secrets.ziplineSecretRef |
string | null |
Required. op:// path of the app-secret item. |
config
| Key | Type | Default | Description |
|---|---|---|---|
config.storage |
string | 50Gi |
Size of the uploads PVC. Required — it has a default, but required() rejects an explicit null. Growing it only works on a StorageClass that allows expansion; shrinking is refused by Kubernetes and fails the next upgrade on the PVC. |
config.storageClass |
string | longhorn |
StorageClass of the uploads PVC. Required, same mechanics as above; this chart pins a class rather than falling back to the cluster default. |
config.maxUploadFileSize |
string | 30m |
Rendered into the ingress-nginx proxy-body-size / client-max-body-size annotations, so it takes effect only while the Ingress is enabled — and is not enforced in Gateway mode either. See Two upload ceilings. |
zipline
| Key | Type | Default | Description |
|---|---|---|---|
zipline.replicas |
int | 1 |
Replica count; 0 is allowed. See One replica, one volume before raising it. |
zipline.securityContext.pod |
object | non-root 1000:1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. The image ships no dedicated user; the Node server runs fine as an arbitrary non-root uid, and fsGroup is what makes the uploads PVC writable. The seccomp profile sits at pod level on purpose: it covers every container including init containers. |
zipline.securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Passed verbatim into the container spec. |
zipline.livenessProbe |
object | GET / on port zipline-server, period 10s, timeout 5s, 3 failures |
Deliberately not the health endpoint — see below. |
zipline.readinessProbe |
object | GET /api/healthcheck, period 10s, timeout 5s, 3 failures |
Checks server and database. |
zipline.startupProbe |
object | GET /api/healthcheck, period 10s, timeout 5s, 30 failures (= 300s) |
Gates liveness and readiness. The budget covers the startup migrations. |
zipline.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. |
zipline.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
zipline.resources.requests |
object | cpu: 0.1, memory: 265Mi, ephemeralStorage: 100Mi |
|
zipline.resources.limits |
object | unset (computed: memory: 795Mi, ephemeral-storage: 300Mi) |
An explicit limit always wins. A CPU limit is only rendered when set here. |
An erased block (zipline.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. config is deliberately not null-safe: its two required keys fail loudly.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
false omits the Ingress and frees clusterIssuer — but not domain. Mutually exclusive with httpRoute.enabled. |
ingress.domain |
string | null |
Required, always. Ingress host and CORE_DEFAULT_DOMAIN, the host Zipline puts into every share link it hands out. An empty value would hand out unusable links silently, so the StatefulSet carries its own required() (style guide §12.1). Also the HTTPRoute's hostname while httpRoute.hostnames is empty. |
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 body-size annotations the chart always sets. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
TLS is stored in tls-<release>-zipline-ingress.
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>-zipline-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. A hostname set here does not move CORE_DEFAULT_DOMAIN with it — every share link keeps naming ingress.domain until that value is changed too. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-zipline-service on port 80 — the port number, because backendRefs cannot name a port the way the Ingress backend does. One path covers the UI, the API and the short URL of every upload.
networkPolicy
Inbound 3000 only. Outbound DNS, TCP 5432 (Postgres) and 443 (the optional OAuth providers, and an external S3 endpoint if one is configured).
| 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.
ingress.domain is required in this mode too, and not because of the route. It is also CORE_DEFAULT_DOMAIN, with its own required() in the StatefulSet — the host Zipline writes into every share link it hands out, which is the whole point of the application. Set httpRoute.hostnames to a name ingress.domain does not carry and the route publishes one host while every link names another.
Your upload ceiling becomes the Gateway's, and this is an upload service. config.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. In Ingress mode that value is the ceiling that actually bites, far below the app's pinned UPLOADER_ADMIN_LIMIT=500mb; in Gateway mode the ceiling is whatever the Gateway enforces, the rejection comes from the proxy rather than from Zipline, and nothing says a chart value was ignored. Set it at the Gateway or through its implementation's own policy object. The /tmp ephemeral-storage trap below is unchanged by the mode and is worth re-reading before raising anything.
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>-zipline-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.
Liveness does not check the database — on purpose
This is the positive example of style guide §12.3. /api/healthcheck checks the server and the database, so it belongs on readiness and startup; liveness uses /, so a database outage cannot restart the pod. Do not "harmonize" the three paths.
Know the resulting picture before you meet it: during a database outage the container keeps running and stays quiet, the pod goes NotReady, its endpoint is dropped and the Ingress answers 503 — while the pod's log and its restart count say nothing at all. Look at the database first.
The converse also holds: a Ready pod here really does mean "server up, database answering", which is more than most readiness probes prove.
The first visitor chooses the administrator
Against an empty database Zipline has no users and sends every visitor to its setup page to name the super administrator and set its password; the endpoint refuses to run a second time once an account exists. Whoever reaches the URL first owns the instance. Do the setup immediately after the first rollout, not the next morning. Against a database that already carries a Zipline schema the step is skipped and the normal login appears.
Startup runs the database migrations
Zipline migrates its schema on start, before serving anything. The startup probe covers that window (300s by default) and keeps liveness and readiness off until it is through; if a migration needs longer, the kubelet kills the container and the next attempt starts the migration over. A failed migration looks like CrashLoopBackOff and only the log says why:
kubectl -n <namespace> logs statefulset/<release>-zipline-sfs
Raise zipline.startupProbe.failureThreshold before a large jump, not after the crash loop has started.
Two upload ceilings, and the one that actually bites
The app-side limit is pinned by the chart at UPLOADER_ADMIN_LIMIT=500mb and is not exposed as a value. In front of it the Ingress caps the request body at config.maxUploadFileSize (30m), which is therefore the ceiling users meet: anything larger is rejected by ingress-nginx with a 413 before Zipline sees a byte, so the error page comes from the proxy and not from the app. Raise config.maxUploadFileSize to move it. With both routing modes off the value is rendered nowhere — whatever proxy sits in front needs its own body-size limit.
In Gateway mode it is likewise not enforced, and that is a decision rather than a gap: the Gateway API has no body-size field, and rebuilding the limit would mean rendering an implementation-specific policy object. The ceiling is then the Gateway's, somewhere between "smaller than you expect" and the app's 500 MB, and the only way to know is to read the Gateway. See Routing.
Chunks go through /tmp, and /tmp is accounted for
The root filesystem is read-only, so /tmp is an emptyDir — and an emptyDir counts against the container's ephemeral-storage limit (300Mi with the defaults). An upload that pushes it over does not fail as a request: the kubelet evicts the whole pod, and the reason is on the pod, not in Zipline's log. Where uploads approach the 500mb the app allows, raise zipline.resources.requests.ephemeralStorage; the limit follows through limitFactor.
One replica, one volume
uploads, public and themes share a single ReadWriteOnce PVC, <release>-zipline-vc, each on its own subPath. It is a plain chart object, not a volumeClaimTemplate, so every replica would mount the same volume and the extra pods stay stuck before start. This chart is built for one replica.
The -vc suffix is a documented exception from the -<kind> naming scheme: renaming the PVC would provision a new, empty volume (style guide §1).
Rotating a secret does not restart the pod
Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9). Pointing a *SecretRef at a different item rolls the pod; editing a value inside the same item does not:
kubectl -n <namespace> rollout restart statefulset/<release>-zipline-sfs
A new CORE_SECRET logs every user out.
The egress ports are the standard ones, not your configured ones
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.
Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the conventional ports, and a dependency listening anywhere else is blocked. Put the real port in networkPolicy.egress.extraPorts.
And note where the failure surfaces here: the probes address this app's own port and not the database, so a blocked Postgres leaves the pod Ready and shows up as an application error instead.
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 database before every app-version bump. Migrations run in place and are one-way;
helm rollbackreturns the old image to a schema the new one has already rewritten. - Backup is two things at once. The database holds users, metadata and one row per file; the PVC holds the bytes. Snapshot them together.
- The PVC belongs to this release. It carries no resource-policy annotation, so
helm uninstall <release>deletes it and, with aDeletereclaim policy, every uploaded file goes with it. The external database survives, which makes an accidental uninstall look recoverable while the files are already gone. Adding the annotation to the live PVC afterwards does not help — Helm reads it from the release manifest. - Growing
config.storageneeds a StorageClass that allows volume expansion; shrinking is refused and fails the nexthelm upgradeon the PVC instead of touching data.
CI
ci/zipline/test-values.yaml pins storageClass: local-path, 1Gi, the host zipline.ci.local and two op:// paths against the stub CRD. ci/zipline/fixtures.yaml supplies the two Kubernetes Secrets the 1Password operator would otherwise materialize, plus a throwaway Postgres 16 Deployment and Service. Because startup and readiness check the database, a green install-test here means the app really connected. The 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 — and here the Ingress mode is also the one that proves the database connection. cyberchef is the chart that pays the other 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/zipline.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. Neither half proves that traffic flows: no Gateway controller runs in CI.
See also
- Chart style guide — §12.3, whose reference implementation this chart is
- Versioning — what a bump of
7.1.0+up4.7.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