hort-server Helm chart — values reference

Per-key reference for deploy/helm/hort-server/values.yaml. For the install path see install.md; for edge-shape choices see examples-overlays.md; for the security posture see security-hardening-checklist.md.

The chart's values.yaml carries inline doc-comments above every top-level key. Those comments are short pointers; the canonical rationale for every HORT_* env var lives in crates/hort-server/src/config.rs. Where this document and config.rs disagree, config.rs wins and this document is the bug.

For the binary-level surface this chart renders into — every hort-server / hort-worker env var and CLI subcommand with its default, required-ness, and startup interlocks — see the server & worker configuration reference. This document maps a subset of those env vars to Helm values; the reference is the complete list.

scripts/check-values-comments.sh (wired into CI) asserts every top-level key in values.yaml has a comment block above it. values.schema.json enforces required-vs-optional and cross-field invariants at helm install time (14 numbered cross-field rules, some with sub-parts — 1, 2, 3, 4, 4b, 4c, 4d, 5, 6, 7, 8a, 8b, 9a, 9b).

The schema is strict (see ADR 0029): additionalProperties: false is set on the top-level object and every nested object block the chart owns the shape of, so an unknown, mistyped, or retired key fails helm install/helm template with a clear Additional property <key> is not allowed error instead of being silently accepted and ignored. A typo like worker.scanner.osvv, a retired key like apiBindAddr / worker.scanner.osvScanner / http.ociUploadTimeoutSeconds, or a top-level replicaCountt is caught at install time, not discovered at runtime when the intended setting turns out to have had no effect. Free-form passthrough blocks the operator owns the shape of (resources, probes.*, affinity, nodeSelector, the *SecurityContext blocks, gitopsConfig, and the verbatim Kubernetes array entries such as extraEnv / extraVolumes / networkPolicy.ingress) stay permissive by design — the strict lint guards the chart's own config keys, not arbitrary pod-spec content.


1. Security-relevant values mapping

The security hardening controls introduced knobs the chart exposes as values keys. The table below cross-walks each env var to its values key, chart default, and binary default; see security-hardening-checklist.md for the full rationale behind each control.

controlenv var(s)values keychart defaultbinary default
Auth providerHORT_AUTH_PROVIDERauth.provideroidcdisabled (see caveat)
Request timeoutHORT_HTTP_REQUEST_TIMEOUT_SECShttp.requestTimeoutSeconds300300
Header-read timeoutHORT_HTTP_HEADER_READ_TIMEOUT_SECShttp.headerReadTimeoutSeconds1515
OCI upload timeoutHORT_HTTP_OCI_UPLOAD_TIMEOUT_SECSoci.uploadTimeoutSeconds36003600
Metrics authHORT_METRICS_REQUIRE_AUTHmetrics.requireAuthtruetrue
Metrics bindHORT_METRICS_BINDmetrics.bindAddr127.0.0.1:9090unset
Metrics unspecified-bind guardHORT_METRICS_PUBLIC_BINDmetrics.allowUnspecifiedBindfalsefalse
Two-role PostgresHORT_DATABASE_URL (split by role; chart injects this canonical name, binary falls back to bare DATABASE_URL)postgres.app.existingSecret + postgres.admin.existingSecret(required)(no default)
Concurrency capHORT_MAX_INFLIGHThttp.maxInflight0 (binary default)512
Per-IP concurrency capHORT_MAX_INFLIGHT_PER_IPhttp.maxInflightPerIp0 (binary default)32
API bindHORT_API_BINDapi.bindAddr0.0.0.0:8080127.0.0.1:8080
Require HTTPSHORT_REQUIRE_HTTPSrequireHttpstruefalse
Public base URLHORT_PUBLIC_BASE_URLpublicBaseUrl(required)(no default)
Secret file rootHORT_SECRETS_FILE_ROOTsecrets.fileRoot/etc/hort-server/secrets(none)
Graceful shutdownHORT_SHUTDOWN_GRACE_SECSshutdown.gracefulSeconds6060
OCI upload-session capHORT_OCI_MAX_SESSIONS_PER_PRINCIPALoci.maxSessionsPerPrincipal0 (binary default)32

Four caveats:


2. Per-upstream mTLS surface

repository_upstream_mappings carries four columns for per-upstream TLS posture: mtls_cert_ref, mtls_key_ref, ca_bundle_ref, pinned_cert_sha256. Each is a SecretPort ID resolved via the mounted-file secret adapter at fetch time.

The chart surface is two values keys:

The full plumbing — gitops ArtifactRepository YAML through Secret to mount to RepositoryUpstreamMapping row — lives in wire-secrets.md. This document only covers the chart-side surface.


3. Per-key reference

Subsections follow values.yaml order. For each key:

image

Container image reference. The chart does not enforce image signatures itself; admission control (Kyverno / sigstore policy-controller / Connaisseur) verifies against image.cosign.publicKey if set.

