Table of Contents

Deploying to Kubernetes

deploy/helm/mailfathom/ is the chart. It installs MailFathom and the objects around it, and deliberately installs neither a database nor a Secret: both belong to whoever operates the cluster, and the chart is written so that it cannot pretend otherwise.

It renders It does not render
Deployment, Service, ConfigMap, ServiceAccount Any Secret
An optional Ingress Any certificate material
Any database, and any schema step

What you supply

Two things have no default and the chart refuses to render without them.

An image. Released images are on both registries under the same digest, and the chart still defaults to none of them: a default would pin every install to whichever version this chart happened to name, and a moving one would let a cluster follow a version nobody chose. You name the immutable reference your deployment runs.

A database. PostgreSQL with the vector extension. A store holding every synchronized message needs backup, durability, and an upgrade path that a subchart cannot own.

A Secret is the third thing, and the chart names it rather than creating one.

kubectl create namespace mailfathom

kubectl --namespace mailfathom create secret generic mailfathom-secrets \
  --from-literal=mailfathom-database-password='…' \
  --from-file=imap-primary-password=./imap-primary-password \
  --from-file=mcp-workstation-key=./mcp-workstation-key \
  --from-file=mailfathom-data-key=./mailfathom-data-key

It is mounted read-only at /etc/mailfathom/secrets, one file per key, so every credential is a file: reference — the same path and the same references the Compose deployment uses.

The last entry is the data-encryption key, and it belongs in this Secret rather than in a chart value: the chart creates no Secret and generates nothing, deliberately, because a Helm-generated key would be replaced on any upgrade that did not guard it with lookup — and lookup returns nothing during helm template, during a dry run, and under Argo CD. Every value already sealed would stop opening. Generate it once with openssl rand -base64 32; the material decodes to exactly 32 bytes and startup refuses any other length. Drop the line when no account authenticates with OAuth, since a deployment that seals nothing needs no key. The data-encryption key states the rest, including why it is backed up with the database and never regenerated.

A Secrets Store CSI driver works, with one step this chart does not take for you. The pod mounts a Kubernetes secret volume and exposes no CSI volume of its own, so configure the driver's secretObjects to synchronize into the Secret named by secrets.existingSecret; the chart then mounts it like any other. Mounting the CSI volume directly would need extraVolumes and extraVolumeMounts values, which the chart deliberately does not have — an arbitrary volume list is how a chart stops being able to say what its pod reads.

Installing

# values.yaml
image:
  registry: ghcr.io             # or docker.io; both carry the same digest
  repository: krzysztof318/mailfathom
  digest: sha256:…              # or an immutable tag

database:
  host: postgres.databases.svc.cluster.local
  name: mailfathom
  user: mailfathom

secrets:
  existingSecret: mailfathom-secrets

config:
  files:
    10-mailfathom.json: |
      {
        "MailSynchronization": {
          "Enabled": true,
          "Accounts": [
            {
              "AccountId": "primary",
              "Host": "imap.example.test",
              "Port": 993,
              "UserName": "you@example.test",
              "Secrets": {
                "Password": {
                  "Name": "imap-primary-password",
                  "SecretReference": "file:/etc/mailfathom/secrets/imap-primary-password"
                }
              },
              "TransportSecurity": { "ConnectionSecurity": "TlsOnConnect" },
              "Folders": [ { "Alias": "inbox", "SpecialUse": "Inbox" } ]
            }
          ]
        },
        "McpEndpoint": {
          "Enabled": true,
          "Authentication": [
            {
              "ApiKey": { "Name": "workstation", "SecretReference": "file:/etc/mailfathom/secrets/mcp-workstation-key" }
            }
          ]
        }
      }
helm install mailfathom oci://ghcr.io/krzysztof318/charts/mailfathom \
  --version <x.y.z> --namespace mailfathom --values values.yaml
kubectl --namespace mailfathom rollout status deployment/mailfathom

The chart is published to GHCR as an OCI artifact by the release that publishes the image, under the same version. Its appVersion is that release, so a chart says which application version it deploys without being unpacked:

