- Go Template 78.8%
- Makefile 21.2%
|
All checks were successful
Publish charts / publish (push) Successful in 27s
|
||
|---|---|---|
| .gitea/workflows | ||
| charts | ||
| .editorconfig | ||
| .gitignore | ||
| ct.yaml | ||
| LICENSE | ||
| lintconf.yaml | ||
| Makefile | ||
| README.md | ||
| renovate.json | ||
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
- helm 4.x
- chart-testing (
ct) andyamllint, only formake ct-lint - kubeconform and crane, only for
make validate
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 incharts/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.tagoverrides that; keep it equal toappVersioninChart.yaml. Renovate updates both together (see Dependency updates). - The upstream chart refuses to render without credentials. The default
newt.newtInstanceslist references a Secret namednewt-credentialswith keysPANGOLIN_ENDPOINT,NEWT_IDandNEWT_SECRET; create it before installing. Helm replaces lists instead of merging them, so an overridingnewtInstancesin 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 TraefikIngressRoute,MiddlewareandTraefikServiceresources, served by a Traefik you install separately. This matches upstream'sdeployment.type=controllerwithdeployment.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:
- The CloudNativePG operator (cluster-wide; its webhook rejects
Clusterresources until it runs). - Traefik v3 with the CRD provider, the Badger plugin, entrypoints
web/websecureand an ACME resolver namedletsencrypt.charts/pangolin/examples/traefik-values.yamlhas the values, including three settings that are easy to miss:fsGroup(otherwise Traefik cannot writeacme.json), amaxSurge: 0update strategy (otherwise aReadWriteOncevolume deadlocks every upgrade) andallowCrossNamespace: true(see below). - 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 aTraefikServiceand produces an empty, non-working route (observed with0.1.0-alpha.1). Because the Services are usually in other namespaces, Traefik needsproviders.kubernetesCRD.allowCrossNamespace: true, which lets a Pangolin admin route to any Service in the cluster. - The chart sets
server.internal_hostnameto the Pangolin Service FQDN, which Pangolin writes into the Badger middleware'sapiBaseUrl; Traefik is in another pod, so the default (pangolin) would not resolve. flags.enable_acme_cert_syncis turned off: Pangolin would read Traefik'sacme.jsonfrom its own filesystem, which is another pod. Set it underpangolin.config.flagsto 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.traefikNamespaceSelectormust match the namespace Traefik runs in. - Pangolin and Gerbil never mount a ServiceAccount token; only the controller does, with a Role in
controller.targetNamespaceplus read access toIngressClassand CRDs. Gerbil runs with onlyNET_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_portis 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/configis anemptyDirwith PostgreSQL; only Gerbil's WireGuard key needs a volume (gerbil.persistence). Volumes arehelm.sh/resource-policy: keep; use a StorageClass withreclaimPolicy: Deletefor 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.configis rendered toconfig.yml; the chart derives dashboard URL, base domain, CORS origin andinternal_hostnamefrom other values, and anything you set wins. The server secret is read from theSERVER_SECRETenv var, frompangolin.secret.existingSecretor 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.modeiscnpg(default, recommended) orsqlite(development only).cnpgrenders a CloudNativePGClusternamed<fullname>-db(3 instances, 10Gi each) and switches Pangolin to itspostgresql-<version>image, withPOSTGRES_CONNECTION_STRINGtaken from the<fullname>-db-appSecret CloudNativePG generates.database.cnpg.extraSpecis merged into theClusterspec: use it for backups, affinity and parameters. Backups are not configured by default.cloudnative-pg.enabled=trueinstalls the operator as this chart's subchart. The operator pod is not ready when Helm creates theClusterin the same release, so the webhook can reject it; prefer the standalone operator. Switching an existing install fromsqlitetocnpgdoes 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.modeiscnpg(default, recommended) orexternal.cnpgrenders a CloudNativePGClusternamed<fullname>-db(3 instances, 10Gi each) and takesDATABASE_URLfrom the<fullname>-db-appSecret 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.externalreadsDATABASE_URLfromdatabase.external.existingSecret.database.cnpg.extraSpecis merged into theClusterspec: 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.typeislocal(default) ors3.localmounts a PersistentVolumeClaim at/app/data(helm.sh/resource-policy: keep, so uninstall leaves it).s3takes the endpoint, region, bucket andforcePathStylefromstorage.s3and the credentials fromstorage.s3.existingSecret(keysS3_ACCESS_KEY_IDandS3_SECRET_ACCESS_KEYby default). Garage and MinIO needforcePathStyle: true.AUTH_SECRETandENCRYPTION_SECRETcome fromsecret.existingSecretor from a chart-created Secret with random values that survive upgrades and are kept on uninstall. Back it up: changingAUTH_SECRETsigns everyone out, and losingENCRYPTION_SECRETmakes saved AI provider credentials unreadable. A Secret you provide may omitENCRYPTION_SECRET.- Everything else is an environment variable: plain ones under
config(feature flags such asFLAG_DISABLE_SIGNUPS,ROOT_RESUME_ID,SMTP_HOST), and anything secret underextraEnvwith asecretKeyRef(SMTP_USER,SMTP_PASS, OAuth client secrets,REDIS_URL). See the upstream reference. Without all ofSMTP_HOST,SMTP_USER,SMTP_PASSandSMTP_FROMthe 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;
/tmpand/app/dataare the only writable paths. Readiness uses/api/health, which checks the database, the storage and, whenREDIS_URLis 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=localand aReadWriteOncevolume the Deployment usesRecreate, and more than one replica is rejected unless the volume isReadWriteMany. Several replicas should share a Redis (Valkey works): putREDIS_URLinextraEnvfrom a Secret and setDEPLOYMENT_NAMESPACEunderconfig. WithREDIS_URLset,/api/healthalso reports Redis, and the auth rate limits are stored there underreactive-resume:<DEPLOYMENT_NAMESPACE>:. - Sign-in with an OpenID Connect provider: set
OAUTH_PROVIDER_NAMEandOAUTH_DISCOVERY_URLunderconfig, andOAUTH_CLIENT_IDandOAUTH_CLIENT_SECRETinextraEnvfrom a Secret. Register the redirect URI<appUrl>/api/auth/callback/customexactly, as a confidential client with the scopesopenid profile email; the user needs an email address. The app signs users up on first login unlessFLAG_DISABLE_SIGNUPSis"true". - Rate limiting is keyed by client IP, read from
CF-Connecting-IP,True-Client-IP,X-Forwarded-FororX-Real-IP. Without one of those headers (for example throughkubectl port-forward) every client shares one bucket and the app logs a warning. Put the app behind a proxy that setsX-Forwarded-For. ingressrenders a plainnetworking.k8s.io/v1Ingress for the host inappUrl; 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:
-
settingsis rendered verbatim assettings.yml, so any key in the settings reference works: engines, categories, plugins,outgoingproxies,ui,search,valkey.use_default_settings: truekeeps 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 asenginesreplaces the chart's.server.secret_keyis better left to the Secret below. -
limiteris a string rendered aslimiter.toml(bot detection,ip_lists, rate-limit prefixes).faviconsis a string rendered asfavicons.toml; its default equals SearXNG's packaged one and is always mounted, because SearXNG logs an error whensearch.favicon_resolveris set and the file is missing. -
config(plain) andextraEnv(full entries, forsecretKeyRef) set environment variables, which overridesettings.yml. SearXNG readsSEARXNG_DEBUG,SEARXNG_LIMITER,SEARXNG_PUBLIC_INSTANCE,SEARXNG_IMAGE_PROXY,SEARXNG_METHOD,SEARXNG_BIND_ADDRESS,SEARXNG_PORT,SEARXNG_BASE_URL,SEARXNG_SECRETandSEARXNG_VALKEY_URL; the server is Granian, tuned withGRANIAN_*. The container is expected to listen on 8080: the probes and the Service target it. -
extraVolumes,extraVolumeMounts,extraContainersandextraInitContainerscover what a settings file cannot: a custom theme, a favicon cache on a PersistentVolumeClaim, a proxy sidecar (Tor, for example) thatoutgoing.proxiespoints at on127.0.0.1.ui.templates_pathandui.static_pathreplace SearXNG's own directories entirely, so a mounted theme must be a complete copy, every theme included, and the directories must exist at start. -
baseUrlis optional and becomesSEARXNG_BASE_URL. -
server.secret_keycomes fromsecret.existingSecretor from a chart-created Secret with a random value that survives upgrades and is kept on uninstall (lookupfinds 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_instanceis on and no Valkey URL is configured. Any ofvalkey.existingSecret,SEARXNG_VALKEY_URLinconfigorextraEnv, orsettings.valkey.urlsatisfies it. -
The limiter believes
X-Forwarded-ForandX-Real-IPonly from the networks inlimiter.toml'sbotdetection.trusted_proxies; list every proxy hop in front of the pod. Without a client address header, requests to/searchare rejected with 429. It also expects browser behaviour (the link-token stylesheet,Sec-Fetch-*,Accept-*headers), socurlagainst a public instance gets 429 or a redirect to/. The JSON API (search.formatsmust listjson; 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;
/tmpand/var/cache/searxngare the writable paths, and the three config files are mounted read-only, so a values change rolls the pods through a checksum annotation./healthzserves 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.modeiscnpg(default, recommended) orexternal.cnpgrenders a CloudNativePGCluster(3 instances, 10Gi each) and passes its generated<fullname>-db-appSecret to Sure asDB_HOST,DB_PORT,POSTGRES_USER,POSTGRES_PASSWORDandPOSTGRES_DB; the operator must already be installed.externaltakesdatabase.external.host,port,databaseandusernameas values and the password fromdatabase.external.existingSecret. Sure has no connection-string setting for TLS; setPGSSLMODEunderconfigfor a managed database.database.cnpg.extraSpecis merged into theClusterspec: 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.modeisvalkey(default) orexternal.valkeyruns 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.externalreadsREDIS_URL(for exampleredis://:PASSWORD@host:6379/1, orrediss://) fromredis.external.existingSecret. Sure cannot run without it: Sidekiq keeps its queues and its schedule there.storage.typeislocal(default) ors3.localmounts a PersistentVolumeClaim at/rails/storagein both the web and the worker pod (helm.sh/resource-policy: keep, so uninstall leaves it).s3selects Active Storage'sgeneric_s3service: endpoint (empty for AWS), region, bucket andforcePathStylefromstorage.s3, credentials fromstorage.s3.existingSecret(keysS3_ACCESS_KEY_IDandS3_SECRET_ACCESS_KEYby default). Garage and MinIO needforcePathStyle: true.- With a
ReadWriteOnceupload 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 isReadWriteManyorstorage.type=s3. SECRET_KEY_BASEcomes fromsecret.existingSecretor from a chart-created Secret with a random value that survives upgrades and is kept on uninstall (lookupfinds 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 ofACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY,ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEYandACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALTinsecret.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 underextraEnvwith asecretKeyRef(OPENAI_ACCESS_TOKEN,SMTP_PASSWORD,OIDC_CLIENT_SECRET,TWELVE_DATA_API_KEY). A key inconfigreplaces the chart's own value of the same name. See the upstream reference. - The image entrypoint runs
db:preparebefore the web server starts, so the web pod owns migrations andstartupProbeallows five minutes for them. The worker's init container (worker.waitForMigrations, on by default) blocks untildb:abort_if_pending_migrationspasses, 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/tmpand/rails/storageare the writable paths./tmpis a memory-backedemptyDir(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. ingressrenders a plainnetworking.k8s.io/v1Ingress for the host inappUrl; 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; setONBOARDING_STATEtoinvite_onlyorclosedunderconfigonce 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
versionin itsChart.yaml; CI enforces this (check-version-increment). - Charts must pass
helm lint --strict. - Chart dependencies are declared in
Chart.yamland locked inChart.lock;charts/*/charts/is not committed.
CI
.gitea/workflows/lint.yamlrunsct lintand thenmake validateon pull requests, but only for charts that changed. Its status,Lint charts / lint (pull_request), is a required check onmain..gitea/workflows/publish.yamlrunsmake publishon pushes tomainthat touchcharts/, and on manual dispatch..gitea/workflows/renovate.yamlruns Renovate daily and on manual dispatch (see Dependency updates)..gitea/workflows/preview.yamlrunsmake publish-previewon pull requests from branches in this repo that touchcharts/, for the changed charts only (see Preview builds)..gitea/workflows/preview-cleanup.yamlrunsmake clean-previewwhen 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
newtchart dependency inChart.yaml(helmv3manager; it also refreshesChart.lock). - Image versions marked with a
# renovate: datasource=<ds> depName=<name>comment on the line above. The comment sits aboveappVersioninChart.yamland aboveimage.taginvalues.yaml; both share adepName, 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 andsha-image tags are not releases, and only GitHub gives a release date (see Automerge). This needsRENOVATE_GITHUB_COM_TOKEN; without it Renovate skips the dependency. - The CloudNativePG PostgreSQL image in
rxresume(database.cnpg.image, a singlerepository:tagvalue). 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 isvX.Y.Z,vX.Y.Z-hotfix.N,vX.Y.Z-rc.NorvX.Y.Z-alpha.Nwhile the image tag drops thev, sorenovate.jsonstrips it (extractVersion) and orders versions with aregexscheme that treats the hotfix as a build number (semver would rank0.7.5-hotfix.3below0.7.5) andrcandalphaas 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, soalpha.2sorts afterrc.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 singlerepository:tagvalue) and the CloudNativePG PostgreSQL image there (database.cnpg.image, the same rules as inrxresume).
- Newt (
- Every Renovate pull request also bumps the chart
version(patch for patch updates, otherwise minor), soct'scheck-version-incrementpasses.
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 theghcr.ioPostgreSQL image. Left at the default, Renovate would hold those updates back as pending forever, so the rule setsminimumReleaseAgeBehaviour: timestamp-optionalfor them: they are not delayed, and the required check below is their only gate. - Branch protection on
mainrequires theLint 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 inlint.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 setsignoreTestsandstatusCheckWhen.minimumReleaseAge: never, which skips those lookups; the required check onmainis then the only CI gate, so do not remove it. Renovate's ownrenovate/stability-daysstatus 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_TOKENneedswrite: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_runis 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.