image.repository and worker.image.repository default to the public distribution images (ghcr.io/project-hort/hort-server / ghcr.io/project-hort/hort-worker), published by the GitHub release pipeline. A bare helm install pulls these public images with no override. Override only to point at a private registry mirror or a locally-built image.

sub-keytypedefaultrequirednotes
image.repositorystringghcr.io/project-hort/hort-servernooverride only for a private mirror
image.tagstring""noempty resolves to .Chart.appVersion
image.pullPolicyenumIfNotPresentnoAlways / IfNotPresent / Never
image.pullSecretslist[]norequired for private registries
image.cosign.publicKeyPEM string""noconsumed by deploy-time policy controller

Example:

image:
  repository: registry.example.com/hort/hort-server
  tag: 0.9.12-dev
  pullSecrets: [{name: ghcr-pull}]

replicaCount

publicBaseUrl

api.bindAddr

requireHttps

trustedProxyCidrs

> Footgun — do not trust the whole pod CIDR. trustedProxyCidrs > authorises a peer to forge client_ip via X-Forwarded-For. > Listing the cluster-wide pod network (e.g. a whole /16) means > any pod in the cluster — including a compromised or hostile > tenant workload — can spoof its source IP past rate-limiting, > fail2ban, and audit attribution. Scope the list to the > gateway/ingress pods — see the "Rightmost-untrusted X-Forwarded-For" > section in the security hardening checklist > for the matching operator action. > > The edge proxy MUST actually set X-Forwarded-For. Trusting a > peer does not synthesise the header. If a trusted peer reaches the > binary without X-Forwarded-For (proxy not configured to add it, > or it is stripped en route), client_ip degrades to the > 0.0.0.0 sentinel and a throttled WARN is logged — the > (XFF_MISSING_SENTINEL in > crates/hort-http-core/src/middleware/trust.rs). All callers through > that proxy then share the one sentinel bucket and per-client > attribution is lost until the proxy is fixed. Confirm your ingress > sets X-Forwarded-For (most controllers do by default; verify > after any custom proxy_set_header / header-rewrite config) and > watch for the trusted peer with missing X-Forwarded-For WARN.

auth

Authentication configuration. Default is oidc; install fails (via schema Rule 2) if auth.oidc.issuerUrl and auth.oidc.audience are unset when provider: oidc. disabled is allowed but emits a prominent post-install NOTES.txt warning.

sub-keytypedefaultrequired
auth.provideroidc / disabledoidcyes
auth.oidc.issuerUrlstring""yes when provider: oidc
auth.oidc.audiencestring""yes when provider: oidc
auth.oidc.groupsClaimstringgroupsno
auth.oidc.jwksCacheTtlSecondsinteger600no (maps to HORT_JWKS_CACHE_TTL_SECS)
auth.tokenExchange.enabledbooleanfalseno
auth.tokenExchange.cliClientIdstring""yes when tokenExchange.enabled: true
auth.nativeTokens.enabledbooleanfalseno
auth.nativeTokens.signingKey.existingSecretstring""yes when nativeTokens.enabled: true
auth.nativeTokens.signingKey.secretKeystringhort-oci-token-signing-key.pemno
auth.nativeTokens.signingKey.prevExistingSecretstring""no (rotation window)
auth.nativeTokens.signingKey.prevSecretKeystringhort-oci-token-signing-key-prev.pemno

disabled is fail-closed in OCI (no synthetic admin), so the binary is safe; but a chart that defaults disabled is publishing the configuration the security audit worked hardest to close. Operators wanting a no-IdP eval path use deploy/compose/docker-compose.yml (which bundles Keycloak), not helm install.

The auth.oidc.jwksCacheTtlSeconds value still carries that name in the chart, but it now maps to the renamed HORT_JWKS_CACHE_TTL_SECS env var (the JWKS client tunables are unified under the HORT_JWKS_* prefix with _SECS durations — ADR 0029; the chart key is unchanged).

auth.tokenExchange.* gates POST /api/v1/auth/exchange (RFC 8693) and the anonymous discovery doc at GET /.well-known/hort-client-config (HORT_TOKEN_EXCHANGE_ENABLED). It requires auth.nativeTokens.enabled: true — the schema install-block rule and the binary boot gate (ConfigError::TokenExchangeRequiresNativeTokens) both enforce the coupling. auth.nativeTokens.* (HORT_NATIVE_TOKENS_ENABLED) gates the hort_(pat|svc|cli)_* PAT validator and the OCI /v2/auth JWT-minting path; when enabled it requires the signingKey.existingSecret ed25519 OCI token-signing key.

