Files
jklein 46942db53a searxng: new chart (1.0.0+up2026.8.22.9fea41204)
SearXNG as a stateless Deployment plus a settings ConfigMap, with the four CI
steps a new chart needs: publish workflow, change-app-version choice list,
ci/searxng/ fixtures and a renovate.json5 block.

Three points where the chart is not a cyberchef with another name:

* The image entrypoint writes settings.yml into /etc/searxng only when the
  file is missing. The chart always mounts one by subPath, so the copy and
  the random-secret sed never run and the container needs no write access
  there - which is what allows readOnlyRootFilesystem: true.
* The signing key is a required OnePasswordItem, not a ConfigMap key. It
  signs the settings cookie and the image-proxy URLs, and the entrypoint's
  self-generating fallback cannot fire here, so without it the instance would
  run on the literal "ultrasecretkey" - which webapp.py refuses with exit 1.
* All three probes use /healthz, the only path the bot detection lets through
  unconditionally. Measured: with the limiter active, "/" answers 429 while
  /healthz still answers 200.

ingress.domain is read a second time outside the Ingress template, for
SEARXNG_BASE_URL, and carries its own required() there - so it stays
mandatory with the Ingress switched off.

The Renovate versioning regex deviates from the plan on the issue and matches
the commit hash WITHOUT capturing it. Renovate parses a "build" group with
Number.parseInt, so a hash becomes the digits leading it; measured on
renovate 41.173.1 and 44.48.0, the planned form reports an image pushed
earlier the same day as an update to a newer one. Reasoning in the block and
in README.
2026-08-27 21:09:59 +02:00

170 lines
9.2 KiB
YAML

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
spec:
replicas: {{ include "searxng.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.searxng.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
annotations:
# Hash of the rendered ConfigMap: a changed settings.yml changes the
# pod template and thus rolls the pods. Without it the change would
# never reach the running container - settings.yml is a subPath mount,
# which the kubelet never refreshes, and SearXNG reads its
# configuration once at start.
checksum/config: {{ include (print $.Template.BasePath "/searxng.configmap.yaml") . | sha256sum }}
# Hashes over the rendered OnePasswordItems. Separately named on
# purpose: they only promise that the config SOURCE changed. Repointing
# a *SecretRef at another vault item rolls the pods; rotating the value
# inside the same item does not, because Helm never sees the content
# (the operator fills the Secret in-cluster) and the values are consumed
# as environment variables, which Kubernetes never refreshes in a
# running container. After such a rotation, restart by hand.
checksum/secret-ref: {{ include (print $.Template.BasePath "/searxng.secret.yaml") . | sha256sum }}
checksum/secret-ref-valkey: {{ include (print $.Template.BasePath "/searxng-valkey.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "searxng.podSecurityContext" .Values.searxng.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment
# Registry written out in full on purpose: an unqualified reference
# would put "searxng" in the registry position, which says nothing
# about where the image comes from and is reported as an untrusted
# registry (AVD-KSV-0125). See ci/trivy/data/ksv0125.yaml.
image: "docker.io/searxng/searxng:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
env:
# The signing key for the settings cookie and the image-proxy URLs.
# Set by the chart rather than left to searxng.env, because SearXNG
# exits 1 when server.secret_key is still the upstream placeholder
# and the image's self-generating fallback cannot fire here (it
# only runs when settings.yml is missing, and this chart always
# mounts one).
- name: SEARXNG_SECRET
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-secret"
key: "secret-key"
# server.base_url. This is the SECOND read of ingress.domain, well
# outside the Ingress template, so it carries its own required()
# (README, "Values that must not lie", point 1). Without it, an
# install with ingress.enabled: false would render base_url as
# "https:///" - the pod would go green while the OpenSearch
# description, every signed image-proxy URL and every absolute link
# pointed at a broken address.
- name: SEARXNG_BASE_URL
value: "https://{{ required "A Domain/Host must be provided (ingress.domain), it is used for SEARXNG_BASE_URL even when the Ingress is disabled" .Values.ingress.domain }}/"
{{- if .Values.secrets.valkeySecretRef }}
# Only rendered when an external Valkey/Redis is configured. The
# limiter is what needs it; see settings.limiter in values.yaml.
- name: SEARXNG_VALKEY_URL
valueFrom:
secretKeyRef:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-valkey-secret"
key: "valkey-url"
{{- end }}
{{- range $value := .Values.searxng.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# quote is required, not cosmetic: a value like "8080" or "true"
# renders unquoted as a YAML number/bool, and the API server then
# rejects the whole manifest with "cannot unmarshal number into
# Go struct field EnvVar.value of type string". Reachable with
# the very first entry someone adds here - SEARXNG_LIMITER and
# GRANIAN_PORT are both of that shape.
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name | quote }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
ports:
# 8080 is the image's own GRANIAN_PORT and its EXPOSE. It is not a
# value, because a value here could lie: changing it would move the
# containerPort and the probes while granian kept listening on
# 8080. The way to move the port is to set both GRANIAN_PORT (or
# its SEARXNG_PORT alias, which the entrypoint maps onto it) in
# searxng.env and this number - which means a chart change, on
# purpose.
#
# The port NAME is load-bearing and must not be renamed: the
# Service selects it by name and so do all three probes.
- containerPort: 8080
protocol: TCP
name: http
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "searxng.livenessProbe" .Values.searxng.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "searxng.readinessProbe" .Values.searxng.readinessProbe | nindent 12 }}
startupProbe:
{{- include "searxng.startupProbe" .Values.searxng.startupProbe | nindent 12 }}
resources:
{{- include "searxng.effectiveResources" .Values.searxng.resources | nindent 12 }}
securityContext:
{{- include "searxng.containerSecurityContext" .Values.searxng.securityContext | nindent 12 }}
volumeMounts:
- name: settings
mountPath: /etc/searxng/settings.yml
subPath: settings.yml
readOnly: true
# The two writable paths below are what keeps
# readOnlyRootFilesystem: true possible. They are image facts
# rather than user configuration, so they are not values.
- name: tmp
# NOT optional, and not obvious: SearXNG keeps three SQLite
# caches (data, engines, favicons) and searx/cache.py falls back
# to tempfile.gettempdir() when no db_url is configured - i.e.
# /tmp. webapp.py init() connects to two of them before the
# first request, so a read-only /tmp is not a degraded cache, it
# is a container that never starts.
mountPath: /tmp
- name: cache
# The image's __SEARXNG_DATA_PATH. The entrypoint aborts with
# exit 127 if the directory is missing, so it has to exist -
# which it does even unmounted, since the image ships it. It is
# mounted anyway and deliberately: nothing in this version writes
# there, but it is the path the application declares as its data
# directory, and the day a release starts using it a read-only
# root filesystem would turn that into a crash loop.
#
# EXPECTED, not a fault: fsGroup leaves this emptyDir owned
# root:977, so the entrypoint prints a multi-line "!!! WARNING
# ... is not owned by searxng:searxng" and carries on. It cannot
# chown as a non-root process and does not need to - the group
# bit is enough.
mountPath: /var/cache/searxng
volumes:
- name: settings
configMap:
name: "{{ .Release.Name }}-{{ .Chart.Name }}-configmap"
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
restartPolicy: Always