helm show chart oci://ghcr.io/krzysztof318/charts/mailfathom --version <x.y.z>
gh attestation verify oci://ghcr.io/krzysztof318/charts/mailfathom:<x.y.z> --repo Krzysztof318/MailFathom

The chart is on GHCR alone, where the image is on both registries. Docker Hub's namespace is namespace/name and nothing deeper, so a chart pushed there would land in the repository the image already occupies and collide with its tags. It is also listed on Artifact Hub.

That listing is rendered entirely from the chart package, which is why Chart.yaml carries more than the fields Helm requires. Its description is the summary the listing shows, so it opens with what the product is and names the protocol last; Artifact Hub imposes no length there. Its keywords are what a search there matches, narrowed to terms an operator would search for and to capabilities this release implements — a keyword is a claim about the artifact it is attached to, so the roadmap's terms are absent from it. artifacthub.io/category is stated rather than omitted, because Artifact Hub otherwise predicts a category from those keywords with a machine-learning model. The overview below all of it is the repository's root README.md, copied into the package at release time so there is one overview rather than two that drift.

Installing the chart directory out of a checkout is the development path and stays available:

helm install mailfathom deploy/helm/mailfathom --namespace mailfathom --values values.yaml

An unpackaged directory states no appVersion, because it is not a release of anything, so the version-drift check below stands down for it.

A digest is preferred over a tag: it is the only reference a registry cannot repoint, so a rollback goes back to the same bytes. values.schema.json rejects latest and the other moving tags outright.

Nothing in the ConfigMap may be a credential — it is readable by anything holding get on it and is neither encrypted at rest nor audited like a Secret. The chart puts no credential there and none in the rendered Deployment; the verification script asserts that on every change.

Applying the schema

MailFathom verifies the schema while starting and refuses to serve against one it does not recognize. The first install therefore does not become ready, and its log says why:

DatabaseSchemaOutOfDateException: The database has not applied 1 migration(s) this build defines: 20260731132336_Initial.

That is the design, and the chart deliberately renders nothing that answers it: a Job carrying a Helm hook would be the automatic migration this whole arrangement exists to prevent, and an initContainer would run one apply per replica.

The answer is mailfathom-schema-<version>.sql, attached to every release. Take a backup, read the SQL, and run it from wherever the database is already reachable:

kubectl --namespace databases port-forward service/postgres 5432:5432 &

psql "postgresql://mailfathom_migrator@127.0.0.1:5432/mailfathom" \
  --set ON_ERROR_STOP=on \
  --file 'mailfathom-schema-<version>.sql'

The role that applies it needs privileges database.user does not — the vector extension is one an ordinary role may not create — and PostgreSQL leaves whoever ran the DDL owning every object it created, so database.user needs grants rather than a transfer of ownership. Applying the database schema states both in full, along with the locks the script takes and what each startup failure means.

TLS and reaching it

MailFathom speaks plain HTTP inside the cluster and terminates no TLS of its own. The chart never issues, templates, or stores certificate materialsecretName names a Secret the cluster already holds, whether an operator created it or cert-manager did.

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
  hosts:
    - host: mailfathom.example.test
      paths:
        - path: /mcp
          pathType: Prefix
  tls:
    - secretName: mailfathom-tls
      hosts:
        - mailfathom.example.test

An Ingress without a tls entry hands the API key and every message served to anything on the network path. The chart renders it, because there are networks where that is a real choice, and warns in its notes.

Without an Ingress the Service is reachable only inside the cluster:

kubectl --namespace mailfathom port-forward service/mailfathom 8080:8080

What the pod serves by default

Plain HTTP on port 8080, with no authentication, no CORS gate, no mTLS, and no rate limiting. That is the usual Kubernetes arrangement — an ingress or a service mesh in front of the workload owns TLS termination and whatever client authentication the cluster imposes — and it is why the chart neither templates certificate material nor asks for a credential to start.

Every one of those is a MailFathom setting rather than a chart value, so turning one on is a ConfigMap entry under config.files and nothing else changes:

To turn on Configure Reference
API keys McpEndpoint:Authentication Authentication
An Origin gate McpEndpoint:Cors CORS and the Origin header
Reading the public scheme and host from the ingress alone ReverseProxy:TrustedProxies Behind a TLS-terminating reverse proxy
TLS terminated by the pod itself McpEndpoint:Https:Endpoints HTTPS and your own domain
Client certificates McpEndpoint:ClientCertificateProfiles Client certificates
Rate limits McpEndpoint:RateLimiting, and AdminEndpoint:RateLimiting for the administrative endpoint Rate limiting

The ingress row is the one an OAuth deployment should not skip, and it narrows rather than enables. The controller terminates TLS and dials the pod over plain HTTP under the Service name, and MailFathom reads the forwarded scheme and host from any peer until you say otherwise — so discovery completes out of the box, and until TrustedProxies names the ingress, anything else that can reach the pod can set those headers too. Name the pod CIDR the ingress controller runs in, which kubectl cluster-info dump | grep -m1 cluster-cidr reports on most distributions. A ClusterIP Service is not a substitute: it keeps the pod off the cluster's edge, not away from every other pod.

Configuring Https:Endpoints with a TLS Transport moves where the endpoint answers, so the chart's service.port and the http container port have to match what the profiles bind. The probe listener is unaffected and keeps its own transport. That is a deliberate step rather than the default: in a cluster, TLS at the ingress is usually what an operator already has.

The credentials any of them reads stay file: references into the mounted Secret. Keep them out of config.files and out of config.extraEnvironment; the values schema rejects an environment name that reads like a credential, because an environment block is visible to anything that can read /proc and cannot be erased from process memory.

Security defaults

The defaults satisfy the Restricted Pod Security Standard, and the schema keeps the load-bearing ones from being switched off by accident: runAsNonRoot must be true, readOnlyRootFilesystem must be true, allowPrivilegeEscalation must be false, capabilities.drop must contain ALL, capabilities.add must be empty, and seccompProfile.type must be RuntimeDefault. The pod runs as UID 1654 and mounts an in-memory emptyDir at /tmp, which is the only path the runtime writes to.

automountServiceAccountToken is false. MailFathom calls no Kubernetes API, so a projected token would be a credential with nothing to authenticate to and one more thing to steal.

Probes

Probe Path Consults
Startup /started The host's own startup gates: every secret reference resolved, the database schema verified. Its budget is what a slow first start is allowed, and it holds liveness off until it succeeds.
Readiness /health The dependencies a request needs, the database included. A pod that cannot serve leaves the Service's endpoints.
Liveness /alive The process alone, so a database outage never becomes a restart loop that cannot fix it.

All three are served on a container port of their own — probes.port, 8081 by default — which sets both the port the kubelet dials and the HealthEndpoints:Port the host binds, so the two cannot drift. The Service publishes 8080 alone, so nothing outside the node reaches the probe listener: the probes answer without a credential, and which network their port is on is what controls who may ask them. Setting probes.port to 8080 is refused, and so is a probe pointed at another endpoint's path — pointing liveness at /health is exactly the mistake that turns an outage into a crash loop, and pointing startup at /alive ends the startup grace period while the pod is still coming up.

The health endpoints states what each probe consults and how a deployment turns the surface off or serves it over TLS.

Configuration reload

A ConfigMap edit reaches the running process for the settings MailFathom classifies reloadable, and DOTNET_USE_POLLING_FILE_WATCHER is set by default because FileSystemWatcher does not observe the symbolic-link swap the kubelet performs. Two things are restart-required and no setting changes that: adding or removing a ConfigMap key, and a subPath mount, which never updates at all. The chart mounts the whole volume and never uses subPath. Configuration sources states the full behavior and the kubelet's own delay.

The Deployment carries a checksum of the rendered ConfigMap, so a helm upgrade that changes configuration restarts the pods — which is what makes an added or removed key take effect.

Scheduling, resources, and placement

