4
rallly
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ClusterIssuer named in ingress.clusterIssuer, and DNS for ingress.domain pointing at the ingress controller.
  • An SMTP server only if rallly.smtp.enabled is true; 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 uninstall loses nothing but the Kubernetes objects — and a database backup is the only backup there is. Take one before an app-version bump: a helm rollback returns the old image, never the old database.
  • Rotating secret-key signs 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