5
sure
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.

sure

Deploys Sure, the maintained community fork of Maybe Finance, as a single-replica StatefulSet with two containers — the Puma web process and a Sidekiq worker — sharing one upload volume. Postgres and Redis are not part of this chart.

Image ghcr.io/we-promise/sure
Versions not listed on purpose — read the current pair off sure/Chart.yaml, see Chart index
Workloads StatefulSet (1 replica, 2 containers: web + worker, plus one init container)
Storage one RWO PVC at /rails/storage, mounted into both containers
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, Redis
Secrets 3 × OnePasswordItem
Install-test ci/sure/ (fixtures + test values, INSTALL_TIMEOUT=900)

Prerequisites

  • 1Password operator in the cluster, plus three 1Password items — see the fields below.
  • Postgres and Redis reachable before the first start. The web container creates and migrates the schema at start, its readiness probe runs a query, and all three worker probes ask Redis for the Sidekiq heartbeat. A missing dependency shows up as a pod that never turns Ready — read the container log, not the pod events.
  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for ingress.domain pointing at the ingress controller.
  • Nothing else: there is no OIDC provider to prepare, and the first account created on a fresh instance becomes the super admin.

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 sure jlab/sure \
  --namespace sure --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml:

secrets:
  appSecretRef: op://vault/sure-app
  dbSecretRef: op://vault/sure-db
  redisSecretRef: op://vault/sure-redis

ingress:
  domain: finance.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 (APP_DOMAIN, WEBAUTHN_RP_ID), and the Gateway's listener must terminate TLS: the chart renders RAILS_FORCE_SSL=true in this mode. 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. 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.

Values key Kubernetes Secret Fields
secrets.appSecretRef <release>-sure-secret secret-key-base — SECRET_KEY_BASE, 64 random bytes hex (openssl rand -hex 64). See Rotating SECRET_KEY_BASE before ever changing it
secrets.dbSecretRef <release>-sure-db-secret host, port, database, username, password — rendered into DB_HOST, DB_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD
secrets.redisSecretRef <release>-sure-redis-secret connection-url — REDIS_URL, e.g. redis://:password@host:6379/0

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders every workload with replicas: 0 without uninstalling. Takes precedence over sure.replicas. PVC, Secrets, Service and Ingress stay.

secrets

Key Type Default Description
secrets.appSecretRef string null Required. op:// path of the app item (SECRET_KEY_BASE).
secrets.dbSecretRef string null Required. op:// path of the Postgres item.
secrets.redisSecretRef string null Required. op:// path of the Redis item.

sure

Key Type Default Description
sure.replicas int 1 Replica count of the StatefulSet; 0 is allowed. Scales both containers, since they live in one pod. See One pod, one volume, two containers before raising it.
sure.securityContext.pod object non-root 1000, fsGroup: 1000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault Passed verbatim into the pod spec; shared by both containers. uid/gid 1000 is what the image declares (USER 1000:1000). The seccomp profile sits here on purpose: it covers every container including init containers.

sure.web

Key Type Default Description
sure.web.securityContext.container object readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root Passed verbatim into the container spec. The writable paths are emptyDirs — see Which paths have to be writable.
sure.web.livenessProbe object GET /up:http, period 10s, timeout 5s, 3 failures Boot check only; it touches no database. See What each probe actually proves.
sure.web.readinessProbe object exec: curl (no -L) against http://127.0.0.1:3000/, accepted for 2xx–3xx, period 10s, timeout 10s, 3 failures The path that reaches Postgres. exec rather than httpGet for a measured reason — see What each probe actually proves.
sure.web.startupProbe object GET /up:http, period 10s, 60 failures (= 600s) Gates liveness and readiness. The budget covers db:prepare on the first start.
sure.web.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
sure.web.resources.requests object cpu: 0.1, memory: 512Mi, ephemeral-storage: 256Mi Upstream reports ~352 MB steady state and asks for at least 1 GB with AI enabled; 512Mi × 3 covers both. The ephemeral-storage figure is not the scaffold's 10Mi — see Ephemeral storage is not boilerplate here.
sure.web.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.
sure.web.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. This is where mail and AI go.

sure.worker

Key Type Default Description
sure.worker.securityContext.container object same hardening as the web container Passed verbatim into the container spec.
sure.worker.livenessProbe object exec sidekiqmon heartbeat check, period 60s, timeout 15s, 5 failures (= 300s) Deliberately generous: the check needs Redis, so a short outage must not restart the pod.
sure.worker.readinessProbe object same exec check, period 30s, timeout 15s, 3 failures
sure.worker.startupProbe object same exec check, period 20s, 30 failures (= 600s) The worker registers only after the schema the web container creates exists.
sure.worker.resources.limitFactor number 3
sure.worker.resources.requests object cpu: 0.1, memory: 512Mi, ephemeral-storage: 256Mi The sample-data generator runs here and is the one operation upstream measured above 512 MB (~680 MB); the computed 1536Mi limit covers it.
sure.worker.resources.limits object unset
sure.worker.env list [] Same rules as sure.web.env. Anything a background job needs — the AI assistant included — belongs here as well as on the web container.

