Kubernetes
InfraSigns ships a Helm 3 chart for production Kubernetes deployments.
Prerequisites
| Requirement | Notes |
|---|---|
| Helm 3.x | brew install helm or helm.sh |
| PostgreSQL 13+ | Accessible from inside the cluster (13+ is required — the schema uses gen_random_uuid() and declarative hash partitioning) |
| Prometheus | Accessible from inside the cluster |
| Kubernetes Secret | Containing sensitive env vars (see below) |
Create the secret
All sensitive values are injected at runtime from a Kubernetes Secret referenced by existingSecret. Create it before installing the chart:
kubectl create secret generic infrasigns-secrets \
--from-literal=DATABASE_DSN='postgres://user:password@postgres:5432/infrasigns?sslmode=require' \
--from-literal=LLM_API_KEY='sk-...' \
--from-literal=TELEGRAM_TOKEN='123456:ABC-...' \
--from-literal=TELEGRAM_CHAT_ID='-100123456789' \
--from-literal=SLACK_WEBHOOK_URL='' \
--from-literal=SLACK_TOKEN='' \
--from-literal=SMTP_USER='' \
--from-literal=SMTP_PASSWORD='' \
--from-literal=PAGERDUTY_ROUTING_KEY='' \
--from-literal=HEARTBEAT_URL='' \
--from-literal=API_TOKEN="$(openssl rand -hex 16)" \
--from-literal=WEBHOOK_TOKEN="$(openssl rand -hex 16)" \
--from-literal=INFRASIGNS_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
--from-literal=BOT_TELEGRAM_TOKEN='' \
--from-literal=BOT_SLACK_APP_TOKEN='' \
--from-literal=BOT_SLACK_TOKEN='' \
--from-literal=DO_TOKEN='' \
--from-literal=HETZNER_TOKEN='' \
--from-literal=PROM_TOKEN=''
Keys PAGERDUTY_ROUTING_KEY and HEARTBEAT_URL can be empty strings — empty values silently disable those channels. The two Slack keys are not in that set; see the Slack sentence below for what an empty value does there. PAGERDUTY_ROUTING_KEY supplies the PagerDuty Events API v2 integration key; empty disables the channel, and because unset keys expand to empty, a secret that omits it simply leaves PagerDuty off. PagerDuty is incident-only — it pages on incident firing/resolution (an inbound alert group, or a health check that fails and recovers) and never receives digests. Slack additionally requires choosing a transport in values via config.notify.slack.mode: set it to webhook and provide SLACK_WEBHOOK_URL, or to api and provide SLACK_TOKEN (a chat:write bot token) plus config.notify.slack.channel — the api transport posts incident updates as thread replies. With mode unset the whole Slack block is omitted and neither key is read, which is what the empty '' in the example above relies on. With mode set, an empty value is not a silent disable but a startup failure: the process refuses the config with notify.slack.webhook_url is required when mode is "webhook" (or the matching notify.slack.token message in api mode) and the pod does not start. A webhook_url that is present but not an http/https URL naming a remote host — a paste with the scheme dropped, say — is the opposite: it loads, builds no Slack channel, and is reported by a startup line and by infrasigns_notify_channel_deliverable. SMTP_USER/SMTP_PASSWORD supply email credentials for the SMTP channel (enabled via config.notify.email.smtp_host); set both to authenticate, or leave both empty for an unauthenticated relay. Setting only one is rejected at config validation — and because unset keys expand to empty, an email channel that needs auth but whose secret omits these keys silently sends unauthenticated. LLM_API_KEY can also be empty when config.llm.provider is none (the deterministic analysis engine needs no key). An empty or absent API_TOKEN disables the manual digest trigger endpoint (POST /api/digest/trigger) and the MCP server (/mcp). Both mount whether or not sign-in is configured, and the token is the whole gate: each one acts on this deployment's own organization and never on a tenant's, so whoever holds API_TOKEN is being trusted as the operator. TELEGRAM_CHAT_ID can be empty, and the pair is what enables delivery: Telegram is a delivery channel only when TELEGRAM_TOKEN and TELEGRAM_CHAT_ID are both set. A token with an empty chat ID is a deliberate, valid shape — the token is what the Q&A bot falls back to where that bot is enabled (config.bot.telegram.allowed_chat_ids), and there is no delivery channel — so the pod starts and stays ready, nothing is delivered to Telegram, and a startup line names notify.telegram.chat_id, the config key this Secret entry populates. Because unset keys expand to empty, a secret that simply forgot the chat ID lands in exactly that state. The startup line cannot tell the two apart — they are the same configuration — so it names both remedies and leaves the choice to you: set notify.telegram.chat_id (this Secret's TELEGRAM_CHAT_ID) to deliver, or move the token to bot.telegram.token, which on this chart means putting it in BOT_TELEGRAM_TOKEN and leaving TELEGRAM_TOKEN empty, if the Q&A bot is all this deployment wants. That is why the line is logged at info level; every other incomplete channel is a warning. Setting TELEGRAM_CHAT_ID while TELEGRAM_TOKEN is empty has no second reading and is a warning — leave both empty to switch Telegram off with no line and no series. BOT_TELEGRAM_TOKEN can be empty: the Q&A bot's Telegram transport (enabled via config.bot.telegram.allowed_chat_ids) falls back to TELEGRAM_TOKEN; set it only to run the bot as a separate Telegram identity. An enabled Telegram bot with BOTH keys empty fails config validation — unset keys expand to empty, so this catches a secret that forgot the token instead of silently polling with a dead credential. The Slack transport (enabled via config.bot.slack.allowed_channel_ids) needs BOT_SLACK_APP_TOKEN (the Socket Mode xapp-… app-level token — required, no fallback, so a secret that omits it fails validation) and BOT_SLACK_TOKEN (the xoxb-… reply token, which can be empty to fall back to SLACK_TOKEN).
INFRASIGNS_ENCRYPTION_KEY is the root key that encrypts credentials stored in the database — anything saved through the web UI: a source token, a basic-auth password, an SMTP password, a Slack webhook URL. It is 32 bytes of standard base64, which is what both openssl rand -base64 32 and infrasigns secrets keygen print. Like the notify keys it can be empty or absent, and unlike them the consequence is not a disabled feature: nothing is encrypted, and every stored credential sits in the clear in the org_configs table, readable from any dump, backup or read replica. The pod starts and stays ready either way, so a Secret that simply forgot this key gives no signal at all. Credentials that live in this Secret and reach the config as ${VAR} are unaffected in either direction — they never enter the database. Losing the key loses every stored credential: there is no recovery, and everything saved through the UI would have to be re-entered, so keep a copy somewhere other than beside the database backups (an archive holding both protects nothing).
Setting the key on an already-running deployment is safe and is not a breaking change: encryption engages on the key's presence, values already stored in the clear stay readable, and serve seals them on its next start (infrasigns secrets seal is the same pass on demand). Removing it again is not symmetric — run infrasigns secrets unseal --write-cleartext while the key is still configured, because only a process that can decrypt can write the values back out.
Rotating it is one restart plus one command: put the new key in INFRASIGNS_ENCRYPTION_KEY, add the old one to config.security.previous_encryption_key as a ${VAR} reference resolved from this same Secret, helm upgrade, then run infrasigns secrets rewrap and drop the old entry at the next deploy. To supply the key as a file instead — the shape the External Secrets Operator projects — leave this variable out and see below; setting both forms is refused at start-up by name rather than resolved by precedence.
DO_TOKEN is the DigitalOcean personal access token (read scope) referenced as ${DO_TOKEN} by a config.sources entry of type: digitalocean — see DigitalOcean. Unlike the notify keys, it can NOT be usefully empty: an omitted or empty DO_TOKEN expands to an empty token and that source fails to load (a required-credential fail-loud, not a silent disable). Omit the key entirely if you configure no DigitalOcean source.
HETZNER_TOKEN is the Hetzner Cloud API token (read scope) referenced as ${HETZNER_TOKEN} by a config.sources entry of type: hetzner — see Hetzner Cloud. Like DO_TOKEN, it can NOT be usefully empty: an omitted or empty HETZNER_TOKEN expands to an empty token and that source fails to load (a required-credential fail-loud, not a silent disable). Omit the key entirely if you configure no Hetzner source.
PROM_TOKEN is the bearer token for an authenticated Prometheus-compatible backend (Grafana Cloud, Dash0, Mimir, VictoriaMetrics), referenced as ${PROM_TOKEN} by a config.sources entry of type: prometheus (or loki). Unlike the DigitalOcean/Hetzner tokens it is OPTIONAL — an omitted or empty PROM_TOKEN expands to an empty token, and the source simply queries unauthenticated (the backend returns 401 if it requires auth). The env-var name is a convention: any name works as long as the source's token: '${…}' reference matches. Omit the key entirely if all your Prometheus/Loki sources are unauthenticated.
WEBHOOK_TOKEN enables the inbound alert webhook receiver (POST /webhook/alerts) and post-deploy verification (POST /webhook/deploys). Empty or absent, the receiver is disabled: since #558 the route is still mounted, but it answers 401 {"error":"unauthorized"} to every request and ingests nothing, so it is never reachable unauthenticated and its presence does not disclose whether you configured it — and a config with config.deploys.enabled: true fails validation. Non-secret webhook settings (dedupe_window, model) live under config.webhook in values; the token itself is accepted only via the secret (the chart schema rejects config.webhook.token).
DATABASE_DSN must reference a role that owns the schema (both the chart's migration job and serve's startup migration run on it). The tenant tables carry forced row-level security as defense-in-depth — see the database notes for the backup implication (pg_dump must run as a superuser or BYPASSRLS role) and the least-privilege groundwork.
External Secrets Operator
If you use AWS Secrets Manager, HashiCorp Vault, or similar, create an ExternalSecret that produces the same key set and reference it with existingSecret.
The encryption key has a second shape here, and it is the one that fits: ESO projects a secret as a mounted file, so rather than routing the key through INFRASIGNS_ENCRYPTION_KEY you can point an ExternalSecret at a Secret of its own and hand the chart its name.
encryptionKeySecret:
name: infrasigns-encryption-key # must already exist in the release namespace
key: encryption-key # the Secret key, and the file name in the pod
The chart mounts it read-only at /etc/infrasigns-keys/<key> in both the server and the migration Job — infrasigns migrate up opens the store and therefore reads the key file too, so a key mounted only into the Deployment would fail every upgrade at the pre-upgrade hook — and renders security.encryption_key_file pointing at that path. Only the named key is projected, so a Secret carrying other material does not spill it into the pod. To create the Secret by hand instead:
infrasigns secrets keygen > key
kubectl create secret generic infrasigns-encryption-key \
--namespace infrasigns --from-file=encryption-key=key
rm key
keygen writes the key to stdout and every word of guidance to stderr, so that redirect produces a file the chart can hand over as it stands.
If something other than this chart puts the file in the pod — a Vault agent sidecar injected through podAnnotations, say — set config.security.encryption_key_file to that path and leave encryptionKeySecret.name empty. Setting both fails the render rather than choosing between them.
Install
helm install infrasigns oci://ghcr.io/infrasigns/charts/infrasigns \
--namespace infrasigns --create-namespace \
--set existingSecret=infrasigns-secrets \
--set config.sources[0].name=production \
--set config.sources[0].url=http://prometheus.monitoring.svc:9090 \
--set config.llm.provider=openai \
--set config.llm.model=gpt-4o-mini
Or with a values file:
# my-values.yaml
existingSecret: infrasigns-secrets
config:
sources:
- name: production
url: http://prometheus.monitoring.svc:9090
# No self-hosted Prometheus? Pull AWS CloudWatch (type: cloudwatch, region,
# IRSA/IAM role), DigitalOcean (type: digitalocean, token: ${DO_TOKEN}), or
# Hetzner Cloud (type: hetzner, token: ${HETZNER_TOKEN}); probe any HTTP
# URL (type: healthcheck, endpoints — no secret needed); or run LogQL metric
# queries against Loki (type: loki, url) instead — see
# docs/configuration.md#sources.
llm:
provider: openai
model: gpt-4o-mini
reports:
digest:
schedule: "0 8 * * *"
# stale_episode_after: "24h" # optional; see the values table below
helm install infrasigns oci://ghcr.io/infrasigns/charts/infrasigns \
--namespace infrasigns --create-namespace \
-f my-values.yaml
What the chart creates
| Resource | Notes |
|---|---|
ConfigMap (<release>-infrasigns-config) |
Non-sensitive config rendered from config.* values; this is the copy the Deployment mounts, and it is an ordinary resource — applied in the normal sync, updated by helm upgrade --no-hooks, and deleted by helm uninstall |
ConfigMap (<release>-infrasigns) |
The same body, rendered from the same named template, as a pre-install/pre-upgrade hook (weight -1) so it exists before the weight-0 migration Job mounts it. Nothing long-lived mounts this copy — except on a release installed before the split, whose running pod mounts this very name until the next upgrade rolls it (see Upgrading from a release installed before the split). The hook copy is the one that keeps the historical name, and which name goes where is load-bearing rather than cosmetic — see Upgrading from a release installed before the split. It renders only when the Job does (both migration.enabled and preflight.enabled off means neither exists), and helm uninstall leaves it behind — see Uninstall. Why two: #583 |
| Job (migrate / preflight) | Runs the config pre-flight and then infrasigns migrate up before each install/upgrade (hook weight 0). The two halves have separate switches: migration.enabled: false drops migrate up, preflight.enabled: false drops the pre-flight. The Job is named <release>-infrasigns-migrate while migrate up runs and <release>-infrasigns-preflight when it does not — one or the other, never both. (Every name in this table is built from the chart's fullname, which is <release>-infrasigns unless the release name already contains infrasigns, in which case it is the release name alone — so a release called infrasigns gives infrasigns-migrate, which is the form the examples below use. A long release name is truncated to fit Kubernetes' 63-character label bound, and the point at which that starts differs between the two suffixes, so kubectl get job -n <namespace> is the reliable read.) |
| Deployment | Single replica — see Replica count |
| Service | ClusterIP on service.port (default 8080); routes to the container via the http named port |
| ServiceAccount | Created by default; set serviceAccount.create: false to bring your own |
| Ingress | Disabled by default |
| ServiceMonitor | Disabled by default; requires prometheus-operator |
GitOps (Argo CD, Flux)
Nothing in this section was driven against Argo CD. No Argo installation was used to write it; what is measured is our own rendered output and the behaviour of Helm's hooks on a real cluster. Each claim below says which of the three it is, because the difference matters when it is your sync that hangs.
The config the pod runs is an ordinary resource, and that is the part that
matters here. Since #583
the ConfigMap the Deployment mounts carries no helm.sh/hook annotations
(measured, from helm template), so a GitOps controller treats it like the
Deployment and the Service: it is applied in the ordinary sync, and it shows up
in argocd app diff. Before the split, the only copy of your configuration
was a Helm hook — which Argo maps to a sync hook, out of the ordinary apply set
— so the diff of your application did not include the thing that decides what it
does.
Flux: the helm-controller runs helm install/helm upgrade itself, so the
hook ordering is Helm's own, exactly as described in Upgrade. That
follows from Helm's semantics rather than from a run of ours.
Argo CD: what remains in the hook phase is the migration Job (weight 0) and
the hook copy of the ConfigMap (weight -1) that only that Job mounts. Argo maps
Helm's pre-install/pre-upgrade to PreSync. Whether it also honours
helm.sh/hook-weight as an ordering within PreSync is the thing we have not
measured. If it does not, the two are unordered and the Job's pod can be
created before its ConfigMap exists. That consequence is measured, on kind:
the pod sits in Init:0/1 with
Warning FailedMount MountVolume.SetUp failed for volume "config":
configmap "<release>-infrasigns" not found
and the sync fails at the timeout. It is loud and recoverable — a retried sync finds the ConfigMap already applied — and it is never a silent stale config, which is the failure mode worth designing against. If you hit it, the lever is an explicit sync wave on the hook ConfigMap:
The chart deliberately does not ship that annotation. It would be a third-party accommodation we have never tested, and shipping one on a belief is how the defect above got into the chart in the first place. If you drive this against a real Argo CD, tell us what happened and it can stop being an open question.
Upgrade
helm upgrade infrasigns oci://ghcr.io/infrasigns/charts/infrasigns \
--namespace infrasigns \
-f my-values.yaml
The migration Job runs automatically before the new Deployment starts. It runs two containers: a preflight that runs infrasigns config validate against the rendered config, and then infrasigns migrate up. If either fails, the upgrade stops and the previous Deployment version keeps running. With migration.enabled: false the Job renders as <release>-infrasigns-preflight and carries the pre-flight alone.
That now covers the config too, which it did not before
#583. The ConfigMap the
Deployment mounts used to be the hook, at weight -1 — so a refused upgrade had
already written the refused config to it by the time the pre-flight failed at
weight 0. helm rollback then reported success in a second without touching it,
because rollback does not run hooks and the chart declares none for it, and the
surviving pod kept working only until something restarted it: the next pod start
read the refused config and CrashLoopBackOffed, on a config no helm command in
your history asked for. (Driven on a cluster, not reasoned about.) With the
mounted copy an ordinary resource, a failed upgrade leaves it holding the last
config that passed, and the delete-a-pod case is an ordinary restart.
The same split fixes helm upgrade --no-hooks, which is what GitOps tooling and
--atomic retries reach for. It used to roll the pod (the Deployment's
checksum/config annotation changed) onto a ConfigMap that hooks never applied,
so the new pod ran the old config — and the divergence was permanent, because
a later ordinary helm upgrade with the same values then updated the ConfigMap
and did not roll the pod. Both halves are now ordinary resources that move
together.
--no-hooks is the one path the pre-flight cannot cover, and it got wider.
The pre-flight lives in the hook Job, so --no-hooks skips it — while the
mounted ConfigMap, now an ordinary resource, IS applied. So a config the daemon
will refuse reaches the pod on that path: helm upgrade --no-hooks exits 0,
the checksum/config annotation moves, strategy: Recreate stops the old pod,
and the new one CrashLoopBackOffs on the refused value with no previous
ReplicaSet to fall back to. Driven on a cluster, not reasoned about: three
restarts in 90 seconds, and helm rollback <previous-revision> brings the pod
back. Two consequences worth planning for. Run infrasigns config validate
against the config you are about to apply whenever you use --no-hooks, or
let the hooks run; helm template … | ... plus the binary is the same check the
Job would have made. And if it does happen, the remedy is helm rollback, not a
retried upgrade — a retry re-applies the same bytes. Closing this properly
(moving the pre-flight onto the Deployment as an init container, so no helm
flag can skip it) is a change to the pod's own shape and is tracked separately.
The pre-flight is what stops it, and that is worth knowing because it used to be migrate up's job and no longer is. Since #566 migrate up reads the file for database.dsn and the encryption key alone — deliberately, so a verdict about your monitoring configuration cannot stop a schema install — and it PRINTS the file's verdict rather than exiting on it. So a config the daemon will refuse still leaves migrate up at exit 0. config validate is the same rules with the opposite disposition: it exits non-zero, which is what fails the hook.
That ordering is what makes the next section's guarantee hold. The Deployment uses the Recreate strategy, so by the time the new pod's serve could refuse anything the old pod is already gone — a refusal there is CrashLoopBackOff with nothing monitoring. Everything decidable from the config alone is therefore decided in the hook, where a failure still leaves the previous Deployment in place — on any path that runs the hook. helm upgrade --no-hooks is the exception, and the paragraph above says what happens there and what to do about it.
Upgrading from a release installed before the split
If the release was installed by a chart older than the two-ConfigMap split,
upgrade it with this chart and nothing else — do not delete or rename anything
first. The split gave the Deployment's copy a new name
(<release>-infrasigns-config) and left the historical <release>-infrasigns
on the hook copy, and that assignment is what makes the upgrade work at all.
The reason, measured rather than argued: Helm does not store hook manifests in
the release, so an older release holds a live <release>-infrasigns ConfigMap
that its own stored manifest does not know about. The moment a chart emits that
name as an ordinary resource, Helm refuses to adopt the live object:
Error: UPGRADE FAILED: Unable to continue with update: ConfigMap
"<release>-infrasigns" in namespace "<namespace>" exists and cannot be imported
into the current release: invalid ownership metadata; annotation validation
error: missing key "meta.helm.sh/release-name"
Adding meta.helm.sh/* annotations to the chart does not fix that — Helm reads
ownership off the live object, not off the manifest being applied. Deleting the
ConfigMap by hand fixes the upgrade and breaks the way back: the previous
revision's Deployment mounts that same name, rollback runs no hooks, and
neither revision can then start — both pods sit in FailedMount. Keeping
the historical name on the hook copy avoids the whole problem, and the upgrade
and a subsequent helm rollback to the pre-split revision were both driven end
to end.
One consequence is worth knowing before you run it, because it is the one thing
the split does not make cleaner on this path. The hook copy's delete policy is
before-hook-creation, so the upgrade deletes and recreates
<release>-infrasigns — and on a pre-split release that is the object the
running pod has mounted. The window is short — the pre-split chart's own
comment estimated ~100ms, and that figure is INHERITED rather than measured:
it arrives verbatim from the comment this split replaced, where it stood beside
"the probability is very low in practice" with nothing behind it, and nothing in
this change timed it. Treat it as an order of magnitude. A pod only notices if it
restarts inside the window, but the window is real until the upgrade rolls the
pod onto <release>-infrasigns-config, and it re-opens on every later upgrade if
you roll back to the pre-split revision and stay there. After the upgrade,
nothing long-lived mounts the hook copy again.
Every existing release rolls its pod once on this upgrade. The rendered
config.yaml is byte-identical, but the Deployment now mounts a
differently-named ConfigMap, which moves the pod template. With Recreate that
is one brief monitoring gap, the same as any other upgrade.
If you installed from an unreleased build of this change, the naming was
briefly the other way round — the Deployment's copy was <release>-infrasigns
and the hook copy <release>-infrasigns-hook — and upgrading from it is not
blocked, but it leaves two traces worth knowing about. <release>-infrasigns-hook
is orphaned permanently: Helm does not store hook manifests in the release, so
nothing prunes it. The kubectl delete configmap,job -l
app.kubernetes.io/instance=<release> command under Uninstall
sweeps it, because every ConfigMap this chart renders carries that label; a
by-name cleanup has to name it explicitly. And <release>-infrasigns should be
deleted by that upgrade even though the pre-upgrade hook has just created it:
it was an ordinary resource in the old release and is a hook in the new one, so
Helm's apply phase has it in the old manifest and not the new one. That step
follows from Helm's ownership rules rather than from a run of ours — unlike the
released-chart path above, this one was not driven — and it costs nothing
either way, because only the migration Job mounts that copy and the next
upgrade's hook recreates it before that Job runs. No
released chart ever produced either state.
Reading a failed migration Job
Two things about that Job are worth knowing before you need them.
kubectl logs job/<release>-infrasigns-migrate -n <namespace> prints the migrate container. The Job also carries a preflight init container, on every install (see below). When that is what failed, the plain command does not print its error: it defaults to the migrate container, which never ran, and answers that the container is still waiting to start (PodInitializing) rather than showing you the refusal. Name the init container instead:
kubectl logs job/infrasigns-migrate -n infrasigns # migrate
kubectl logs job/infrasigns-migrate -n infrasigns -c preflight # the pre-flight
The Job's NAME depends on migration.enabled. With migrations managed
out-of-band there is no migrate up container, so the Job is named
<release>-infrasigns-preflight and the pre-flight is its only container —
kubectl logs job/infrasigns-preflight -n infrasigns with no -c, for a release
called infrasigns. kubectl get job -n <namespace> names whichever one this
release rendered, which is the reliable way to read it: the name is the chart's
fullname plus the suffix, truncated if that would exceed Kubernetes' 63-character
label bound (see What the chart creates).
Read the logs before you retry. The Job's helm.sh/hook-delete-policy is before-hook-creation,hook-succeeded, so a failed Job is kept for debugging — and then deleted at the start of the next hook run. Re-running helm upgrade, which is the natural response to a failed one, destroys the Job and its pod logs before creating the replacement.
Configuration rules can tighten between releases
A release may make a previously-accepted configuration value invalid. Where the daemon would refuse to start on a value an earlier release accepted, the release notes call the change out as Breaking; where it warns and starts while config validate refuses — the disposition #569 takes — the note says so instead, and the value still stops a helm upgrade, because the hook runs config validate. Sweep for offending values before upgrading either way, because the two ways InfraSigns reads its configuration find out very differently.
Configuration in this chart's values is checked before the new Deployment starts: the Job's preflight init container runs the whole file-scope validator over the rendered config. A value it refuses fails the Job, the upgrade stops, and the previous Deployment keeps running. Its logs name the offending key and say why — see Reading a failed migration Job above, because retrying deletes them, and name the preflight container or you will read the one that never ran.
There is a second class of rule that gate does not cover, and on this chart it has no subject. Since #546 a deployment with sign-in configured also copies its config file's sources: into its own database document at start-up, and the store's write gate applies rules there that the file-scope validator does not — a class infrasigns config validate reports on and exits 1 over, with or without --strict-provisioning (see Pre-flight before an upgrade). That flag used to be what moved the exit code; it is accepted and implied now, so on a current binary it changes nothing. This chart renders no auth: section at all (grep templates/configmap.yaml), so nothing it can produce reaches those rules, on any values.
The preflight container carries the gate for them anyway, and what is conditional is the FLAGS rather than the container: it runs infrasigns config validate on every install, and adds --strict-provisioning only where config.auth is set — which is to say nowhere today — and --accept-unjudged only where preflight.acceptUnjudged is turned on, which is off by default. That split is deliberate on both halves, and it is about PINNED IMAGES rather than about this release's behaviour, because neither flag changes it. The bare config validate is in every released image, so the container cannot break an upgrade whose image.tag is pinned below this chart's appVersion; --strict-provisioning is new in #546, so an image pinned below it fails that container with unknown flag and stops an upgrade the deployment itself would have completed. Between those two points — an image at or after #546 and before the release that made the refusal unconditional — the flag is what moves the exit code, which is the only window in which passing it still buys anything. Confining the flag to the arm that already had it is what lets the container itself run everywhere — which it has to, because the file-scope refusals it stops (a bare $NAME, for one) reach every install and not only the ones with sign-in. A refusal in serve is the CrashLoopBackOff the section above describes, so the check belongs in the hook either way.
--accept-unjudged has a switch of its own, preflight.acceptUnjudged, defaulting off, and what it buys is on the OTHER side of the exit code. config validate answers 3 when the file is valid under every rule the run could apply and a set of rules was skipped — see Pre-flight before an upgrade for the three codes. Any non-zero exit in an init container stops the upgrade, so an unqualified 3 would fail installs over a check the command could not make rather than over anything wrong with the config. The flag turns that answer back into exit 0, leaving the explanation in the container's log where kubectl logs … -c preflight reads it. It is a flag rather than a shell || because the image is distroless and carries no shell, so the container's command: is exec form with nothing to write a || in.
It does NOT ride config.auth. The two flags carry image floors at different commits — --strict-provisioning is #546, this one is #579 — and a flag added to an arm raises that whole arm's floor, so "this arm already has a floor" is not an argument for a flag whose floor is newer. Both land in the same release here, so nothing pinned to a version tag sits between them; a pin to one of the per-commit sha-… images published on every merge to main can. Turning preflight.acceptUnjudged: true on therefore carries the #579 floor and is your decision rather than the chart's: leave it off and exit 3 stops the hook with the explanation in kubectl logs … -c preflight; turn it on once your image.tag is at or after the release carrying #579 and your gate would rather log the third verdict than stop on it. (What this does not close is --strict-provisioning's own floor: an image.tag pinned below #546 with config.auth set still fails the container with unknown flag. That trade is stated in the paragraph above and is unchanged.)
Exit 3 is unreachable on every values file this chart accepts, and it is worth knowing that it is a union rather than one condition. config validate answers 3 when the file declares auth.<provider>.client_id and this shell cannot resolve it, or when the file's own bytes are not YAML while the expanded text is — a ${VAR} written inside a flow collection, which needs no auth: key at all. The first arm has no subject here because this chart renders no auth: section. The second has none because the rendered config.yaml is hand-written template literals plus toYaml over your config: values, and toYaml is a YAML serializer: what it emits parses as written. (A file that simply fails to parse both ways is not the third verdict at all — it is exit 1, a refusal.) The flags are there so that growing an auth: section activates the first arm rather than needing it found — templates/configmap.yaml lists what such a block would owe.
Third-party CI keeps the distinction: if your own gate would rather stop on 3 than tolerate it, run config validate without the flag and read the code.
It has its own switch, preflight.enabled, defaulting true and independent of
migration.enabled — which is what makes "on every install" above a statement
rather than an aspiration, including for the operator who manages migrations
out-of-band and is the one most likely to hand-write a config. Setting
preflight.enabled: false removes it, deliberately; on such a deployment nothing
in the chart stops an upgrade on a config the daemon will refuse, so run
infrasigns config validate against the rendered config yourself.
Per-organization configuration stored in the database — the multi-tenant deployment — has no such gate. Those documents are validated when they are written, not when they are read, so one written before a rule tightened keeps rendering in the UI while the worker refuses to build a runtime for it: that organization goes quiet — no collection, no digests, no checks — and everything else keeps running. It surfaces in exactly two places, neither of them the tenant's own screens:
- an error log,
lease: cannot resolve runtime for schedule, backing off, which names the organization and the validator's key; infrasigns_schedule_cycles_total{status="no_runtime"}, which carries no organization label — so it tells you that something is degraded, not which tenant.
The fix is always to correct the stored value, which means finding the affected organizations first. The release note for each tightened rule says what to look for.
This release (#569) treats a
bare $NAME anywhere in the config file's values as a mistake: only ${NAME} is a
reference, and a literal dollar is written $$. serve warns about it and
starts — a rule that would refuse a file an earlier version accepted must not stop a
daemon — while infrasigns config validate refuses it, so the preflight
container is where you will meet it. That class reaches every install, with or
without sign-in. Sweep the config this chart would render, rather than your values
file — the $ may be coming from a default:
helm template infrasigns oci://ghcr.io/infrasigns/charts/infrasigns \
-f my-values.yaml -s templates/configmap.yaml \
| sed 's/\$\$//g' | grep -nE '\$([^{$]|$)'
The sed deletes the $$ escape before the search, so the reader who has already
applied the remedy is not shown their own fix as a problem. -E and not grep -P:
the Perl form is a GNU extension, so on macOS or busybox it fails to stderr and
prints nothing — which reads exactly like a clean render. The pattern reads bytes
and cannot see YAML, so it errs wide in two ways: a hit inside a # comment is not
refused, and neither is a $ with nothing after it. It is a floor rather than a
total, and
A literal $ in a config-file value
says what it does not see.
This release (#432) tightens two source-name rules — a length bound and a new reserved word. See Source names for both, and for what a rename leaves behind.
Sign-in now requires an encryption key
Breaking. A deployment with sign-in configured refuses to start unless security.encryption_key or security.encryption_key_file is set. Such a deployment holds other organizations' credentials, and without a key they sit in the clear in the database; the start-up error names the key and points at infrasigns secrets keygen.
A chart-managed install cannot reach that rule today. This chart renders no auth: section at all — grep templates/configmap.yaml — so every config it can produce is self-hosted, and the migration Job validates the rendered config before the new Deployment starts either way. The requirement binds a deployment running the image against a config file of its own. When the chart grows hosted support, the key joins the required key set beside the auth block.
Setting a key on a self-hosted install is not breaking and is worth doing — see INFRASIGNS_ENCRYPTION_KEY above for what it covers and what losing it costs.
The Deployment uses the Recreate strategy: the old pod stops before the new one starts, so expect a brief monitoring gap during upgrades. This is deliberate — the MVP runs a single scheduler worker, and Recreate avoids briefly running two.
Uninstall
The Deployment's own ConfigMap (<release>-infrasigns-config) goes with the
release — it is an ordinary resource. Hook resources are not deleted by helm
uninstall, and two remain: the migration Job and the hook copy of the
ConfigMap (<release>-infrasigns). Remove them manually if the namespace is not
being reclaimed anyway:
# If you used a different namespace (-n flag) or release name (instance= value), update both below
kubectl delete configmap,job -n infrasigns -l app.kubernetes.io/instance=infrasigns
Replica count
replicaCount must be 0 or 1. InfraSigns schedules digest, trends, and health-check cycles from a single worker process. The chart schema enforces this — helm install fails immediately if replicaCount > 1. (The scheduler claims each cycle through a database lease that single-flights it, so multi-replica scale-out is a planned flag flip rather than a rewrite; today the chart caps at one worker.)
Use replicaCount: 0 to pause the service without uninstalling.
Graceful shutdown
On SIGTERM the process tears its components down in reverse startup order under a
single 30s budget (totalShutdownTimeout). Each component gets at most 10s of
that, minus whatever is still reserved for the components below it, so the ones
that must finish their in-flight database writes before the process exits cannot
be starved by a slow HTTP server or chat transport above them.
The reservations are:
| Component | Reserved | Why |
|---|---|---|
| Incident sink | 5s | One incident write / prune call |
| Report sink | 5s | One report write (its prune shares that budget) |
| Source-health recorder | 5s | One health write |
| Lease scheduler | 5s | Releasing the claimed schedule row of a cut-short cycle |
| Store (pool close) | 2s | Closing idle connections and releasing waiters — see below |
That is 22s reserved of the 30s, leaving 8s for everything unreserved (the HTTP server, the chat transports, the webhook and deploy handlers, the session reaper) to share, in LIFO order, before the reservations start binding.
The reservations make a write abandoned mid-shutdown unlikely, not impossible: a drainer that runs out of budget gives up on its background writes rather than cancelling them, and nothing downstream rescues one that it gave up on.
The store's own 2s is not a rescue and buys no waiting. Closing the pool
(database/sql's DB.Close) closes the idle connections, stops new queries
from starting, drops the goroutines waiting for a connection and stops the
connection opener; it never blocks on a connection that is currently in use. Its
documentation says otherwise — that it "waits for all queries that have started
processing on the server to finish" — but the implementation does not do that, so
do not size the reservation as if it did. An abandoned write survives only
because closing the pool leaves its busy connection alone, and what it then races
is process exit, which nothing here bounds. The value of the reservations is that
they make abandonment less likely by giving each drainer its slot, not that an
abandoned write is safe.
Worst case the pod needs about 35s: the 30s budget, plus up to one more
incident write, because the incident sink waits for its aborted write to unwind
instead of abandoning it. The chart therefore sets
terminationGracePeriodSeconds: 40 — matching kubernetes' 30s default would let
SIGKILL land in the middle of the final writes. If you override the grace period,
keep it above 35s.
Enabling ServiceMonitor
If you run prometheus-operator or kube-prometheus-stack:
serviceMonitor:
enabled: true
interval: 60s
labels:
release: kube-prometheus-stack # match your Prometheus CR's serviceMonitorSelector
Metrics reference
All application metrics are exported on /metrics under the infrasigns_
prefix (plus the standard Go runtime and process collectors). The full catalog —
every metric name, type, label, and label value — lives on the
Observability page, along with the
/healthz and /readyz probe contracts.
Two things about the probes are worth knowing before you size a cluster around
them. /readyz gates the pod on nothing at all — it answers 200 for every
process that answers, and the fault, if there is one, is in the body's top-level
status word (#507). So
a 1/1 pod is not evidence that its database, sources or notification channels
are reachable: the machine-readable signal is
infrasigns_readiness, and if you take one
alerting rule from this page, take
infrasigns_readiness{kind="subsystem",name="database"} == 0. The reason it
gates nothing is that this chart caps replicaCount at 1, so eviction can never
move traffic to a healthy sibling — it only removes the last endpoint, taking the
web UI that would explain the fault, /metrics and the webhook receiver with it.
Two alerts you may already have stop covering this pod, and this is the whole
of what replaces them. The pod is now Ready from the moment its containers
start, whatever it can or cannot reach, so it never enters the state either of
these is written against. Both are severity: warning, both for: 15m, and
both are read from the upstream
kubernetes-mixin
that kube-prometheus-stack ships rather than from memory:
Both are described here by the CONDITION they turn on, not quoted: the shipped
expressions are longer than anything worth reproducing in a doc — KubePodNotReady
is a union of three vectors wrapped in a topk that drops Job-owned pods and
excludes SchedulingGated, and KubeDeploymentReplicasMismatch ands a second
clause onto its comparison. Read them in the mixin; a quoted sub-expression here
would go stale the first time upstream edits the rule, and it is the condition,
not the text, that this section is about.
KubePodNotReadyfires on a pod that is in phaseRunningand whoseReadycondition is false. A pod held NotReady by a failing readiness probe used to satisfy it.KubeDeploymentReplicasMismatchfires when a Deployment's desired replica count exceeds its available one, and only while nothing has rolled in the last 10 minutes (so an ordinary deploy does not trip it). An unready sole replica used to satisfy that too.
Neither can fire on an InfraSigns pod any more, and nothing in kube-prometheus-stack
replaces them, because the fault is no longer expressed in any Kubernetes object.
infrasigns_readiness == 0 is the replacement, and you have to add it yourself —
the chart's serviceMonitor gets the series scraped and ships no PrometheusRule.
Suggested alerts has a starting set, including
the one rule that catches nothing polling /readyz at all.
The same fact reaches a GitOps controller. A pod that is always Ready keeps the
Deployment's Available condition True and availableReplicas at 1, so
helm upgrade --wait returns success and Argo CD assesses the Deployment as
Healthy however broken the thing it monitors is. That follows from the Kubernetes
object, which is measured; the Argo half is the mapping Argo documents, and — as
GitOps says of everything in that section — was not
driven against a real Argo CD installation.
Before this a single unreachable source meant a correct install never became
Ready at all, which --wait, an Argo health gate or an HPA all read as a failed
install; first-install database reachability is still gated, by the pre-install
migrate up hook and by serve migrating before it binds its listener. And
the container carries a startupProbe on /healthz
(periodSeconds: 5 × failureThreshold: 48 = 240s), because serve applies
pending migrations before it listens: while a migration runs there is no HTTP
server to answer, and without the startup probe liveness killed the pod at
roughly 90 seconds and the migration restarted from the beginning. 240s is
deliberately the same number as the migration Job's activeDeadlineSeconds —
one budget for one piece of work — and CI asserts the two are equal. A migration
slower than that is still killed; the answer for one of those is
migration.enabled: false and applying it out of band, or raising both numbers
together (and helm's own --timeout above them).
The ServiceMonitor above is the scrape mechanism for /metrics on a
prometheus-operator cluster; on a plain Prometheus, add a static scrape config
pointing at the Service.
Enabling Ingress
ingress:
enabled: true
className: nginx
hosts:
- host: infrasigns.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: infrasigns-tls
hosts:
- infrasigns.example.com
Key values reference
| Value | Default | Description |
|---|---|---|
existingSecret |
"" |
Required. Name of the Kubernetes Secret with sensitive env vars |
config.sources |
[] |
Required. List of {name, url} Prometheus sources |
service.port |
8080 |
Kubernetes Service port; routes to the container via the http named port |
config.llm.provider |
openai |
openai, anthropic, or none (deterministic engine, no API key) |
config.llm.model |
gpt-4o-mini |
Model name passed to the LLM API; ignored with none |
config.notify.<channel>.feeds |
unset | Which producers reach one channel — any of digest, trends, incidents, deploys, checks. Unset means every feed, the behaviour of every release before it, and an explicit null in a second -f resets the key to that default; an empty list keeps the channel configured and silent. A repeated feed is refused at helm install/helm upgrade, matching the loader, rather than installing a config the pod then crashloops on. config.notify.pagerduty.feeds accepts only incidents and checks and its enum refuses the other three at install time too — a pager drops any message carrying no incident, so those scopes could never match. See Feed routing |
config.reports.digest.schedule |
"0 8 * * *" |
Cron expression (UTC) for the daily digest |
config.reports.digest.stale_episode_after |
unset (24h) |
Go duration. An open incident episode nothing has re-asserted for this long still appears on the digest but stops standing its verdict up. Applies to senders that repeat (Alertmanager, Grafana), never to CloudWatch alarms, which only notify on a state change; must be longer than config.webhook.dedupe_window. No "off" value — 0s/negatives are rejected; set something very large ("87600h") to keep the pre-#419 behaviour |
config.reports.trends.enabled |
false |
Enable the periodic trends report |
config.reports.trends.schedule |
"0 9 * * 1" |
Cron expression for the trends report |
config.reports.trends.window |
"168h" |
Analysis look-back (Go duration); defaults to 7d |
config.reports.trends.timezone |
"UTC" |
IANA timezone name for the trends schedule |
config.reports.retention_days |
unset | Prune archived reports after N days; 0/unset keeps everything forever |
config.incidents.retention_days |
unset | Prune RESOLVED incident episodes after N days; open episodes are never pruned; 0/unset keeps forever |
config.timeline.retention_days |
unset | Prune source timeline events (fetch failures/recoveries, check-verdict changes) after N days — and, at startup only, maintenance windows that stopped silencing anything that long ago; 0/unset keeps forever |
config.source_health.retention_days |
unset | Prune a removed source's frozen per-source rows after N days — its health row and its severity reading (#358) alike (live sources keep updating both, so only removed ones are reaped); 0/unset keeps forever |
config.webhook.dedupe_window |
unset | Suppress duplicate inbound alerts within this window (Go duration; app default 5m) |
config.webhook.model |
unset | llm.model override for inbound-alert incident summaries |
config.heartbeat.enabled |
false |
Set to true and provide HEARTBEAT_URL in the secret |
migration.enabled |
true |
Run migrate up as a pre-install/pre-upgrade hook |
preflight.enabled |
true |
Run infrasigns config validate against the rendered config as a pre-install/pre-upgrade hook, so a config the daemon will refuse stops the upgrade rather than the new pod. Independent of migration.enabled |
preflight.acceptUnjudged |
false |
Pass --accept-unjudged to that check, so its third verdict (exit 3 — valid under every rule the run could apply, with a set of rules skipped) does not stop the upgrade. Requires an image.tag at or after the release carrying #579; on an older image the flag itself fails the container. See Upgrade |
replicaCount |
1 |
Must be 0 or 1 |
serviceMonitor.enabled |
false |
Create a ServiceMonitor for prometheus-operator |
image.tag |
"" |
Defaults to chart appVersion; override to pin a specific release |