No description
  • Go Template 78.8%
  • Makefile 21.2%
Find a file
renovate[bot] aa31cae5ae
All checks were successful
Publish charts / publish (push) Successful in 26s
chore(deps): update dependency we-promise/sure to v0.7.7-alpha.2 (#49)
2026-11-04 05:02:09 +00:00
.gitea/workflows fix(previews): honor branch input on dispatch (#27) 2026-10-20 17:52:42 +00:00
charts chore(deps): update dependency we-promise/sure to v0.7.7-alpha.2 (#49) 2026-11-04 05:02:09 +00:00
.editorconfig chore: scaffold helm charts repository (#1) 2026-09-30 17:04:14 +00:00
.gitignore chore: initial commit 2026-09-30 11:34:49 -05:00
ct.yaml chore(argocd)!: remove argocd wrapper chart (#13) 2026-10-05 17:43:33 +00:00
LICENSE docs: add gpl-3.0 license (#28) 2026-10-20 18:11:35 +00:00
lintconf.yaml chore: scaffold helm charts repository (#1) 2026-09-30 17:04:14 +00:00
Makefile ci(previews): delete preview charts with branch (#26) 2026-10-20 14:39:59 +00:00
README.md fix(sure): make /tmp usable for ruby (#43) 2026-10-30 18:57:22 +00:00
renovate.json chore(sure): bump to 0.7.7-alpha.1 (#42) 2026-10-30 18:15:09 +00:00

helm

Helm charts.

Layout

charts/<name>/      one directory per chart (Chart.yaml, values.yaml, templates/)
ct.yaml             chart-testing config
lintconf.yaml       yamllint rules used by `ct lint`
Makefile            local dev tasks
.gitea/workflows/   CI

Requirements

Usage

make help                    # list targets
make new-chart NAME=myapp    # scaffold charts/myapp via `helm create`
make lint                    # helm lint --strict + helm template, all charts (once per charts/<name>/ci/*-values.yaml)
make validate                # render each chart, check manifests against Kubernetes schemas (kubeconform) and that every image tag exists (crane); CHARTS=charts/foo to limit
make ct-lint                 # chart-testing lint against main
make package                 # write .tgz files to .packages/
make publish                 # push new chart versions to the OCI registry
make publish-preview SUFFIX=pr7.abc1234   # push <version>-<suffix> pre-release builds (CHARTS=charts/foo to limit)
make clean-preview BRANCH=feat/x DRY_RUN=1   # list (or, without DRY_RUN, delete) the preview builds of PRs opened from a branch; needs PR_TOKEN and REGISTRY_TOKEN in the environment

Charts

newt

Newt, the Pangolin site tunnel client, as a wrapper around the upstream newt chart from https://charts.fossorial.io (source: fosrl/helm-charts). The upstream version is pinned in charts/newt/Chart.yaml and locked in Chart.lock.

  • Upstream options go under the newt: key in charts/newt/values.yaml (or your own values file). Reference: upstream values.
  • The upstream chart tags its image with its own appVersion, which lags Newt releases (chart 1.5.0 ships Newt 1.12.5). newt.image.tag overrides that; keep it equal to appVersion in Chart.yaml. Renovate updates both together (see Dependency updates).
  • The upstream chart refuses to render without credentials. The default newt.newtInstances list references a Secret named newt-credentials with keys PANGOLIN_ENDPOINT, NEWT_ID and NEWT_SECRET; create it before installing. Helm replaces lists instead of merging them, so an overriding newtInstances in your values file replaces this default instance entirely.
kubectl -n newt create secret generic newt-credentials \
  --from-literal=PANGOLIN_ENDPOINT=https://pangolin.example.com \
  --from-literal=NEWT_ID=<id> --from-literal=NEWT_SECRET=<secret>
helm install newt oci://git.enclaveis.com/enclave-information-systems/newt \
  --version <version> -n newt -f my-values.yaml

To upgrade Newt by hand: set newt.image.tag and appVersion to the new Newt release and bump the chart version. Check the Newt release notes for new env vars, and pass any the upstream chart lacks through newtInstances[].extraEnv. To pick up a new upstream chart, change the newt dependency version in Chart.yaml and run helm dependency update charts/newt.

pangolin

Pangolin, the tunneled reverse proxy server, as an in-repo chart (not a wrapper) with two topologies, set by deployment.type:

  • controller (default, recommended): Pangolin, Gerbil (WireGuard) and pangolin-kube-controller run as separate Deployments. The controller turns the routes Pangolin generates into Traefik IngressRoute, Middleware and TraefikService resources, served by a Traefik you install separately. This matches upstream's deployment.type=controller with deployment.mode=multi.
  • standalone: one pod running Pangolin, Gerbil and its own Traefik with the Badger plugin, mirroring upstream's docker-compose install. For labs; the three cannot scale or roll independently.

Every version is pinned to an exact release in charts/pangolin/values.yaml and Chart.yaml:

Component Pin Value
Pangolin 1.24.0 pangolin.image.tag (and appVersion)
Gerbil 1.5.2 gerbil.image.tag
pangolin-kube-controller 0.1.0-alpha.1 (the only release; alpha) controller.image.tag
Traefik (standalone) v3.7.13 traefik.image.tag
Badger plugin v1.7.0 traefik.badger.version, and experimental.plugins.badger in your Traefik
curl (wait init container) 8.22.0 waitForPangolin.image.tag
CloudNativePG PostgreSQL image 18.6-standard-trixie database.cnpg.image
CloudNativePG operator chart 0.29.1 (operator 1.30.1) Chart.yaml dependency, Chart.lock
Traefik chart (controller mode, yours) 41.6.1 examples/traefik-values.yaml

Controller mode

Prerequisites, installed before the chart:

  1. The CloudNativePG operator (cluster-wide; its webhook rejects Cluster resources until it runs).
  2. Traefik v3 with the CRD provider, the Badger plugin, entrypoints web/websecure and an ACME resolver named letsencrypt. charts/pangolin/examples/traefik-values.yaml has the values, including three settings that are easy to miss: fsGroup (otherwise Traefik cannot write acme.json), a maxSurge: 0 update strategy (otherwise a ReadWriteOnce volume deadlocks every upgrade) and allowCrossNamespace: true (see below).
  3. A namespace that allows the privileged Pod Security level, because Gerbil needs NET_ADMIN.
helm install cnpg cloudnative-pg --repo https://cloudnative-pg.github.io/charts \
  --version 0.29.1 -n cnpg-system --create-namespace
helm install traefik traefik --repo https://traefik.github.io/charts --version 41.6.1 \
  -n traefik --create-namespace -f charts/pangolin/examples/traefik-values.yaml \
  --set certificatesResolvers.letsencrypt.acme.email=you@example.com
kubectl create ns pangolin
kubectl label ns pangolin pod-security.kubernetes.io/enforce=privileged
helm install pangolin oci://git.enclaveis.com/enclave-information-systems/pangolin \
  --version <version> -n pangolin -f my-values.yaml

my-values.yaml needs domain.dashboard and domain.base, plus gerbil.baseEndpoint: WireGuard peers connect to Gerbil, not Traefik, so it must name an address that reaches the <release>-gerbil-wireguard Service (UDP 51820 and 21820 by default). Point domain.dashboard and *.<domain.base> at Traefik's load balancer.

Behavior worth knowing:

  • Pangolin's resource targets must be Kubernetes Service hostnames (name.namespace.svc). The controller converts those into Service references; a raw IP target cannot be expressed as a TraefikService and produces an empty, non-working route (observed with 0.1.0-alpha.1). Because the Services are usually in other namespaces, Traefik needs providers.kubernetesCRD.allowCrossNamespace: true, which lets a Pangolin admin route to any Service in the cluster.
  • The chart sets server.internal_hostname to the Pangolin Service FQDN, which Pangolin writes into the Badger middleware's apiBaseUrl; Traefik is in another pod, so the default (pangolin) would not resolve.
  • flags.enable_acme_cert_sync is turned off: Pangolin would read Traefik's acme.json from its own filesystem, which is another pod. Set it under pangolin.config.flags to override.
  • The controller reaches Pangolin over plain HTTP inside the cluster (CONFIG_ALLOW_INSECURE_HTTP) and logs a warning every poll. networkPolicy (on by default) restricts Pangolin's unauthenticated internal API (3001) and Gerbil's (3004) to Traefik, Gerbil and the controller, and needs a CNI that enforces NetworkPolicy. networkPolicy.traefikNamespaceSelector must match the namespace Traefik runs in.
  • Pangolin and Gerbil never mount a ServiceAccount token; only the controller does, with a Role in controller.targetNamespace plus read access to IngressClass and CRDs. Gerbil runs with only NET_ADMIN. The controller is non-root with a read-only root filesystem. Pangolin's image runs as root, so it keeps a writable filesystem but drops all capabilities.
  • Gerbil's WireGuard interface listens on pangolin.config.gerbil.start_port. Its relay always listens on 21820 in the pod; clients_start_port is only the public port Pangolin gives clients, and the Service maps it onto 21820.
  • The Gerbil Service is separate from the in-cluster API Service so a UDP-only load balancer is enough. Not every load balancer carries UDP: Linode NodeBalancers only support it on premium types in beta. gerbil.service.type: NodePort (with both ports in the node port range) exposes it on the nodes' addresses instead.
  • Pangolin's /app/config is an emptyDir with PostgreSQL; only Gerbil's WireGuard key needs a volume (gerbil.persistence). Volumes are helm.sh/resource-policy: keep; use a StorageClass with reclaimPolicy: Delete for throwaway installs, or delete the PVCs yourself.

Standalone mode

helm install pangolin oci://git.enclaveis.com/enclave-information-systems/pangolin \
  --version <version> -n pangolin --create-namespace \
  --set deployment.type=standalone --set database.mode=sqlite \
  --set domain.dashboard=pangolin.example.com --set domain.base=example.com \
  --set traefik.letsEncrypt.email=admin@example.com

traefik.letsEncrypt.email is required here. The Service carries 80/tcp, 443/tcp, 443/udp, 51820/udp and 21820/udp, so it needs a LoadBalancer that supports mixed protocols (externalTrafficPolicy: Local keeps client IPs). One volume (persistence) holds the Gerbil key, the SQLite database and Traefik's certificates, so the Deployment is single-replica with Recreate. Traefik downloads Badger from GitHub at startup, so the pod needs outbound HTTPS. In this mode server.internal_hostname is 127.0.0.1, since Badger and Pangolin share a network namespace.

Both modes

  • pangolin.config is rendered to config.yml; the chart derives dashboard URL, base domain, CORS origin and internal_hostname from other values, and anything you set wins. The server secret is read from the SERVER_SECRET env var, from pangolin.secret.existingSecret or from a chart-created Secret with a random value that survives upgrades and is kept on uninstall (back it up; the database is unreadable without it).
  • database.mode is cnpg (default, recommended) or sqlite (development only). cnpg renders a CloudNativePG Cluster named <fullname>-db (3 instances, 10Gi each) and switches Pangolin to its postgresql-<version> image, with POSTGRES_CONNECTION_STRING taken from the <fullname>-db-app Secret CloudNativePG generates. database.cnpg.extraSpec is merged into the Cluster spec: use it for backups, affinity and parameters. Backups are not configured by default.
  • cloudnative-pg.enabled=true installs the operator as this chart's subchart. The operator pod is not ready when Helm creates the Cluster in the same release, so the webhook can reject it; prefer the standalone operator. Switching an existing install from sqlite to cnpg does not migrate data.
  • Pangolin runs a single replica in both modes.

Tested on Linode LKE (Kubernetes 1.36) with chart 41.6.1 of Traefik and 0.29.1 of the CloudNativePG operator: controller mode served the dashboard over HTTPS with a Let's Encrypt (staging) certificate, the controller created IngressRoutes for a resource targeting a Service, Badger enforced SSO, and traffic reached the backend once SSO was disabled. Not tested: a Newt site and WireGuard tunnel, CloudNativePG backups, and upgrades from an earlier chart version.

To upgrade: set the new release in pangolin.image.tag, gerbil.image.tag, controller.image.tag, traefik.image.tag or traefik.badger.version, set appVersion to the Pangolin release, check the release notes for config changes, and bump the chart version. For the operator, change the cloudnative-pg dependency version in Chart.yaml, run helm dependency update charts/pangolin, and update cloudnative-pg= in ct.yaml only if the repository changes.

rxresume

Reactive Resume, the self-hosted resume builder, as an in-repo chart. Since v5.1.0 PDFs are rendered in the browser, so the only hard dependency is PostgreSQL; there is no Chromium or Browserless service to run.

Component Pin Value
Reactive Resume v5.3.2 image.tag (and appVersion)
CloudNativePG PostgreSQL image 18.6-standard-trixie database.cnpg.image

appUrl is required: it becomes APP_URL, which must equal the URL users open or sign-in redirects fail.

helm install rxresume oci://git.enclaveis.com/enclave-information-systems/rxresume \
  --version <version> -n rxresume --create-namespace \
  --set appUrl=https://resume.example.com --set ingress.enabled=true
  • database.mode is cnpg (default, recommended) or external. cnpg renders a CloudNativePG Cluster named <fullname>-db (3 instances, 10Gi each) and takes DATABASE_URL from the <fullname>-db-app Secret CloudNativePG generates; the operator must already be installed. The app runs its migrations on start and exits if the database is unreachable, so on a fresh install the pod restarts a few times until PostgreSQL is up. external reads DATABASE_URL from database.external.existingSecret. database.cnpg.extraSpec is merged into the Cluster spec: use it for backups, affinity and parameters. Backups are not configured by default; the database and the uploads are the two things to back up.
  • storage.type is local (default) or s3. local mounts a PersistentVolumeClaim at /app/data (helm.sh/resource-policy: keep, so uninstall leaves it). s3 takes the endpoint, region, bucket and forcePathStyle from storage.s3 and the credentials from storage.s3.existingSecret (keys S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY by default). Garage and MinIO need forcePathStyle: true.
  • AUTH_SECRET and ENCRYPTION_SECRET come from secret.existingSecret or from a chart-created Secret with random values that survive upgrades and are kept on uninstall. Back it up: changing AUTH_SECRET signs everyone out, and losing ENCRYPTION_SECRET makes saved AI provider credentials unreadable. A Secret you provide may omit ENCRYPTION_SECRET.
  • Everything else is an environment variable: plain ones under config (feature flags such as FLAG_DISABLE_SIGNUPS, ROOT_RESUME_ID, SMTP_HOST), and anything secret under extraEnv with a secretKeyRef (SMTP_USER, SMTP_PASS, OAuth client secrets, REDIS_URL). See the upstream reference. Without all of SMTP_HOST, SMTP_USER, SMTP_PASS and SMTP_FROM the app logs emails to its console instead of sending them.
  • The pod is non-root (uid 1000) with a read-only root filesystem and all capabilities dropped; /tmp and /app/data are the only writable paths. Readiness uses /api/health, which checks the database, the storage and, when REDIS_URL is set, Redis, so a Redis outage makes every pod unready. Liveness is a TCP check so a dependency outage does not restart every pod.
  • One replica by default. With storage.type=local and a ReadWriteOnce volume the Deployment uses Recreate, and more than one replica is rejected unless the volume is ReadWriteMany. Several replicas should share a Redis (Valkey works): put REDIS_URL in extraEnv from a Secret and set DEPLOYMENT_NAMESPACE under config. With REDIS_URL set, /api/health also reports Redis, and the auth rate limits are stored there under reactive-resume:<DEPLOYMENT_NAMESPACE>:.
  • Sign-in with an OpenID Connect provider: set OAUTH_PROVIDER_NAME and OAUTH_DISCOVERY_URL under config, and OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET in extraEnv from a Secret. Register the redirect URI <appUrl>/api/auth/callback/custom exactly, as a confidential client with the scopes openid profile email; the user needs an email address. The app signs users up on first login unless FLAG_DISABLE_SIGNUPS is "true".
  • Rate limiting is keyed by client IP, read from CF-Connecting-IP, True-Client-IP, X-Forwarded-For or X-Real-IP. Without one of those headers (for example through kubectl port-forward) every client shares one bucket and the app logs a warning. Put the app behind a proxy that sets X-Forwarded-For.
  • ingress renders a plain networking.k8s.io/v1 Ingress for the host in appUrl; the chart does not create Gateway API or Traefik resources.

Tested on a Talos cluster (Kubernetes 1.37, Longhorn, CloudNativePG operator already installed): the cnpg + local topology came up healthy and served sign-up; switching the release to storage.type=s3 against Garage reported valid credentials on /api/health; the generated Secret was unchanged by an upgrade; with a password-protected Valkey 9.1.2, /api/health reported Redis healthy and the rate-limit keys landed in it; and an Authentik OpenID Connect login through kubectl port-forward created the user and its custom account. Not tested: database.mode=external, the Ingress, more than one replica, a proxy-supplied client IP, SMTP, uploads and downloads of actual files, CloudNativePG backups, and upgrades from an earlier chart version.

To upgrade: set the new release in image.tag and appVersion, read the release notes for new or removed environment variables, and bump the chart version. Back up the database and uploads first; migrations run automatically on start.

searxng

SearXNG, the privacy-respecting metasearch engine, as an in-repo chart (there is no upstream chart to wrap). The chart renders SearXNG's three config files from values and passes everything else through, so every SearXNG setting can be configured without editing the chart.

Component Pin Value
SearXNG 2026.10.4-d48c4b555 image.tag (and appVersion)

The defaults make a public instance: settings.server.limiter and settings.server.public_instance are true, and SearXNG refuses to start with the limiter unless it has a Valkey. The chart does not deploy Valkey; give it a connection URL in a Secret.

kubectl -n searxng create secret generic searxng-valkey \
  --from-literal=SEARXNG_VALKEY_URL='valkey://default:<password>@valkey.searxng.svc.cluster.local:6379/0'
helm install searxng oci://git.enclaveis.com/enclave-information-systems/searxng \
  --version <version> -n searxng \
  --set baseUrl=https://search.example.com --set valkey.existingSecret=searxng-valkey

For a private instance set settings.server.limiter=false and settings.server.public_instance=false; then neither baseUrl nor Valkey is needed.

What configures what:

  • settings is rendered verbatim as settings.yml, so any key in the settings reference works: engines, categories, plugins, outgoing proxies, ui, search, valkey. use_default_settings: true keeps every default engine and setting you do not list; make it a map (engines: {keep_only: [...]}) to prune them. Helm replaces lists instead of merging them, so an overriding list such as engines replaces the chart's. server.secret_key is better left to the Secret below.

  • limiter is a string rendered as limiter.toml (bot detection, ip_lists, rate-limit prefixes). favicons is a string rendered as favicons.toml; its default equals SearXNG's packaged one and is always mounted, because SearXNG logs an error when search.favicon_resolver is set and the file is missing.

  • config (plain) and extraEnv (full entries, for secretKeyRef) set environment variables, which override settings.yml. SearXNG reads SEARXNG_DEBUG, SEARXNG_LIMITER, SEARXNG_PUBLIC_INSTANCE, SEARXNG_IMAGE_PROXY, SEARXNG_METHOD, SEARXNG_BIND_ADDRESS, SEARXNG_PORT, SEARXNG_BASE_URL, SEARXNG_SECRET and SEARXNG_VALKEY_URL; the server is Granian, tuned with GRANIAN_*. The container is expected to listen on 8080: the probes and the Service target it.

  • extraVolumes, extraVolumeMounts, extraContainers and extraInitContainers cover what a settings file cannot: a custom theme, a favicon cache on a PersistentVolumeClaim, a proxy sidecar (Tor, for example) that outgoing.proxies points at on 127.0.0.1. ui.templates_path and ui.static_path replace SearXNG's own directories entirely, so a mounted theme must be a complete copy, every theme included, and the directories must exist at start.

  • baseUrl is optional and becomes SEARXNG_BASE_URL.

  • server.secret_key comes from secret.existingSecret or from a chart-created Secret with a random value that survives upgrades and is kept on uninstall (lookup finds nothing in a render without cluster access, such as Argo CD, so pass an existing Secret there). Changing it invalidates signed image proxy URLs and the limiter's Valkey entries.

  • The chart refuses to render when the limiter or public_instance is on and no Valkey URL is configured. Any of valkey.existingSecret, SEARXNG_VALKEY_URL in config or extraEnv, or settings.valkey.url satisfies it.

  • The limiter believes X-Forwarded-For and X-Real-IP only from the networks in limiter.toml's botdetection.trusted_proxies; list every proxy hop in front of the pod. Without a client address header, requests to /search are rejected with 429. It also expects browser behaviour (the link-token stylesheet, Sec-Fetch-*, Accept-* headers), so curl against a public instance gets 429 or a redirect to /. The JSON API (search.formats must list json; the default is HTML only) is limited to 4 requests per hour per address under the limiter.

  • The pod is non-root (uid 977) with a read-only root filesystem and all capabilities dropped; /tmp and /var/cache/searxng are the writable paths, and the three config files are mounted read-only, so a values change rolls the pods through a checksum annotation. /healthz serves the startup, readiness and liveness probes and does not touch Valkey. No Ingress or Gateway API resource is created; the Service listens on port 80.

Tested with podman against the pod the chart renders (same environment, uid 977, read-only root filesystem, dropped capabilities, config files mounted read-only), not on a cluster: the default public instance with Valkey started, passed a browser-like sequence (page, link-token stylesheet, search with Sec-Fetch-*) and returned results, and exited without Valkey; a private instance with no baseUrl and no Valkey started and returned 20 JSON results with json in search.formats; SearXNG loaded the mounted favicons.toml (cache path changed) and logged the missing-file error when the file was absent. Rendered and schema-checked only, never run: extraContainers, extraInitContainers, extraVolumes on a real volume, outgoing.proxies through a sidecar, a custom theme, Secret reuse across upgrades, probes, and Kubernetes fsGroup handling (SearXNG only warns when /var/cache/searxng is not owned by searxng:searxng).

To upgrade: set the new release in image.tag and appVersion (tags are YYYY.M.D-<sha>), read the release notes, and bump the chart version. The limiter and favicons defaults copy SearXNG's own files, so compare them with the new release's limiter.toml and favicons.toml.

sure

Sure, the self-hosted personal finance app (a community fork of Maybe Finance), as an in-repo chart. Upstream ships its own chart in the same repository; this one is separate and does not take its values. It runs four things: the Rails server (<fullname>-web), the Sidekiq worker (<fullname>-worker), a single Valkey (<fullname>-valkey) and a CloudNativePG Cluster (<fullname>-db).

Component Pin Value
Sure 0.7.7-alpha.1 image.tag (and appVersion)
Valkey 9.1.3-alpine redis.valkey.image
CloudNativePG PostgreSQL image 18.6-standard-trixie database.cnpg.image

appUrl is required. It becomes APP_DOMAIN (links in emails) and WEBAUTHN_ALLOWED_ORIGINS (passkeys), and its scheme sets RAILS_FORCE_SSL and RAILS_ASSUME_SSL: https assumes TLS ends in front of the pod and marks cookies secure, http (a kubectl port-forward, for example) turns both off, because sign-in does not work over plain HTTP with secure cookies.

helm install sure oci://git.enclaveis.com/enclave-information-systems/sure \
  --version <version> -n sure --create-namespace \
  --set appUrl=https://sure.example.com --set ingress.enabled=true
  • database.mode is cnpg (default, recommended) or external. cnpg renders a CloudNativePG Cluster (3 instances, 10Gi each) and passes its generated <fullname>-db-app Secret to Sure as DB_HOST, DB_PORT, POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB; the operator must already be installed. external takes database.external.host, port, database and username as values and the password from database.external.existingSecret. Sure has no connection-string setting for TLS; set PGSSLMODE under config for a managed database. database.cnpg.extraSpec is merged into the Cluster spec: use it for backups, affinity and parameters. Backups are not configured by default; the database and the uploads are the two things to back up.
  • redis.mode is valkey (default) or external. valkey runs one password-protected Valkey pod whose password lives in a Secret that survives upgrades and is kept on uninstall, with a 1Gi volume so queued jobs survive a restart. external reads REDIS_URL (for example redis://:PASSWORD@host:6379/1, or rediss://) from redis.external.existingSecret. Sure cannot run without it: Sidekiq keeps its queues and its schedule there.
  • storage.type is local (default) or s3. local mounts a PersistentVolumeClaim at /rails/storage in both the web and the worker pod (helm.sh/resource-policy: keep, so uninstall leaves it). s3 selects Active Storage's generic_s3 service: endpoint (empty for AWS), region, bucket and forcePathStyle from storage.s3, credentials from storage.s3.existingSecret (keys S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY by default). Garage and MinIO need forcePathStyle: true.
  • With a ReadWriteOnce upload volume the web and worker pods both mount it, so the chart adds a required pod affinity that keeps them on one node, and rejects more than one replica of either unless the volume is ReadWriteMany or storage.type=s3.
  • SECRET_KEY_BASE comes from secret.existingSecret or from a chart-created Secret with a random value that survives upgrades and is kept on uninstall (lookup finds nothing in a render without cluster access, such as Argo CD, so pass an existing Secret there). Back it up: changing it signs everyone out, and on a self-hosted instance Sure derives the Active Record encryption keys that protect stored provider credentials from it. To pin those keys instead, put all three of ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY, ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY and ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT in secret.existingSecret; Sure refuses to start with only some of them.
  • Everything else is an environment variable: plain ones under config (ONBOARDING_STATE, OPENAI_MODEL, EXCHANGE_RATE_PROVIDER, SMTP_ADDRESS, OIDC_ISSUER), and anything secret under extraEnv with a secretKeyRef (OPENAI_ACCESS_TOKEN, SMTP_PASSWORD, OIDC_CLIENT_SECRET, TWELVE_DATA_API_KEY). A key in config replaces the chart's own value of the same name. See the upstream reference.
  • The image entrypoint runs db:prepare before the web server starts, so the web pod owns migrations and startupProbe allows five minutes for them. The worker's init container (worker.waitForMigrations, on by default) blocks until db:abort_if_pending_migrations passes, so Sidekiq never runs against an empty or outdated schema.
  • Pods are non-root (uid 1000) with a read-only root filesystem and all capabilities dropped; /tmp, /rails/tmp and /rails/storage are the writable paths. /tmp is a memory-backed emptyDir (tmp.sizeLimit, 256Mi), because Ruby refuses a disk-backed one (see below); what is written there, such as the temporary files of uploads and imports, counts against the pod's memory limit. All probes use /up, which answers once the app has booted and does not touch the database or Redis, so a dependency outage does not restart the pods. The worker has no probe. Sure used about 650Mi (web) and 600Mi (worker) resident right after start with an empty database, so the defaults request 768Mi and limit 1536Mi.
  • ingress renders a plain networking.k8s.io/v1 Ingress for the host in appUrl; the chart does not create Gateway API or Traefik resources. The first account created on a fresh instance is the super admin, who can open /sidekiq; set ONBOARDING_STATE to invite_only or closed under config once the accounts exist.

Tested with podman against the containers the chart renders (same environment variables, uid 1000, read-only root filesystem, dropped capabilities, /tmp and /rails/tmp as tmpfs, /rails/storage as a volume), not on a cluster, because rootless k3s could not start here (the kubelet cannot enter its cgroup). PostgreSQL was the plain postgres:18-alpine image, not the CloudNativePG one, and Valkey ran with the command, uid 999, read-only filesystem and password the chart renders. The worker's migration check kept waiting on an empty database and passed once the web container had migrated; the web container applied every migration under the read-only root filesystem, created the pgcrypto extension as the database owner, answered /up in about 25s, redirected to sign-up and created a user over plain HTTP with RAILS_FORCE_SSL=false; Sidekiq connected to the password-protected Valkey and registered its cron jobs; with RAILS_FORCE_SSL=true and RAILS_ASSUME_SSL=true the app answered a plain-HTTP /up with 200 and sent Strict-Transport-Security. Rendered and schema-checked only, never run: the CloudNativePG Cluster and the secret keys (host, port, dbname, username, password) the pods read from it, the $(REDIS_PASSWORD) expansion in REDIS_URL, the pod affinity, database.mode=external, redis.mode=external, storage.type=s3, the Ingress, more than one replica, AI features, bank syncs, uploads, and upgrades from an earlier chart version.

Found on a cluster (Kubernetes, Talos) on 2026-10-30: with a disk-backed emptyDir at /tmp the web pod crash-looped at start (/tmp is world-writable, could not find a temporary directory). Kubernetes creates such a directory with mode 2777, no sticky bit, and Ruby's Dir.tmpdir refuses it; the podman test above used a tmpfs, which is sticky, and hid it. Tested in a pod with this image, user and fsGroup: a disk-backed emptyDir (also with a subPath) fails, a memory-backed one is created 3777 and Dir.tmpdir returns /tmp. A disk-backed directory created by an init container (mode 0700) also works but needs one more container in every pod; the memory-backed volume was chosen for being one line.

To upgrade: set the new release in image.tag and appVersion (image tags are the release tag without the leading v), read the release notes for new or removed environment variables, and bump the chart version. Back up the database and uploads first; migrations run automatically when the web pod starts.

Conventions

  • Every change to a chart MUST bump version in its Chart.yaml; CI enforces this (check-version-increment).
  • Charts must pass helm lint --strict.
  • Chart dependencies are declared in Chart.yaml and locked in Chart.lock; charts/*/charts/ is not committed.

CI

  • .gitea/workflows/lint.yaml runs ct lint and then make validate on pull requests, but only for charts that changed. Its status, Lint charts / lint (pull_request), is a required check on main.
  • .gitea/workflows/publish.yaml runs make publish on pushes to main that touch charts/, and on manual dispatch.
  • .gitea/workflows/renovate.yaml runs Renovate daily and on manual dispatch (see Dependency updates).
  • .gitea/workflows/preview.yaml runs make publish-preview on pull requests from branches in this repo that touch charts/, for the changed charts only (see Preview builds).
  • .gitea/workflows/preview-cleanup.yaml runs make clean-preview when a branch is deleted, and on manual dispatch (see Preview builds).

Dependency updates

Renovate opens pull requests for upstream releases. It is configured in renovate.json and covers charts/newt, charts/searxng, charts/rxresume and charts/sure:

  • The upstream newt chart dependency in Chart.yaml (helmv3 manager; it also refreshes Chart.lock).
  • Image versions marked with a # renovate: datasource=<ds> depName=<name> comment on the line above. The comment sits above appVersion in Chart.yaml and above image.tag in values.yaml; both share a depName, so one pull request updates both and they stay equal:
    • Newt (datasource=docker depName=fosrl/newt) and SearXNG (datasource=docker depName=docker.io/searxng/searxng), read from the image registry.
    • Reactive Resume (datasource=github-releases depName=reactive-resume/reactive-resume), read from GitHub releases instead of Docker Hub: its release tag equals the image tag, nightly and sha- image tags are not releases, and only GitHub gives a release date (see Automerge). This needs RENOVATE_GITHUB_COM_TOKEN; without it Renovate skips the dependency.
    • The CloudNativePG PostgreSQL image in rxresume (database.cnpg.image, a single repository:tag value). Only minor releases are proposed; a PostgreSQL major upgrade needs a migration plan, so major updates are disabled. The -standard-<distro> part of the tag must stay the same, so a base image change is never proposed.
    • Sure (datasource=github-releases depName=we-promise/sure), read from GitHub releases: the release tag is vX.Y.Z, vX.Y.Z-hotfix.N, vX.Y.Z-rc.N or vX.Y.Z-alpha.N while the image tag drops the v, so renovate.json strips it (extractVersion) and orders versions with a regex scheme that treats the hotfix as a build number (semver would rank 0.7.5-hotfix.3 below 0.7.5) and rc and alpha as prerelease. From a stable release only stable releases are proposed. A chart on an alpha or rc is only offered later releases; the scheme has one number for both, so alpha.2 sorts after rc.1. A tag that does not match the scheme is invalid to Renovate, which then offers older releases as updates, so extend the scheme before pinning a new tag shape.
    • Valkey in sure (redis.valkey.image, a single repository:tag value) and the CloudNativePG PostgreSQL image there (database.cnpg.image, the same rules as in rxresume).
  • Every Renovate pull request also bumps the chart version (patch for patch updates, otherwise minor), so ct's check-version-increment passes.

SearXNG tags look like 2026.10.19-81371861f. The commit hash cannot be ordered, so Renovate compares only the date (regex versioning in renovate.json); two builds from the same day count as the same version. Reactive Resume major updates (for example v5 to v6) arrive as ordinary pull requests, so read the release notes before merging.

To track another version, add the same comment on the line above it. A tag: or appVersion: value is matched by the first regex manager and a image: repo:tag value by the second; both match Chart.yaml and values.yaml of every chart. To track another chart, add it to includePaths in renovate.json.

.gitea/workflows/renovate.yaml runs on a schedule (05:00 UTC daily) and on manual dispatch, using a pinned ghcr.io/renovatebot/renovate image. It needs the RENOVATE_TOKEN repository Actions secret: a personal access token for a dedicated Renovate account with repo (read and write), issue (read and write), user (read) and organization (read) scopes. Renovate pull requests are opened by that account, so the preview workflow publishes them like any other in-repo branch.

It also reads the RENOVATE_GITHUB_COM_TOKEN secret: a github.com personal access token (classic) with no scopes. It raises the GitHub API rate limit for changelog lookups, and Renovate refuses to read Reactive Resume's GitHub releases without it.

Automerge

Renovate automerges patch and minor updates (squash) of charts/newt, charts/searxng, charts/rxresume and charts/sure once every required check passes. Major updates stay manual: Reactive Resume v5 to v6, a new SearXNG year (its date tags are read as year.month.day) and PostgreSQL majors (disabled). Sure is at 0.x, so its regular releases (0.7 to 0.8) are minor updates and merge on their own, including the data migrations they run on the next deploy; only a 1.0 would wait for you. Monthly SearXNG releases and PostgreSQL minor releases of the CloudNativePG image do merge on their own; the latter restarts the database pods once the new chart is deployed. Two things keep a broken update from merging, and a third explains why Renovate does not check CI itself:

  • A release must be at least 3 days old (minimumReleaseAge) before Renovate creates or updates its pull request, so a release that is pulled or hotfixed shortly after publishing is skipped. This uses the release timestamp from the datasource, which exists for Newt (Docker Hub), Reactive Resume and Sure (GitHub). It does not exist for SearXNG, whose Docker Hub tag listing returns 403 past page 10 of thousands of tags, nor for the ghcr.io PostgreSQL image. Left at the default, Renovate would hold those updates back as pending forever, so the rule sets minimumReleaseAgeBehaviour: timestamp-optional for them: they are not delayed, and the required check below is their only gate.
  • Branch protection on main requires the Lint charts / lint (pull_request) status check. Renovate asks Forgejo to merge when checks succeed, and Forgejo only waits for required checks: without that rule it would merge a pull request whose checks have failed or not started. Keep the required context name in sync with the workflow and job name in lint.yaml.
  • Renovate does not read CI status itself. This forge returns 404 for the commit statuses of a branch whose name contains a slash (renovate/...), and Renovate treats that 404 as a deleted branch and aborts the run after pushing the branch, before creating the pull request or the Dependency Dashboard. The automerge rule therefore sets ignoreTests and statusCheckWhen.minimumReleaseAge: never, which skips those lookups; the required check on main is then the only CI gate, so do not remove it. Renovate's own renovate/stability-days status is not set, but the release age is still enforced when the branch is created.

make validate is what makes that check meaningful for dependency bumps: it fails when the rendered manifests do not match the Kubernetes schemas or when an image tag does not exist in its registry. It does not run the chart, so an upstream release that breaks at runtime still gets through; the 3 day wait and reading the release notes are the only defence against that.

Every merged update is published by publish.yaml, so an automerged bump ships a new chart version without a manual step. To stop that, remove the automerge rule from renovate.json.

Publishing

Charts are published as OCI artifacts to the Gitea container registry:

oci://git.enclaveis.com/enclave-information-systems/<chart>:<version>

make publish packages every chart and pushes each one whose name:version is not yet in the registry, so re-runs are safe and published versions are never overwritten. A failed lookup other than "not found" (network, auth) aborts instead of pushing.

CI authenticates with the REGISTRY_TOKEN repository Actions secret, a personal access token with the write:package scope.

After publishing, CI links each chart's package to this repository through the Gitea API (Gitea does not do this on push). Packages pushed by hand are not linked until the next CI publish run, or link them manually in the package settings.

To publish or consume by hand:

helm registry login git.enclaveis.com -u <username>   # token as password
make publish
helm install myapp oci://git.enclaveis.com/enclave-information-systems/<chart> --version <version>

Pulling requires helm registry login with a token that has read:package, unless the package visibility is public.

Preview builds

Every push to a pull request that changes a chart publishes that chart as <version>-pr<number>.<short-sha>, for example 0.1.0-pr7.6f5a337. The job log prints the install command. Pre-release versions are ignored by helm install and helm search unless you pass --version or --devel, so they never shadow a real release.

helm install newt oci://git.enclaveis.com/enclave-information-systems/newt --version 0.1.0-pr7.6f5a337 -n newt

Preview versions are deleted when their branch is: .gitea/workflows/preview-cleanup.yaml runs on the delete event for branches (tags are ignored). Forgejo runs a delete workflow from the default branch, so a change to the workflow takes effect once it is merged. PRs from forks are skipped because they cannot read the REGISTRY_TOKEN secret.

make clean-preview maps the deleted branch to its pull requests (head.label keeps the branch name after the branch is gone, whatever the PR state), then deletes every container version named -pr<number>.<sha> of those PRs, together with the untagged sha256: manifest version each push leaves behind (it holds the chart layer, and the registry does not remove it with the tag). Release versions, other PRs' previews and packages linked to a different repository are never touched. It is safe to run again.

  • REGISTRY_TOKEN needs write:package, and its user must be allowed to delete packages of the organization. The job's built-in token only reads pull requests.
  • To clean up by hand, run the workflow from the Actions tab with a branch name. dry_run is on by default and only lists what would be deleted; untick it to delete.
  • Previews pushed before this workflow existed are not matched: those PRs no longer carry their branch name (refs/pull/<n>/head). Delete them in the package settings.
  • Packages are matched by version only (-pr<number>.<sha>), so a preview-only package of another repository in the same organization with the same PR number would also be deleted until it is linked to its repository.

License

The charts and tooling in this repository are licensed under the GNU General Public License, version 3 (see LICENSE). Upstream projects that the charts deploy, such as Pangolin, Newt, SearXNG, Reactive Resume and Sure, keep their own licenses.