storage

Key Type Default Description
storage.amount string 10Gi Size of the Active Storage PVC.
storage.storageClass string null Storage class; null uses the cluster default.

ingress

Key Type Default Description
ingress.enabled bool true Switches the Ingress object off. Mutually exclusive with httpRoute.enabled. ingress.domain stays required: see ingress.domain is read four times. Since #114 this flag no longer decides the TLS flags on its own: RAILS_ASSUME_SSL/RAILS_FORCE_SSL render from ingress.enabled OR httpRoute.enabled — see Routing: Ingress or Gateway API.
ingress.domain string null Required. Ingress host, APP_DOMAIN (mail links), WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS.
ingress.clusterIssuer string null Required while the Ingress is enabled. cert-manager ClusterIssuer.
ingress.className string nginx Ingress class.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode, and nothing this chart needs lives here — the TLS flags are environment variables and are handled in both modes.

TLS is stored in tls-<release>-sure-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>-sure-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>-sure-service on port 80. The web container serves everything; the Sidekiq worker has no HTTP port and is never routed to.

networkPolicy

Inbound 3000 only, declared by the web container — the worker declares no port at all. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis) and 443, and those egress rules apply to both containers.

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.

HOW RAILS IS TOLD ABOUT TLS NO LONGER HANGS ON ingress.enabled ALONE, AND THAT WAS A DEFECT WAITING TO HAPPEN. RAILS_ASSUME_SSL and RAILS_FORCE_SSL used to render from ingress.enabled — exactly right while an Ingress was the only way to publish. Gateway mode sets ingress.enabled: false by construction, while TLS is still terminated in front of the app, now at the Gateway's listener. Left as it was, a Gateway-mode release would have come up with both flags false: Rails would not trust the forwarded scheme, would hand out session cookies without the Secure attribute over a TLS connection, would send no HSTS, and would build absolute URLs — mail links, OIDC redirects — as http://. The pod is Ready, the UI works, and the only way to notice is to read a Set-Cookie header.

Since #114 the question is asked once, in the sure.publishedOverTls helper, as ingress.enabled OR httpRoute.enabled. With httpRoute.enabled at its default of false that expression is ingress.enabled, so nothing changed for an existing release. Measured in all three states: Ingress true/true, Gateway true/true, both off false/false — the last is what lets a plain-HTTP port-forward log in at all.

The corollary for the operator: a Gateway listener that is plain HTTP will hand out cookies the browser never returns, because the chart now assumes the Gateway terminates TLS. That is the one thing to get right before switching this chart over.

WEBAUTHN_RP_ID is the other sharp edge, and the route does not move it. APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS all follow ingress.domain, not httpRoute.hostnames. Publishing under a name ingress.domain does not carry registers passkeys against the wrong relying party — and changing ingress.domain afterwards to fix it invalidates every credential already registered (upstream docs/hosting/webauthn.md).

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>-sure-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.

What each probe actually proves

Rails' /up route (get "up" => "rails/health#show") returns 200 as soon as the app has booted without raising. It touches no database and no Redis. The widespread assumption that it tests the database is exactly what style guide §12.3 exists to catch — upstream's own Helm chart, which ships inside the application repository, uses /up for readiness, and this chart deliberately does not copy that.

  • web liveness and startup: GET /up. A Postgres outage therefore does not restart the pod.

  • web readiness: /, asked through curl. Every request runs authenticate_user!; without a session cookie — which a probe never has — it evaluates self_hosted_first_login?, i.e. User.count.zero?, which is an SQL query. With the database up the request ends in a 302 to the login or registration page, which counts as success; with the database down it answers 500 and the pod leaves the Service until Postgres is back.

    Why exec + curl instead of httpGet: the kubelet's HTTP prober follows redirects to the same host, and with RAILS_ASSUME_SSL=true Rails builds that redirect as https://<pod-ip>:3000/..., which Puma does not speak. The install-test failed with http: server gave HTTP response to HTTPS client on a pod that was answering perfectly. curl without -L asks once and reports the status code, and the probe accepts 2xx–3xx exactly as the kubelet would. curl is part of the image.

  • worker: all three are exec probes running bundle exec sidekiqmon processes | grep -q "^${HOSTNAME}:". Sidekiq registers each process under the identity <hostname>:<pid>:<nonce> and keeps that entry alive with a heartbeat whose Redis key expires after about 60s — so a hung worker disappears from the list while its PID still exists. A plain process check could not tell that, and would in fact never fail at all: Sidekiq is PID 1 in its container, so if it dies the container dies with it.