The prior auth.basic block and auth.lockout block are removed (the HORT_AUTH_LOCKOUT_* env vars powered the now-deleted authenticate_local HTTP-Basic-against-local-admin-row path — see docs/auth-catalog.md Entry 8). The PAT-side bearer-path brute-force protection (HORT_PAT_LOCKOUT_*, distinct mechanism inside PatValidationUseCase) is unchanged. A values file with auth.provider: basic, an auth.basic block, or an auth.lockout block is rejected by the strict values.schema.json at install/upgrade time.

Example:

auth:
  provider: oidc
  oidc:
    issuerUrl: https://keycloak.example.com/realms/hort
    audience: hort-server
    groupsClaim: realm_access.roles

http

HTTP transport tunables — connection-level timeouts and per-router load-shed limits. See http-transport-timeouts.md for operator-level depth on the timeout knobs.

sub-keytypedefaultrequired
http.requestTimeoutSecondsinteger300no
http.headerReadTimeoutSecondsinteger15no
http.maxInflightinteger0 (binary default 512)no
http.maxInflightPerIpinteger0 (binary default 32)no
http.publishBodyMaxSizesize string"" (binary default 300Mi)no

headerReadTimeoutSeconds closes Slowloris-style attacks at the connection layer. The OCI blob-upload per-route deadline lives under oci.uploadTimeoutSeconds (grouped with the other OCI surfaces; it still maps to HORT_HTTP_OCI_UPLOAD_TIMEOUT_SECS).

Beyond maxInflight, the load-shed layer returns 503 with no body and emits hort_http_responses_total{result="shed"}.

Override publishBodyMaxSize only when publishing artifacts above 300 MiB. It is a human-readable size string (e.g. "512Mi", "1Gi"); an empty string leaves it unset (binary default), and an explicit "0" is the refuse-all-publishes kill-switch. The env var is the size-string HORT_PUBLISH_BODY_MAX_SIZE — a size string (not a bare integer) so a multi-GiB value cannot round-trip through Helm's float64 coercion into scientific notation (a known boot-crash class; ADR 0029).

upstream

Streaming-fetch storage backstops on the pull-through path (the streaming-metadata contract, ADR 0026). Each cap bounds the per-fetch on-disk write; a trip emits a structured 502 with bytes_read + cap and the result=<metadata|manifest|version_object>_too_large metric label.

sub-keytypedefaultrequired
upstream.metadataCacheMaxSizesize string64Mino
upstream.manifestCacheMaxSizesize string16Mino
upstream.projectorVersionObjectMaxSizesize string2Mino

All three are human-readable size strings (64Mi, 1Gi, 512Ki, decimal 64M, or a bare byte integer — quote them), not integers, for the same float64-coercion reason as http.publishBodyMaxSize (ADR 0029). They map to HORT_UPSTREAM_METADATA_CACHE_MAX_SIZE, HORT_UPSTREAM_MANIFEST_CACHE_MAX_SIZE, and HORT_UPSTREAM_PROJECTOR_VERSION_OBJECT_MAX_SIZE. metadataCacheMaxSize caps the streamed fetch_metadata write (npm packument, PyPI JSON, Cargo sparse-index — largest known packument @types/node ~50 MiB, so 64Mi gives ~28% headroom); manifestCacheMaxSize caps the OCI manifest / index / attached-signature streamed write; projectorVersionObjectMaxSize caps a single version object inside the streaming JSON projector.

metrics

Prometheus scrape configuration.

sub-keytypedefaultrequired
metrics.bindAddrstring (host:port)127.0.0.1:9090no
metrics.allowUnspecifiedBindbooleanfalseno
metrics.requireAuthbooleantrueno
metrics.serviceMonitor.enabledbooleanfalseno
metrics.serviceMonitor.intervalduration30sno
metrics.serviceMonitor.scrapeTimeoutduration10sno
metrics.serviceMonitor.namespacestring"" (release ns)no

/metrics is always served by the binary. bindAddr controls where:

requireAuth: true (the default) requires admin authentication on the /metrics endpoint regardless of which listener serves it. Setting false re-permits anonymous scraping for legacy deployments and emits a startup WARN plus a prominent NOTES.txt post-install warning.

serviceMonitor.enabled: true requires the Prometheus Operator CRDs (monitoring.coreos.com/v1) installed in the cluster, AND bindAddr to be non-empty (the ServiceMonitor scrapes the dedicated metrics Service port). The chart fails helm template / helm install with an explicit message if these conditions are not met.

control

