Table of Contents
- rallly
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- Sign-in goes through the provider, and only through the provider
- ALLOWED_EMAILS: '*' is an open door
- Changing the domain later is a change on two sides
- Mail is off by default, and nothing says so
- Rotating an item in 1Password restarts nothing
- What a green rollout does not prove
- The cache volume counts against the ephemeral-storage limit
- Why capabilities.add is absent
- The SMTP rule is derived from rallly.smtp.enabled
- 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
rallly
Deploys Rallly, a self-hosted scheduling and poll app, as a stateless Deployment. Postgres is not part of this chart, and neither is any persistent volume: the only writable path is an emptyDir over the Next.js cache. Sign-in goes exclusively through an OIDC provider.
| Image | lukevella/rallly |
| Versions | not listed on purpose — read the current pair off rallly/Chart.yaml, see Chart index |
| Workloads | Deployment (1 replica) |
| Storage | none persistent — an emptyDir mounted over the Next.js cache directory |
| Routing | two mutually exclusive modes. Ingress (default): ingress.domain + ingress.clusterIssuer. Gateway API: httpRoute.enabled with httpRoute.parentRefs — see Routing: Ingress or Gateway API |
| External dependencies | Postgres, an OIDC provider, optionally an SMTP server |
| Secrets | 3 × OnePasswordItem, plus a 4th when SMTP is enabled |
| Install-test | ci/rallly/ (fixtures + test values) |
Prerequisites
- 1Password operator in the cluster, plus three (or four) 1Password items — see the fields below.
- Postgres reachable before the first start. The chart brings no database.
- An OIDC client whose redirect URI is
https://<ingress.domain>/api/auth/callback/oidc. There is no fallback login — see Sign-in goes through the provider. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. - An SMTP server only if
rallly.smtp.enabledistrue; otherwise mail is off — see Mail is off by default.
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 rallly jlab/rallly \
--namespace rallly --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
ralllySecretRef: op://vault/rallly
oidcSecretRef: op://vault/rallly-oidc
databaseSecretRef: op://vault/rallly-db
rallly:
supportEmail: support@example.com
ingress:
domain: poll.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 also NEXT_PUBLIC_BASE_URL and the base of the OIDC redirect URI. 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 OnePasswordItem CRs; the operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item. Every field is wired as a plain secretKeyRef without optional: true, so 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. The key has to exist; an empty value is accepted, but the app then sees an empty string rather than its own default.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.ralllySecretRef |
<release>-rallly-secret |
secret-key (SECRET_PASSWORD, at least 32 characters; it encrypts the sessions, so changing it signs everyone out), initial-admin-email (INITIAL_ADMIN_EMAIL) |
secrets.databaseSecretRef |
<release>-rallly-database-secret |
database-url — postgres://user:password@host:5432/database |
secrets.oidcSecretRef |
<release>-rallly-oidc-secret |
discovery-url (the provider's .well-known/openid-configuration), client-id, client-secret |
secrets.smtpSecretRef (only with rallly.smtp.enabled: true) |
<release>-rallly-smtp-secret |
host, port, secure, user, password, no-reply-email, no-reply-email-name, tls-servername |
All eight SMTP fields are required here, including the three that are optional upstream (port, secure, tls-servername) and no-reply-email, which upstream would default to SUPPORT_EMAIL. An item carrying only host, user and password wedges the pod before it starts.
The discovery URL is fetched by the pod, not by your browser: an IdP that only resolves outside the cluster, or one behind a policy this namespace cannot pass, fails here while looking perfectly reachable from your desk.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the Deployment with replicas: 0 without uninstalling. Takes precedence over rallly.replicas. Secrets, Service and Ingress (with its certificate) stay. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.ralllySecretRef |
string | null |
Required. op:// path of the app item (session key + initial admin). |
secrets.oidcSecretRef |
string | null |
Required. op:// path of the OIDC item. |
secrets.databaseSecretRef |
string | null |
Required. op:// path of the Postgres item. |
secrets.smtpSecretRef |
string | null |
Required while rallly.smtp.enabled is true; with SMTP off no OnePasswordItem is rendered and the value is unused. |
rallly
| Key | Type | Default | Description |
|---|---|---|---|
rallly.replicas |
int | 1 |
Replica count; 0 is allowed. |
rallly.smtp.enabled |
bool | false |
Renders the SMTP OnePasswordItem and the SMTP_*/NOREPLY_* variables. Off means the app sends nothing — see Mail is off by default. |
rallly.smtp.rejectUnauthorized |
bool | true |
SMTP_REJECT_UNAUTHORIZED. Leave it at true. false disables the mail server's certificate check entirely and opens the SMTP session to a man-in-the-middle; it is the setting for a self-signed certificate only. Strings are rejected by the schema, and an explicit false is not swallowed by a default (#109). |
rallly.allowedEmails |
string | '*' |
ALLOWED_EMAILS, comma-separated, wildcards supported (*@example.com). See * is an open door. |
rallly.supportEmail |
string | null |
Required. SUPPORT_EMAIL; the render fails without it, independently of the Ingress. |
rallly.oidc.name |
string | OIDC |
OIDC_NAME — the label of the login button. |
rallly.oidc.pictureClaimPath |
string | picture |
Claim the app reads the avatar from. |
rallly.oidc.emailClaimPath |
string | email |
Claim the app reads the address from; it is also what initial-admin-email has to match. |
rallly.oidc.nameClaimPath |
string | name |
Claim the app reads the display name from. A provider that nests these elsewhere answers with a user who has no name or no address — that is these three values, not the provider. |
rallly.cache.path |
string | /app/apps/web/.next/cache |
Required (the template uses required()), mount path of the writable cache volume. Matches the Next.js cache directory of the upstream image; only needs changing if the image layout changes. |
rallly.cache.sizeLimit |
string/number | null |
Optional upper bound for the emptyDir, e.g. 512Mi. Unset means it is bounded only by the container's ephemeral-storage limit. |
rallly.securityContext.pod |
object | non-root 20000:1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed into the pod spec. The fsGroup is what makes the mounted cache volume group-writable for the container. The seccomp profile sits here on purpose: it covers every container including init containers. |
rallly.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
The app is a plain Node process on the unprivileged port 3000, verified to run with every capability dropped. There is deliberately no add: key — see Why capabilities.add is absent. |
rallly.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. |
rallly.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
rallly.resources.requests |
object | cpu: 0.1, memory: 512Mi, ephemeral-storage: 100Mi |
|
rallly.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
rallly.livenessProbe |
object | GET / on port http, period 10s, timeout 5s, 3 failures |
See What a green rollout does not prove. |
rallly.readinessProbe |
object | GET / on port http, period 10s, timeout 5s, 3 failures |
|
rallly.startupProbe |
object | GET / on port http, period 5s, 30 failures (= 150s) |
Gates liveness and readiness, so neither needs an initialDelaySeconds. |
Probe, securityContext and resources blocks may be erased (null) — the chart then falls back to exactly these defaults instead of rendering an unhardened, unprobed or unaccounted container. rallly.cache is deliberately not null-safe: cache.path is required(), so an erased block fails loudly.
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Switches the Ingress object off. ingress.domain stays required — it is also the app's NEXT_PUBLIC_BASE_URL. Mutually exclusive with httpRoute.enabled. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. |
ingress.domain |
string | null |
Required. Ingress host, public base URL and the base of the OIDC redirect URI in one value. |
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 by default. |
TLS is stored in tls-<release>-rallly-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>-rallly-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>-rallly-service on port 80. The Next.js server serves the app, its API routes and /api/auth/* from one process.
networkPolicy
Inbound 3000 only. Outbound DNS, TCP 5432 (Postgres), 443 (the OIDC provider) and — only while rallly.smtp.enabled is true — 587 and 465 for mail submission.
| 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.
The hostname is not a routing detail here, in either mode. NEXT_PUBLIC_BASE_URL follows ingress.domain and not httpRoute.hostnames, and the OIDC redirect URI registered at the provider is built from it. So a Gateway-mode release published under a name ingress.domain does not carry gives you an instance whose every link and whose sign-in leave for the other host — and with httpRoute.hostnames set for a migration there are three sides to keep in agreement: the provider, the route, and ingress.domain.
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>-rallly-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.
Sign-in goes through the provider, and only through the provider
The chart sets EMAIL_LOGIN_ENABLED and REGISTRATION_ENABLED to "false" in the template, not through a value: there is no magic-link login and no self-registration to fall back on. Every account arrives through the OIDC provider. A wrong OIDC item therefore does not degrade the login, it removes it — including your own way back in.
Admin rights are claimed by INITIAL_ADMIN_EMAIL from the app item. It has to match the address the provider returns in the email claim exactly — nothing reconciles the two, and a mismatch simply leaves the instance without an admin.
ALLOWED_EMAILS: '*' is an open door
With the default every account the provider authenticates may sign in, so the only gate in front of this instance is the provider's own audience. With a broad IdP that is an open door. Narrow it with a comma-separated list; wildcards are supported. Note where the symptom then appears: an address outside the list is rejected after a successful login at the provider, so the provider reports success and Rallly still refuses.
Changing the domain later is a change on two sides
ingress.domain is the Ingress host, the app's NEXT_PUBLIC_BASE_URL and therefore the OIDC redirect URI in one value. Editing it here alone breaks the login: the provider refuses a callback it was never told about, and it is the provider that reports it — Kubernetes reports a healthy pod and a valid certificate for the new name. Register the new redirect URI first, then upgrade.
The same value is why ingress.domain stays required with the Ingress switched off: a port-forward reaches the app, but every generated link and every sign-in still leaves for https://<ingress.domain>.
Mail is off by default, and nothing says so
With rallly.smtp.enabled: false the container receives none of the SMTP_* variables — upstream lists SMTP_HOST among the required ones. Invitations and notification mails then fail where they are sent, with a green rollout and a healthy pod either way. Sign-in is unaffected, because email login is off anyway.
Rotating an item in 1Password restarts nothing
Helm never sees a OnePasswordItem's content, so no checksum annotation can cover these Secrets and this chart carries none (style guide §9). Change a field in 1Password and the operator updates the Kubernetes Secret while the running pod keeps what it read at start — environment variables are read once. This bites hardest on database-url and the SMTP password: the app goes on using the old credentials, and nothing in the cluster says so.
kubectl -n <namespace> rollout restart deployment/<release>-rallly-deployment
What a green rollout does not prove
All three probes are the same plain HTTP GET / against the container. They establish that the web process answers and nothing beyond it: the OIDC provider, the mail server and the addresses in ALLOWED_EMAILS are exercised by the first real sign-in and the first real mail, never by a probe. Every misconfiguration listed above outlives the 150s startup budget untouched.
The cache volume counts against the ephemeral-storage limit
Next.js writes prerendered pages and the optimized images of /_next/image into its cache directory. /app belongs to the image's own nextjs user while the container runs as uid 20000 under a read-only root filesystem, so the chart mounts a writable emptyDir over that path. emptyDir usage counts against the container's ephemeral-storage limit (limitFactor × request, 300Mi by default); whichever bound is reached first — that limit or rallly.cache.sizeLimit — the kubelet evicts the pod, which reads as a restart with no application error anywhere in the log.
The cache is an emptyDir on purpose, not a PVC: its entries are derived data tied to the build id of the image, so a persistent volume would only carry stale entries across app updates. The cost is a slower first request after every restart.
Why capabilities.add is absent
The container context drops all capabilities and adds none — and the add: key is left out entirely rather than written empty. An empty list item under it renders as a null entry, which is what Trivy reported as AVD-KSV-0022 for the older chart version deployed in the cluster (#84). Do not reintroduce it.
The SMTP rule is derived from rallly.smtp.enabled
With mail switched off no SMTP rule is rendered at all. That is deliberate: a rule that permits nothing makes the object look stricter than it is, so policyTypes and the rules name only what there is something to restrict.
With it on, both conventional submission ports are allowed — 587 (STARTTLS) and 465 (implicit TLS) — because the host and port live in secrets.smtpSecretRef, which the chart never parses, so it cannot tell which one the server uses. A mail server on 25 or 2525 is blocked, and the symptom is invitations that are never delivered with a healthy pod behind them.
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.
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
- All state is in the external Postgres. The release itself owns no volume, so
helm uninstallloses nothing but the Kubernetes objects — and a database backup is the only backup there is. Take one before an app-version bump: ahelm rollbackreturns the old image, never the old database. - Rotating
secret-keysigns everyone out, because it encrypts the sessions. - Register a new OIDC redirect URI before changing
ingress.domain, not after. - App-version bumps go through a PR so that validate and install-test run — see Versioning.
CI
ci/rallly/ holds test-values.yaml (op:// refs against the stub CRD, rallly.ci.local, supportEmail, SMTP left off) and fixtures.yaml (a throwaway Postgres, a dex OIDC provider so the discovery URL actually answers, and the three Secrets the 1Password operator would otherwise materialize). 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/rallly.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
- Versioning — what a bump of
8.1.4+up4.15.1means - Renovate — how the app version gets 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