Two things this depends on, both set by the chart: SELF_HOSTED=true (in managed mode / redirects without the User.count), and RAILS_ASSUME_SSL matching RAILS_FORCE_SSL. Sure's production.rb defaults both to true; with assume_ssl off and force_ssl on, a plain-HTTP probe is answered with a 301 before the controller runs — a "success" that proves nothing. Overriding RAILS_ASSUME_SSL to "false" while leaving RAILS_FORCE_SSL on turns the readiness probe back into a lie.

The design above was read off the source of 0.7.4 (the Rails health controller, app/controllers/concerns/authentication.rb, config/environments/production.rb, Sidekiq 8.0.5's lib/sidekiq/component.rb). Two parts of it are now measured in the install-test: readiness on / really is decided by User.count (against an empty database the answer was a redirect to /registration/new, which is that query's branch), and the worker's sidekiqmon check really does turn Ready — the whole pod reached Ready about 80 seconds after helm install, first start and schema creation included. What is still unmeasured is behaviour under failure: nobody has yet taken Postgres or Redis away from a running instance and watched the probes react.

One pod, one volume, two containers

Active Storage writes uploads, imports and exports to /rails/storage, and both processes use them: the web process serves the files a background job produced. Upstream's compose file mounts one volume into both services, and a ReadWriteOnce PVC cannot be attached to two pods — so the two containers share one pod instead of being two workloads. Raising sure.replicas above 1 normally leaves the extra pods Pending on Multi-Attach error for volume.

Only the web container migrates

The image entrypoint (/rails/bin/docker-entrypoint) runs ./bin/rails db:prepare only when the command is ./bin/rails server. That is the image's default CMD, so the web container migrates; the worker is started with args: ["bundle", "exec", "sidekiq"] and therefore skips it. The two can never race.

args, not command, and the distinction is load-bearing: Kubernetes' command would replace the entrypoint, which is also what preloads jemalloc — one of the three tunings upstream names as the reason its memory figures hold.

Which paths have to be writable

readOnlyRootFilesystem: true holds for both containers; every writable path is an emptyDir, one set per container:

Path Why
/rails/storage the PVC — Active Storage's local disk service
/tmp Rack writes multipart uploads (CSV imports, attachments) to Dir.tmpdir before Active Storage ever sees them
/rails/tmp Rails' own scratch directory: the Bootsnap cache, Puma's pidfile when PIDFILE is set, and the combined CA bundle the 00_ssl initializer writes when SSL_CA_FILE is given
/rails/log production logs go to STDOUT, so nothing is expected here; it stays writable because Rails' default logger path is log/<env>.log and a rake or console path falling back to it would otherwise hit a read-only filesystem

One trade-off worth knowing: the image ships a Bootsnap cache precompiled at build time under /rails/tmp, and the emptyDir hides it, so every pod start recompiles it. That is boot time, not correctness — but it is also what makes the ephemeral-storage budget below matter.

An emptyDir is not a temporary directory Ruby accepts

Mounting /tmp as an emptyDir is not enough, and the failure is total rather than subtle: Kubernetes creates an emptyDir mode 0777 without the sticky bit, and Ruby's Dir.tmpdir skips exactly that shape. With the root filesystem read-only, no candidate is left and the app aborts during boot with ArgumentError: could not find a temporary directory — raised from MiniMagick while Rails is still loading.

The chart therefore runs a small init container (its own image, the same non-root uid, no added capability) that creates two directories inside each tmp emptyDir and chmod 700s them; the containers then run with TMPDIR=/tmp/ruby and HOME=/tmp/home. Creating an entry in a 0777 directory needs no privilege, and neither does chmod on a directory one has just created — which is why this needs none of the root-plus-CHOWN shape other charts use.

HOME is part of the same fix: /home/rails belongs to the read-only image, and Bundler answers that by building a throwaway home directory on every invocation — every bundle exec, including the worker's probe every 30 seconds.

Ephemeral storage is not boilerplate here

The scaffold's ephemeral-storage: 10Mi (limit 30Mi) made the kubelet evict the pod about two minutes into every start: Pod ephemeral local storage usage exceeds the total limit of containers 60Mi. An eviction, not a crash — the container log shows a healthy boot and says nothing about it.