Internal-only control-plane listener. Moves the /admin API, /api/v1/admin/*, and /api/v1/subscriptions management routes onto a dedicated pod-internal listener so a NetworkPolicy can restrict them to the operator network. Mirrors metrics.bindAddr exactly. See control-plane-tiers.md.

sub-keytypedefaultrequired
control.bindAddrstring (host:port)""no
control.allowUnspecifiedBindbooleanfalseno

control.bindAddr maps to HORT_CONTROL_BIND. Empty (default) keeps the control routes on the main 8080 router — byte-identical to the no-split behaviour, no migration. Non-empty moves them onto a dedicated listener and exposes service.controlPort (default 9443). It follows the <subsystem>.bindAddr shape shared by api.bindAddr and metrics.bindAddr. control.allowUnspecifiedBind maps to HORT_CONTROL_PUBLIC_BIND — required to bind 0.0.0.0/:: (the same 0.0.0.0 footgun guard the metrics listener carries). The token-generation and artifact-pull planes are never moved here — they are public by requirement.

oci

sub-keytypedefaultrequired
oci.uploadTimeoutSecondsinteger3600no
oci.legacyCatalogEnabledbooleanfalseno
oci.maxSessionsPerPrincipalinteger0 (binary default 32)no

uploadTimeoutSeconds maps to HORT_HTTP_OCI_UPLOAD_TIMEOUT_SECS and is applied as a per-route override on PATCH /v2/.../blobs/uploads/<uuid> and the corresponding PUT. Raise it (and shutdown.gracefulSeconds with it) when uploading multi-gigabyte images on slow networks. This knob moved here from the retired http.ociUploadTimeoutSeconds key (HARD rename, no alias — ADR 0029; the env var is unchanged).

legacyCatalogEnabled opts in to the aggregating /v2/_catalog endpoint — only enable if an external client explicitly needs it.

maxSessionsPerPrincipal caps (repo_id, principal) outstanding upload sessions; beyond the cap, new session requests return 429 Too Many Requests and emit hort_upload_session_cap_rejections_total{format="oci", repo, result}.

shutdown

sub-keytypedefaultrequired
shutdown.gracefulSecondsinteger60no

Wraps with_graceful_shutdown in tokio::time::timeout. On timeout the runtime aborts outstanding join handles and emits a WARN carrying the in-flight count. Worst-case OCI long-tail uploads should complete in under oci.uploadTimeoutSeconds; if you raised that, raise this in tandem.

secrets

Surface for the mounted-file SecretPort adapter. See wire-secrets.md for the operator-side walk-through.

sub-keytypedefaultrequired
secrets.fileRootabsolute path/etc/hort-server/secretsno
secrets.mountslist of {name, secretName, key, path}[]no

secrets.fileRoot is the containment root for secret_ref resolution; anything resolved outside this root is rejected (symlink-escape protection). The adapter also requires file mode & 0o077 == 0; it logs a WARN for the K8s-default 0644 mode but does not refuse.

secrets.mounts projects one key from a Kubernetes Secret to a file under fileRoot. Used for upstream-registry credentials and the four per-upstream mTLS surface fields (mtls_cert_ref, mtls_key_ref, ca_bundle_ref, pinned_cert_sha256).

Example:

secrets:
  mounts:
    - name: dockerhub-password
      secretName: dockerhub-pull
      key: password
      path: upstream/dockerhub/password
    - name: ghcr-mtls-cert
      secretName: ghcr-mtls
      key: cert.pem
      path: upstream/ghcr/cert.pem
    - name: ghcr-mtls-key
      secretName: ghcr-mtls
      key: key.pem
      path: upstream/ghcr/key.pem

postgres

External-only Postgres. Two roles: admin (DDL) used by the migrations Job at install/upgrade time only, app (DML) used by the runtime Deployment with INSERT, SELECT only on events. Startup-time has_table_privilege probes refuse to start if the runtime role retains UPDATE / DELETE / TRUNCATE / REFERENCES on events. The chart does not bundle a Postgres subchart; eval/dev users use deploy/compose/docker-compose.yml.

sub-keytypedefaultrequired
postgres.app.existingSecretstring""yes (schema rule 5)
postgres.app.secretKeystringDATABASE_URLno
postgres.admin.existingSecretstring""yes (schema rule 6)
postgres.admin.secretKeystringDATABASE_URLno

Both existingSecret references point at Kubernetes Secrets in the chart's namespace whose secretKey value is a full postgres://user:...@host:5432/db DSN. The admin Secret is never mounted on the runtime Deployment.

secretKey is the key inside the Secret (default DATABASE_URL), independent of the container env-var name. The chart maps it to the env var HORT_DATABASE_URL on every pod (server, worker, migrate Job, and the DSN-direct CronJobs) — the canonical operator DSN var. The binary still honors bare DATABASE_URL as a fallback (sqlx-cli / Tier-2 tests / 12-factor), so changing secretKey only changes the Secret's data-key, not the env-var name the chart injects.

storage

Artifact CAS backend.

sub-keytypedefaultrequired
storage.backendfilesystem / s3filesystemyes
storage.filesystem.pvc.enabledbooleantrueno
storage.filesystem.pvc.sizequantity50Gino
storage.filesystem.pvc.storageClassNamestring"" (cluster default)no
storage.filesystem.pvc.accessModestringReadWriteOnceno
storage.s3.endpointstring""yes when backend: s3
storage.s3.regionstring""yes when backend: s3
storage.s3.bucketstring""yes when backend: s3
storage.s3.pathStylebooleantrueno
storage.s3.allowHttpbooleanfalseno
storage.s3.sseMode"" / bucket-default / sse256 / sse-kms""no
storage.s3.sseKmsKeyArnstring""yes when sseMode: sse-kms
storage.s3.existingSecretstring""yes when backend: s3

filesystem uses an RWO PVC (single replica only). s3 is required when replicaCount > 1 (RWO PVC cannot multi-attach; schema rule 8b).

pathStyle: true is required for MinIO / zot; AWS S3 supports both virtual-hosted and path style.

storage.s3.allowHttp (HORT_STORAGE_S3_ALLOW_HTTP) opts in to plain HTTP S3 endpoints (the object_store crate refuses HTTP by default); the binary's validator rejects it on https:// endpoints and on real AWS S3. Acceptable on a trusted cluster network because the application layer enforces integrity via SHA-256 CAS; never over the public internet.

storage.s3.sseMode (HORT_S3_SSE_MODE) selects the server-side encryption mode requested on puts: ""/bucket-default send no opinion (the bucket default applies; AWS S3 has applied SSE-S3 unconditionally since 2023), sse256 requests SSE-S3 (AES256), sse-kms requests SSE-KMS. When sse-kms, storage.s3.sseKmsKeyArn (HORT_S3_SSE_KMS_KEY_ARN) is required (the server refuses to start otherwise) and the KMS key must grant the S3 service kms:Encrypt/kms:Decrypt/kms:GenerateDataKey*.

storage.s3.existingSecret points at a Kubernetes Secret with keys AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY.

ephemeralStore

Ephemeral state backend (auth lockout counters, OCI session counts, JWKS cache).

sub-keytypedefaultrequired
ephemeralStore.backendmemory / redismemoryyes
ephemeralStore.memoryobject{}no
ephemeralStore.redis.urlstring""yes when backend: redis and existingSecret empty
ephemeralStore.redis.existingSecretstring""yes when backend: redis and url empty
ephemeralStore.redis.secretKeystringREDIS_URLno
ephemeralStore.redis.evictableUrlstring""no
ephemeralStore.redis.evictableExistingSecretstring""no
ephemeralStore.redis.evictableSecretKeystringREDIS_URL_EVICTABLEno
ephemeralStore.redis.durableUrlstring""no
ephemeralStore.redis.durableExistingSecretstring""no
ephemeralStore.redis.durableSecretKeystringREDIS_URL_DURABLEno

Multi-pod deployments must use Redis — memory cannot share state across pods. values.schema.json Rule 8a blocks replicaCount > 1 + memory. redis.url and redis.existingSecret are mutually exclusive (schema rule 4).

The optional evictable* / durable* keys (per-class keyspace routing) split Redis traffic onto dedicated instances: the evictable class (HORT_REDIS_URL_EVICTABLE — Cargo/PyPI/npm caches, pull-through dedup) and the durable class (HORT_REDIS_URL_DURABLE — auth lockout flags, OCI session records, auth-event throttle). Each falls back to redis.url when unset. Within a class, the plaintext *Url and the *ExistingSecret indirection are mutually exclusive (schema rules 4b / 4c).

Pull-through deduplication

Two-layer request coalescing covers every upstream pull-through path (Cargo, npm, PyPI, OCI). The chart does not yet expose named values for this feature; the binary reads seven HORT_PULL_DEDUP_* env vars. Set them via extraEnv until the chart exposes named keys.

env vardefaultpurpose
HORT_PULL_DEDUP_TTL_NOT_FOUND_SECS30Negative-cache TTL for upstream 404 responses. Followers see the same 404 for this window without re-querying upstream.
HORT_PULL_DEDUP_TTL_UNAVAILABLE_SECS10Short-cache TTL for upstream 5xx and 429. A single rate-limit burst against the upstream registry produces one upstream request and N short-cached 502 Bad Gateway responses — set this conservatively to avoid amplifying transient upstream issues.
HORT_PULL_DEDUP_TTL_TIMEOUT_SECS10Short-cache TTL for upstream timeouts and network errors. Same rationale as UNAVAILABLE.
HORT_PULL_DEDUP_TTL_CHECKSUM_MISMATCH_SECS60Short-cache TTL for upstream-checksum-mismatch outcomes. Longer than UNAVAILABLE because checksum mismatch is a content-integrity signal, not a transient transport issue — re-fetching immediately wastes bandwidth on the same poisoned upstream.
HORT_PULL_DEDUP_FOLLOWER_WAIT_SECS300Maximum wall-time a follower waits for the leader's fetch to complete (Layer B / cluster-wide path). On expiry, the follower returns 502 Bad Gateway with a leader-timeout reason. Set higher than your slowest expected upstream fetch — defaults safe for OCI image-layer pulls under typical latency.
HORT_PULL_DEDUP_LEADER_TIMEOUT_SECS600Hard ceiling on the leader's own fetch (issue #55); on expiry the leader is abandoned and the next caller elects fresh.
HORT_PULL_DEDUP_LEADER_LOCK_TTL_SECS90Cluster-wide leader-lock lease (issue #65). A healthy heartbeat (renewing every ttl / 3) keeps a legitimately-slow leader's lock alive regardless of this value; it only bounds how long followers wait to detect a genuinely-dead leader.
HORT_PULL_DEDUP_LEADER_TIMEOUT_SECS600Hard ceiling on the leader's own fetch (issue #55). On expiry the leader is abandoned — a Failed(Timeout) terminal is written and the Layer-A entry evicted so the next caller elects fresh, converting a wedged leader from "poisoned until process restart" into "self-heals within the deadline." Must stay above HORT_STORAGE_PUT_TIMEOUT_SECS (default 300s) — the leader closure's slowest leg — or legitimately-slow large pulls get abandoned.

Coalescing has no chart-level toggle: it is always on because the in-memory EphemeralStore adapter is functionally identical to Redis at this scale (sub-microsecond put_if_absent). Single-replica deployments get coalescing for free.

The keyspace prefix is pulldedup:, registered as Evictable in the KEYSPACE_REGISTRY — losing a coalescing record under memory pressure converts at worst into a duplicate upstream fetch, never into a correctness violation. If a sustained pull-burst workload starts evicting longer-lived records (auth lockout, OCI session caps), split the keyspace onto a dedicated Redis instance via the per-class routing primitive (HORT_REDIS_URL_EVICTABLE).

Observable surface: hort_pull_dedup_total{layer, format, outcome} counter and hort_pull_dedup_wait_seconds{layer, format} summary. Both are emitted whether or not the chart values are set; full label schema is in docs/metrics-catalog.md.

extraEnv:
  - name: HORT_PULL_DEDUP_TTL_UNAVAILABLE_SECS
    value: "30"          # tighter rate-limit absorbing
  - name: HORT_PULL_DEDUP_FOLLOWER_WAIT_SECS
    value: "120"         # smaller upstream — tighter follower bound

serviceAccount

sub-keytypedefaultrequired
serviceAccount.createbooleantrueno
serviceAccount.namestring"" (generated from chart fullname)no
serviceAccount.annotationsmap[string]string{}no

annotations are used to attach EKS IAM-roles-for-ServiceAccounts or GKE Workload Identity bindings.

serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/hort-server

podSecurityContext and containerSecurityContext

PSS-restricted defaults. The chart applies these unconditionally; older clusters lacking PSS-restricted admission need overrides.

sub-keytypedefault
podSecurityContext.runAsNonRootbooleantrue
podSecurityContext.runAsUserinteger65532 (distroless nonroot)
podSecurityContext.runAsGroupinteger65532
podSecurityContext.fsGroupinteger65532
podSecurityContext.seccompProfile.typestringRuntimeDefault
containerSecurityContext.allowPrivilegeEscalationbooleanfalse
containerSecurityContext.readOnlyRootFilesystembooleantrue
containerSecurityContext.capabilities.droplist[ALL]

The container image is built to run as UID 65532.

resources

sub-keytypedefault
resources.requests.cpuquantity250m
resources.requests.memoryquantity256Mi
resources.limits.cpuquantity1000m
resources.limits.memoryquantity1Gi

Defaults are sized for a single-replica light-traffic deployment; tune up for production.

probes

Wired to the binary's /healthz and /readyz endpoints.

probepathinitialDelayperiodtimeoutfailureThreshold
liveness/healthz101535
readiness/readyz5523
startup/healthz05260 (300 s budget)

The 300 s startup budget (60 × 5 s) accommodates the worst-case cold OIDC discovery + JWKS fetch on a slow network. The startup probe runs first; liveness/readiness only take over once it succeeds.

podDisruptionBudget

sub-keytypedefaultrequired
podDisruptionBudget.enabledbooleanfalseno
podDisruptionBudget.minAvailableinteger or percent1no

When replicaCount > 1, the chart auto-renders a PDB regardless of enabled. Set enabled: true to render at replicaCount: 1 (rare; typical single-replica deployments accept eviction).

networkPolicy

sub-keytypedefaultrequired
networkPolicy.enabledbooleantrueno
networkPolicy.ingresslist (NetworkPolicy ingress rules)[]no
networkPolicy.egresslist (NetworkPolicy egress rules)[]no

Default-on (flipped from the previous default off). Set networkPolicy.enabled: false to opt out entirely (documented escape hatch — e.g. a mesh AuthorizationPolicy already governs the namespace). With it enabled, an empty ingress / egress list renders a policy that selects the policyType with zero rules ⇒ stock Kubernetes denies all traffic in that direction — the pod is unreachable / cannot egress until you populate rules (there is no implicit namespace baseline). Populate: public 8080 from the artifact/token-gen clients, and — when control.bindAddr is set — service.controlPort (9443) restricted to the operator namespace. Egress: open to Postgres / S3 / Redis / OIDC issuer / upstream registries plus the known webhook-forwarder set. A separate Job-scoped policy auto-renders to grant the migrate/bootstrap Jobs DNS + egress. See control-plane-tiers.md for a worked three-tier example.

extraEnv / extraVolumes / extraVolumeMounts

Escape hatches for any HORT_* knob the chart does not yet surface as a top-level key, plus the Volume / VolumeMount pair for any sidecar or extra mount. Use sparingly — prefer raising a chart issue when a new knob is needed.

keytypedefault
extraEnvlist of EnvVar objects[]
extraVolumeslist of Volume objects[]
extraVolumeMountslist of VolumeMount objects[]

Example:

extraEnv:
  - name: HORT_RBAC_REFRESH_SECS
    value: "60"

nodeSelector / tolerations / affinity / topologySpreadConstraints

Standard Kubernetes scheduling primitives, passed through to the Deployment pod-spec verbatim.

keytypedefault
nodeSelectormap[string]string{}
tolerationslist of Toleration objects[]
affinityAffinity object{}
topologySpreadConstraintslist of TopologySpreadConstraint objects[]

When replicaCount > 1, consider adding a topology.kubernetes.io/zone constraint so replicas spread across availability zones.

service

sub-keytypedefault
service.typestringClusterIP
service.httpPortinteger8080
service.metricsPortinteger9090
service.controlPortinteger9443
service.annotationsmap[string]string{}

type: ClusterIP is the only fully-tested type; LoadBalancer works but the operator typically owns the edge via an Ingress / Gateway resource (see examples-overlays.md). service.controlPort is the container/Service port for the dedicated control-plane listener — only exposed when control.bindAddr is non-empty (mirrors how metricsPort is gated on metrics.bindAddr).

extraCaBundle

Process-wide extra CA trust bundle (ADR 0010). Controls whether the binary loads additional X.509 trust anchors at boot time for all outbound TLS connections: upstream proxy requests, S3/MinIO storage, and OIDC discovery and JWKS. See extra-ca-bundle.md for operator recipes.

sub-keytypedefaultrequired
extraCaBundle.pathstring (absolute path in container)""conditional
extraCaBundle.configMapNamestring (ConfigMap name)""conditional
extraCaBundle.secretNamestring (Secret name)""conditional

Failure semantics (fail-closed): If path is set but the file is unreadable, malformed, or contains zero parseable certificates, the binary refuses to start with a ConfigError (server) or a named fatal that points at the missing mount (worker). A misconfigured trust knob never silently degrades to untrusted-CA behaviour.

Auto-mount sources. configMapName and secretName are mutually-exclusive auto-mount sources (a pod can mount only one ca.crt at path); setting both fails the render at helm install. Either one, when set, requires a non-empty path and makes the chart mount the bundle read-only (0444) at path AND set HORT_EXTRA_CA_BUNDLE — symmetrically on the hort-server Deployment, the hort-worker Deployment, and the server-runtime CronJobs.

Rendering states (mount/secret symmetry — the chart never sets the env on a pod it did not mount the bundle onto):

Example (Secret recipe):

extraCaBundle:
  path: /etc/hort-server/ca-bundle/ca.crt
  secretName: corporate-ca-bundle-secret   # or configMapName: …

gitopsConfig

gitopsConfig:
  "auth/oidc.yaml": |
    mappings:
      - claim: groups
        value: hort-admins
        role: admin
  "repos/ghcr-mirror.yaml": |
    apiVersion: project-hort.de/v1
    kind: ArtifactRepository
    metadata:
      name: ghcr-mirror
    spec:
      format: oci
      type: proxy
      proxy:
        upstreamUrl: https://ghcr.io

scheduledTasks

ALL periodic/scheduled tasks live under this one tree (ADR 0029). Previously the set was split — five tasks at the top level and the rest nested under cronJobs.* — leaking the execution-path implementation detail into the config-tree location. That is collapsed: every task is here, and each carries an executionPath attribute (dsn-direct | admin-task) recording how it is invoked. The prior cronJobs.* block and the five top-level task keys are retired (HARD rename, no alias) — the strict schema rejects them at install.

Two execution paths:

Top-level gating keys:

sub-keytypedefaultrequired
scheduledTasks.adminTasksEnabledbooleanfalseno
scheduledTasks.svcTokenKubectlImagestringbitnamilegacy/kubectl:1.30no
scheduledTasks.rotateSvcTokenbooleanfalseno

adminTasksEnabled is the single opt-in surface for every executionPath: admin-task task — it replaces the retired cronJobs.enabled. svcTokenKubectlImage / rotateSvcToken replace the retired cronJobs.kubectlImage / cronJobs.rotateSvcToken.

Each task block carries executionPath (string), enabled (boolean), and schedule (cron string); some carry extra parameters. The full set as of HEAD:

taskexecutionPathdefault enableddefault scheduleextra params
scrubdsn-directtrue0 3 * * *samplingRate, concurrency, actionOnMismatch
quarantineReleaseSweepdsn-directtrue*/5 * * * *
prefetchTickdsn-directfalse*/15 * * * *
prefetchRowRetentionSweepdsn-directfalse0 2 * * *
wheelMetadataBackfilldsn-directfalse0 4 * * 0batchSize
noopadmin-taskfalse0 0 * * *
stagingSweepadmin-taskfalse*/15 * * * *
cronRescanTickadmin-taskfalse*/5 * * * *
advisoryWatchTickadmin-taskfalse0 */6 * * *
retentionEvaluateadmin-taskfalse0 3 * * *
retentionPurgeadmin-taskfalse0 4 * * *
eventstoreArchiveadmin-taskfalse0 5 * * 0
serviceAccountRotationadmin-taskfalse*/15 * * * *
eventstoreCheckpointadmin-taskfalse0 * * * *
replaySeenPruneadmin-tasktrue0 * * * *
verifyEventChainadmin-taskfalse0 2 * * *
scannerRegistryPruneadmin-tasktrue0 * * * *

