Table of Contents
- voucher-vault
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- voucherVault.env is the complete environment, not an additive block
- ingress.domain is not an ingress-only value
- What a Ready pod does not prove
- The first start prints the only admin password it will ever print
- A pod that never starts is usually waiting for Postgres
- Read-only root filesystem, and the static-files init container
- Single-instance by construction
- Expiry notifications stay silent until a user adds an Apprise URL
- 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
voucher-vault
Deploys VoucherVault, a Django application for storing vouchers, gift cards and loyalty cards, as a single-replica StatefulSet with a local upload volume. Postgres, Redis and the OIDC provider are not part of this chart.
| Image | docker.io/l4rm4nd/vouchervault |
| Versions | not listed on purpose — read the current pair off voucher-vault/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) plus an init-static init container |
| Storage | one RWO PVC mounted at /opt/app/database; three emptyDirs for the read-only root filesystem |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled). ingress.domain required always, ingress.clusterIssuer while the Ingress is enabled — see Routing: Ingress or Gateway API |
| External dependencies | Postgres, Redis (Celery broker), an OIDC provider |
| Secrets | 3 × OnePasswordItem |
| Install-test | ci/voucher-vault/ (fixtures + test values) |
Prerequisites
- 1Password operator in the cluster, plus three 1Password items — see the fields below.
- Postgres reachable before the first start. The entrypoint loops on
nc -z $POSTGRES_HOST $POSTGRES_PORTwith no timeout, so a wrong host or port produces no error at all — see A pod that never starts is usually waiting for Postgres. - Redis as the Celery broker. A wrong
redis-urlcosts the expiry notifications and shows nothing else. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. - An OIDC client whose redirect URI is
https://<ingress.domain>/oidc/callback/— with the trailing slash, and without the language prefix every other path of the app carries. The sign algorithm is RS256, which is why a JWKS endpoint is a required field rather than an optional extra. - A storage class that honours
fsGroup. The entrypoint exits 1 when/opt/app/databaseis not writable for uid/gid 33.
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 voucher-vault jlab/voucher-vault \
--namespace voucher-vault --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
voucherVaultSecretRef: op://vault/voucher-vault
oidcSecretRef: op://vault/voucher-vault-oidc
databaseSecretRef: op://vault/voucher-vault-db
ingress:
domain: vouchers.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: <the public hostname> # 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. ingress.domain is still required — it is Django's DOMAIN and therefore its only ALLOWED_HOSTS entry besides 127.0.0.1. See Routing: Ingress or Gateway API.
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — and additionally warns when voucherVault.env has dropped a required entry. Read it — it is the per-release version of this page.
What the 1Password items must contain
The chart renders three OnePasswordItem CRs unconditionally; each carries an item path only. The operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item, and those field names are decided by this chart — they appear nowhere in 1Password. A missing field does not fail the install: the operator writes a Secret without that key, and the pod then hangs in CreateContainerConfigError naming the key it could not find.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.voucherVaultSecretRef |
<release>-voucher-vault-secret |
secretKey (Django's SECRET_KEY; changing it logs everyone out), redis-url (Celery broker, e.g. redis://:PASSWORD@redis:6379/0) |
secrets.databaseSecretRef |
<release>-voucher-vault-db-secret |
host, port, user, password, database |
secrets.oidcSecretRef |
<release>-voucher-vault-oidc-secret |
client-id, client-secret, authorization-endpoint, token-endpoint, user-endpoint, oidc-jwks-endpoint |
The OIDC item is required even if OIDC is turned off via voucherVault.env: the CR is not gated, and the container references its keys.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over voucherVault.replicas. PVC, Secrets, Service and Ingress stay, so the certificate is not re-issued. |
global |
object | unset | Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.voucherVaultSecretRef |
string | null |
Required. op:// path of the app item (secretKey, redis-url). |
secrets.oidcSecretRef |
string | null |
Required, unconditionally — the CR is rendered whether or not OIDC is used. op:// path of the OIDC item. |
secrets.databaseSecretRef |
string | null |
Required. op:// path of the Postgres item. |
voucherVault
| Key | Type | Default | Description |
|---|---|---|---|
voucherVault.replicas |
int | 1 |
Replica count; 0 is allowed. See Single-instance by construction before raising it. |
voucherVault.ports |
list | [{containerPort: 8000, protocol: TCP, name: http}] |
Rendered verbatim as the container's ports list. The name http is load-bearing: the Service and all three probes select it by name. |
voucherVault.env |
list | the chart's full list (see below) | The container's complete environment, not an additive block. A values file that sets it replaces the whole list, secret wiring included. Entries are rendered through tpl; value, secretKeyRef and configMapKeyRef forms. |
voucherVault.storage.volumes |
list | one entry: voucher-vault-data → PVC <release>-voucher-vault-pvc |
Volumes of the workload, rendered verbatim. persistentVolumeClaim, configMap, secret and emptyDir forms are supported; names and paths go through tpl. |
voucherVault.storage.volumeMounts |
list | one entry: voucher-vault-data at /opt/app/database, subPath: voucher-vault-data |
Mounts of the workload, rendered verbatim, in addition to the three emptyDirs the chart always mounts. |
voucherVault.securityContext.pod |
object | non-root 33, runAsGroup: 33, fsGroup: 33, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed into the pod spec. uid/gid 33 is the image's www-data. The seccomp profile sits here on purpose: from the pod context it covers the init container too. |
voucherVault.securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Applied to both the init container and the app container. |
voucherVault.livenessProbe |
object | GET /:http with header Host: 127.0.0.1, period 10s, timeout 5s, 3 failures |
See What a Ready pod does not prove. |
voucherVault.readinessProbe |
object | GET /:http with header Host: 127.0.0.1, period 10s, timeout 5s, 3 failures |
|
voucherVault.startupProbe |
object | GET /:http with header Host: 127.0.0.1, period 5s, timeout 5s, 30 failures (= 150s) |
Gates liveness and readiness. The budget is also the deadline for a Postgres that never answers. |
voucherVault.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
voucherVault.resources.requests |
object | cpu: 0.1, memory: 256Mi, ephemeral-storage: 128Mi |
The static-files emptyDir (~90Mi of assets) counts toward the pod's ephemeral-storage usage, so the request must cover it. |
voucherVault.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
The init container carries fixed resources of its own (requests 10m / 16Mi / 1Mi, limits 200m / 64Mi / 10Mi); they are not values.
Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply", not "no probe" or "unhardened container".
storage
| Key | Type | Default | Description |
|---|---|---|---|
storage.amount |
string | 10Gi |
Size of the data PVC. |
storage.storageClass |
string | null |
Storage class of the data PVC; null uses the cluster default. |
Deliberately not null-safe: an erased storage block yields an invalid PVC rather than a silently degraded workload.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Gates the Ingress object. Mutually exclusive with httpRoute.enabled. Switching it off makes clusterIssuer unnecessary — but not domain. |
ingress.domain |
string | null |
Required, always — also with ingress.enabled: false. It is the Ingress host and Django's DOMAIN: the only ALLOWED_HOSTS entry besides 127.0.0.1, and the base of CSRF_TRUSTED_ORIGINS. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. |
ingress.className |
string | nginx |
Ingress class. |
ingress.annotations |
object | {} |
Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode, and nothing this chart needs lives here. |
TLS is stored in tls-<release>-voucher-vault-ingress.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 1 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>-voucher-vault-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 — which is how a migration off the Ingress runs. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-voucher-vault-service on port 80. Django serves the UI, /oidc/* and the admin from one process.
networkPolicy
Inbound 8000 only. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis) and 443 (the OIDC provider).
| 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.
ALLOWED_HOSTS does not follow the route, and the probes cannot tell you. Django's DOMAIN is built from ingress.domain, and that is the only name besides 127.0.0.1 it accepts. A hostname the HTTPRoute publishes which ingress.domain does not name is answered with 400 before a single view runs — and all three probes stay green while it happens, because they deliberately carry a Host header of 127.0.0.1 so that probing survives every domain change. So httpRoute.hostnames and ingress.domain have to be set together; a migration over a second hostname is not a route-only change here.
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>-voucher-vault-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.
voucherVault.env is the complete environment, not an additive block
This is the chart's sharpest deviation from style guide §10. For this chart voucherVault.env is the container's whole env list: a values file that sets it overwrites everything, including the entries that wire up the three Secrets. NOTES.txt checks for DOMAIN, SECRET_KEY, REDIS_URL, DB_ENGINE, POSTGRES_HOST and POSTGRES_DB after every install or upgrade and prints which of them went missing — but the render itself succeeds, so read it. To change one entry, copy the full list out of the chart's values.yaml and edit the copy.
What the default list contains:
| Group | Entries |
|---|---|
| Host | DOMAIN (from ingress.domain, via required), PORT=8000 |
| Session | SECURE_COOKIES=True, SESSION_COOKIE_AGE=30, SESSION_EXPIRE_AT_BROWSER_CLOSE=True |
| Expiry | EXPIRY_THRESHOLD_DAYS=90, EXPIRY_LAST_NOTIFICATION_DAYS=7, TZ=UTC |
| Celery | CELERY_WORKER_CONCURRENCY=1, CELERY_WORKER_PREFETCH_MULTIPLIER=1, REDIS_URL (secret) |
| OIDC | OIDC_ENABLED=True, OIDC_AUTOLOGIN=True, OIDC_CREATE_USER=True, OIDC_RP_SIGN_ALGO=RS256, plus six secret-backed endpoints/credentials |
| Database | DB_ENGINE=postgres, POSTGRES_HOST/PORT/USER/PASSWORD/DB (secret) |
| Django | SECRET_KEY (secret) |
SESSION_COOKIE_AGE is in minutes, not seconds — the app multiplies it by 60. SECURE_COOKIES=True means session and CSRF cookies carry the Secure flag; browsers accept those from 127.0.0.1 but from no other plain-http host, so a login that silently bounces back to the form over http is this, not a wrong password.
ingress.domain is not an ingress-only value
Django's ALLOWED_HOSTS holds exactly 127.0.0.1 and this domain, and CSRF_TRUSTED_ORIGINS is built from it. Under any other name the app answers 400 before a single view runs. That is why the value stays required when the Ingress is switched off, and why a port-forward has to look exactly like this:
kubectl -n <namespace> port-forward svc/<release>-voucher-vault-service 8000:80
Keep 8000 as the local port and open http://127.0.0.1:8000/ — not http://localhost:8000/. localhost is not in ALLOWED_HOSTS and answers 400; CSRF_TRUSTED_ORIGINS holds exactly http://127.0.0.1:8000, so any other local port fails every POST including the login form.
What a Ready pod does not prove
The probes send Host: 127.0.0.1 on purpose: that host is always in ALLOWED_HOSTS, so probing survives every domain change. The price is that a wrong ingress.domain is invisible to Kubernetes — all three probes pass, the rollout goes green, and every real request gets 400. Check against the domain instead:
curl -sI https://vouchers.example.com/ | head -1
A 302 to a language-prefixed path is the healthy answer; 400 means the Host does not match.
Celery is the second blind spot: worker and beat are started detached from the same entrypoint, so uwsgi answers whether or not they came up.
The first start prints the only admin password it will ever print
Against an empty database the entrypoint creates the superuser admin with a randomly generated password and writes it to stdout — once, never again, and into no Secret:
kubectl -n <namespace> logs <release>-voucher-vault-sfs-0 | grep -A3 "Creating superuser"
If that line has already scrolled out of the log, reset it in place:
kubectl -n <namespace> exec -it <release>-voucher-vault-sfs-0 -- python manage.py changepassword admin
With OIDC enabled the app drops its password-change pages and the login form redirects to the provider on its own (OIDC_AUTOLOGIN). Django's own admin login at /admin/ is not covered by that redirect — that is the way back in when the provider is down or its item is wrong.
A pod that never starts is usually waiting for Postgres
Before it opens the HTTP port, the entrypoint loops on nc -z $POSTGRES_HOST $POSTGRES_PORT with no timeout. A wrong host or port in the database item therefore produces no error at all — the log simply stops at
[TASK] Waiting for PostgreSQL db...
until the startup probe kills the container after 150s, which surfaces as CrashLoopBackOff.
The other candidate is one line earlier: [FAIL] Insufficient permissions on bind mount volume, when /opt/app/database is not writable for uid/gid 33. fsGroup: 33 with fsGroupChangePolicy: Always covers that on any storage class that honours fsGroup.
Read-only root filesystem, and the static-files init container
The image runs as www-data (uid/gid 33) and writes at runtime to /tmp (upload and PDF scratch files), /opt/app/logs and STATIC_ROOT (/opt/app/myapp/static, since the entrypoint runs collectstatic at every start). All three are emptyDirs, which is what makes readOnlyRootFilesystem: true possible.
STATIC_ROOT doubles as the app's own static source directory, so it cannot simply be shadowed by an empty volume. The init-static init container seeds the emptyDir with the assets shipped in the image (cp -a of the children, not of ., so cp never tries to set metadata on a mountpoint the non-root uid may not own; preserved timestamps mean collectstatic only adds what is missing, e.g. the Django admin assets).
Uploads and everything else durable live on the PVC at /opt/app/database.
Single-instance by construction
Uploaded files land on the one ReadWriteOnce PVC <release>-voucher-vault-pvc, which every pod of the StatefulSet mounts at the same path — and each pod runs its own Celery beat. Raising voucherVault.replicas above 1 therefore buys nothing: further pods stay Pending on a node that does not hold the volume, and where they do start they send every expiry notification a second time.
Expiry notifications stay silent until a user adds an Apprise URL
EXPIRY_THRESHOLD_DAYS and EXPIRY_LAST_NOTIFICATION_DAYS decide when an item counts as expiring, not whether anyone hears about it: delivery runs over the Apprise URLs each user stores in their own profile, and a profile without one is skipped without a word. The "Periodic Expiry Check" schedule runs daily at 09:00 in the container's time zone (TZ) and is created only while an empty database is initialized — delete it in the admin and no later start brings it back.
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 pods; editing a value inside the same item does not. After such a rotation:
kubectl -n <namespace> rollout restart statefulset/<release>-voucher-vault-sfs
Rotating secretKey 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.
All three of this chart's dependencies arrive that way, so all three ports are conventions rather than configuration.
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
- Upgrades migrate, rollbacks do not unmigrate. Every single start runs
makemigrationsandmigratebefore the server comes up, so an app-version bump changes the schema the moment the new pod boots.helm rollbackputs the old image back but leaves the migrated schema in place, and the old code then meets columns it does not know. - Back up both halves, together. The rows live in Postgres, the uploads on the PVC. Because the PVC is a chart object,
helm uninstalldeletes it — the external database survives, which makes an accidental uninstall look recoverable while the uploads are already gone. - Raise
voucherVault.startupProbe.failureThresholdbefore a large migration, not after the crash loop has started.
CI
ci/voucher-vault/test-values.yaml holds the three op:// refs (against the stub CRD) and vv.ci.local with issuer ci. ci/voucher-vault/fixtures.yaml stands up throwaway Postgres and Redis, a dex OIDC provider with a static client, and the three Secrets the 1Password operator would otherwise materialize — so the install-test exercises the full OIDC wiring, not just the render. 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-test installs 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 (ci/gateway-api-crds.sh, pinned, applied --server-side), which is where a malformed parentRefs or backendRefs is caught. Spending it a second time here would have dropped this chart's Ingress from the install for a schema the repository already exercises. Instead ci/httproute/voucher-vault.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 CI/CD.
See also
- Chart style guide — the rules this chart implements, and the env-block deviation it carries
- Versioning — what a bump of
6.1.1+up1.30.2means - Renovate — how the app version is tracked
- 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