Both emptyDirs and the container's stdout log count against that limit, and the Bootsnap cache mentioned above is rebuilt into one of them on every start. The chart requests 256Mi per app container (limit 768Mi); that is headroom, not a measured steady state. If a release turns out to need more — large imports write through /tmp — raise sure.web.resources.requests.ephemeral-storage rather than waiting for the next eviction.

The startup warning about unencrypted data

Every start logs [SECURITY] ActiveRecord Encryption is NOT configured … stored UNENCRYPTED at rest. The warning counts only keys set explicitly, through ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions or through Rails credentials; the self-hosted fallback that derives them from SECRET_KEY_BASE does not satisfy it. Setting the three variables through sure.web.env / sure.worker.env is what silences it — and it is also what decouples the encryption keys from SECRET_KEY_BASE, which is worth doing before the first start for the reason in the next section.

ingress.domain is read four times

Once as the Ingress host and three more times in the environment of both containers: APP_DOMAIN (the host Action Mailer builds absolute URLs from), WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS. It therefore keeps its own required() outside the Ingress template and stays mandatory with ingress.enabled: false — the §12.1 case. Without that guard the app would come up with an empty relying party id and register passkeys against it, rendered cleanly and with a green pod.

Changing the domain of a running instance invalidates every registered passkey, because the relying party id is part of the credential.

Rotating SECRET_KEY_BASE destroys more than sessions

In self-hosted mode Sure derives its Active Record encryption keys from SECRET_KEY_BASE — three SHA-256 digests over that one value (config/initializers/active_record_encryption.rb) — unless ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions are set explicitly. Every encrypted column (provider tokens, OAuth credentials, MFA secrets) is then readable only with the value it was written under. Replacing it does not just log everyone out: it makes that data undecryptable, and no rotation brings it back.

Treat the field as permanent for the life of the database, or pin the three encryption keys through sure.web.env / sure.worker.env before the first start.

Rotating a secret does not restart the pod

Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9). The pod template carries checksum/secret-ref annotations over the three rendered OnePasswordItems, so pointing a *SecretRef at a different item rolls the pod; editing a value inside the same item does not. After such a change:

kubectl -n <namespace> rollout restart statefulset/<release>-sure-sfs

The operator's operator.1password.io/auto-restart annotation is deliberately not set: it restarts Deployments only, so on a StatefulSet it would advertise a guarantee that does not exist.

The chart configures no mail and no AI

No SMTP_* variable and no OPENAI_* variable is set, so invitations and notifications are silently never sent and the assistant stays off — nothing in the UI says so. Both are plain environment variables and belong in sure.web.env, plus sure.worker.env for anything a background job needs (the assistant runs in the worker). secretKeyRef is available for the API key and the SMTP password.

Modelling the AI options as chart values was considered and rejected: they are a dozen variables that the app reads directly, and a values surface for them would have to be maintained against every upstream change without buying anything the env block does not already give.

The alpha channel is not tracked

The registry carries releases (0.7.4), prereleases (-alpha.N, -rc.N, -hotfix.N), the moving names stable, latest (which upstream calls the alpha channel) and nightly, and one sha-<commit> tag per build. The chart pins the release tag, and Renovate uses versioning: semver for it: prereleases are unstable and never offered, the moving names and sha- tags are unparseable and never selected.

It covers both containers, because a policy selects pods and not containers

This chart runs a web container and a worker in one pod. The worker declares no port and its probes are exec probes, so it contributes nothing to the ingress rules — but it is fully subject to the egress ones, which is where it does all of its work. One object governs both.

The Postgres host and port are fields of the 1Password item, passed through to the application without the chart reading them, so 5432 is still a convention rather than configuration.

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.

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

  • Back up the database before every app-version bump. Migrations are one-way; a helm rollback returns the old image to a database the new one has already rewritten.
  • Raise sure.web.startupProbe.failureThreshold before a large jump or a first import, not after the crash loop has started: a restart lands in the middle of a migration.
  • Backup is two things at once. The database holds the accounts and one row per attachment, the PVC holds the bytes. Snapshot them together. Because the PVC is a chart object, helm uninstall deletes it — the external database survives, which makes an accidental uninstall look recoverable while the files are already gone.
  • The first account created on a fresh instance becomes the super admin. Sign in yourself before handing out the URL.

CI

ci/sure/ holds test-values.yaml (op:// refs against the stub CRD, sure.ci.local), fixtures.yaml (throwaway Postgres 16 — the version upstream's compose file uses — plus Redis and the dummy secrets) and settings.env with INSTALL_TIMEOUT=900, because the rollout wait has to outlast a first start that creates the whole schema. Measured on a green run: the pod was Ready about 80 seconds after helm install, so the budget is deliberately generous rather than tight. 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/sure.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