replaySeenPrune and scannerRegistryPrune are the only admin-task tasks that default enabled: true (both run once adminTasksEnabled is flipped). scrub.actionOnMismatch (HORT_CAS_SCRUB_ACTION_ON_MISMATCH) is also read by the main Deployment, so it is load-bearing even with scrub.enabled: false. The schedule floor is 5 minutes (the admin-task Idempotency-Key uses minute-granularity windows).

serviceAccountRotation.enabled is the single source-of-truth toggle for fallback PAT rotation (the prior two-place switch is collapsed). When true it drives both the CronJob and the worker-side wiring; the schema requires adminTasksEnabled: true and a non-empty worker.rotation.publicRegistryHost (fail-fast on a half-set config). See rotating-service-account-tokens.md.

worker

The hort-worker Deployment — a separate pod from the server that claims jobs rows and dispatches each to its registered TaskHandler (scan, cron-rescan-tick, advisory-watch-tick, staging-sweep, etc.).

sub-keytypedefaultrequired
worker.enabledbooleantrueno
worker.replicasinteger1no
worker.workerIdOverridestring""no
worker.image.repositorystringghcr.io/project-hort/hort-workerno
worker.image.tagstring""no (empty ⇒ .Chart.appVersion)
worker.image.pullPolicyenumIfNotPresentno
worker.resourcesobject{cpu, memory}no
worker.scanner.pollIntervalSecsinteger5no
worker.scanner.batchSizeinteger4no
worker.scanner.maxAttemptsinteger5no
worker.scanner.lockDurationSecsinteger900no
worker.scanner.trivy.enabledbooleantrueno
worker.scanner.trivy.binarystring/usr/local/bin/trivyno
worker.scanner.trivy.dbDirstring/var/cache/trivyno
worker.scanner.osv.enabledbooleantrueno
worker.scanner.osv.binarystring/usr/local/bin/osv-scannerno
worker.advisory.osvUrlstringhttps://api.osv.dev/v1/querybatchno
worker.db.lockTimeoutMsinteger120000no
worker.serviceAccount.createbooleantrueno
worker.serviceAccount.namestring""no
worker.serviceAccount.annotationsmap{}no
worker.rotation.targetNamespaceslist[]no
worker.rotation.publicRegistryHoststring""conditional

worker.enabled: false stops the worker rendering; dispatched kind='scan' jobs then accumulate with no progress. Under quarantine-by-default that strands ingested artifacts in Quarantined unless the operator declares a ScanPolicy with scan_backends: [] (the explicit ScanWaived authority) — so the chart defaults enabled: true.

The scanner backend block is the parallel-named pair worker.scanner.{trivy, osv} (the prior osvScanner key was renamed osv to match trivy — ADR 0029). Each *.enabled is load-bearing: enabled: false makes the worker not register that backend even if the binary --version probe would pass — the flag is the enabling gate, the probe a secondary health check. Disabling both backends is a hard boot error (a scanner worker with nothing to scan); set worker.enabled: false instead.

worker.rotation.* carries the parameters of the single scheduledTasks.serviceAccountRotation.enabled toggle — targetNamespaces (per-namespace RBAC rendered for each) and publicRegistryHost (the dockerconfigjson.auths host, schema-required when rotation is enabled). There is no worker.rotation.enabled switch (the prior two-place toggle is collapsed). Worker-scoped extraEnv / extraVolumes / extraVolumeMounts / nodeSelector / tolerations / affinity mirror the top-level keys but apply to the worker pod only.


4. Cross-references