nodeSelector, tolerations, affinity, topologySpreadConstraints, priorityClassName, resources, podAnnotations, podLabels, service.type, service.annotations, and terminationGracePeriodSeconds are all values. Nothing requires editing a template.

terminationGracePeriodSeconds defaults to 60 against a 10-second MailSynchronization:ShutdownDrainTimeout. Raise them together: a grace period shorter than the drain kills the process with the drain still running.

Upgrading, rolling back, and uninstalling

Back up the database and apply the new release's mailfathom-schema-<version>.sql before the upgrade. The new pod refuses to start against a schema that is behind it, and the old pod keeps serving against a schema that is ahead — so that order is the one with no window in which nothing serves.

helm upgrade mailfathom deploy/helm/mailfathom --namespace mailfathom --values values.yaml
helm rollback mailfathom <revision> --namespace mailfathom
helm uninstall mailfathom --namespace mailfathom

helm rollback returns the workload to a previous image. It does not return the schema. A migration only moves forward, so returning to the earlier schema means restoring the database from the backup taken before the migration; rolling back states when that is necessary and when rolling only the image back is enough.

Uninstalling removes every object the chart owns. It removes no data: the database is not the chart's, and the Secret was created outside it and stays.

Chart version and application version

Chart.version moves whenever anything under the chart directory changes; Chart.appVersion is the application version the chart is written against. They are separate, and a values default corrected without touching the image is a chart release on its own.

Chart.yaml carries no appVersion, deliberately: the release run supplies it when it packages the chart, from the VersionPrefix in Directory.Build.props that is the only file in the repository carrying an application version.

helm package deploy/helm/mailfathom --app-version "$(bash scripts/read-declared-version.sh)"

A packaged chart therefore always states the application version it deploys, and refuses an install whose image.tag disagrees with it unless image.allowVersionMismatch says the combination is deliberate. Two cases carry nothing to compare and are not refusals: a deployment naming the image by image.digest, which publishes no version, and the unpackaged chart directory, which states none because it is not a release of anything.

Nightly builds

image.channel: nightly deploys unsupported development output. It requires image.nightlyAcknowledgement: i-understand-this-is-unsupported, requires an image.tag that carries a -nightly. identifier and rejects one on the release channel — the channel is decided by what the reference calls itself, not by the registry it came from, because both registries carry both channels — labels every rendered object io.mailfathom/release-channel: nightly — the same value the image carries as io.mailfathom.release-channel, spelled the way a Kubernetes label prefix has to be — prints a warning in the chart's notes, and labels the workload with the nightly identifier rather than with appVersion — so a nightly is never indistinguishable from a release in a query that reads that label.

What a nightly build risks states what the acknowledgement is acknowledging: a schema that can be ahead of any published migration, no upgrade path in either direction, four public surfaces that move without notice, and a tag that is deleted once thirty newer nightlies exist. Name the exact -nightly.<n>-<short revision> identifier or a digest rather than the moving nightly tag. The package is public, so the cluster needs no pull secret to reach it; image.pullSecrets stays in the chart for a mirror or a private registry an operator pulls through instead.

The nightly channel has no chart of its own. Install the most recent released chart and point it at the nightly image, which is what the values above are for; publishing a chart per nightly would fill the chart's version list with references deleted a month later.

Verification

Reading the chart needs only Helm, and it is what a change here is reviewed with:

helm lint     deploy/helm/mailfathom --values deploy/helm/mailfathom/ci/release-values.yaml
helm template verification deploy/helm/mailfathom --values deploy/helm/mailfathom/ci/nightly-values.yaml

deploy/helm/mailfathom/ci/ holds those two values files. They are excluded from the packaged chart and name no real image and no real database.

The release run performs the same two commands as a gate before it publishes anything, against both values files, so a chart that does not lint or render is never published. It additionally renders the packaged chart against the digest the release published and refuses one that would deploy anything else.

Installing the chart into a real cluster and asserting what only a running deployment can answer — that the pod reaches the database through the chart's own wiring and then refuses to serve until the release's schema artifact has been applied — is still not done anywhere. The repository runs no cluster of its own for it.