Deploying to Kubernetes
deploy/helm/mailfathom/ is the chart. It installs MailFathom, the objects around it, and — unless you tell it
otherwise — the PostgreSQL server it stores mail in. It deliberately installs no Secret: credentials 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 |
A PostgreSQL StatefulSet, its Service, and its initialization script, unless database.deploy.enabled is false |
Any certificate material |
| An optional Ingress | Any schema step |
What you supply
Two things have no default and the chart refuses to render without them. The third has one, and choosing it is the decision this section is mostly about.
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 Secret. The chart names one rather than creating it.
A database is the third thing, and it has a default. The chart runs PostgreSQL with the vector extension as a
single-replica StatefulSet on a retained PersistentVolumeClaim, from the pgvector/pgvector image the Compose
deployment and the local orchestration pin to the same version. It is not a subchart: the templates are in this
repository and change in the diff that changes them.
That default is the smaller of two arrangements, and it is worth knowing which one you are choosing. A claim gives the data a lifetime longer than the pod's and nothing else — no backup schedule, no failover, no point-in-time recovery, and no upgrade path across a PostgreSQL major. Point the chart at a server you already operate once any of those is somebody's job:
database:
deploy:
enabled: false
host: postgres.databases.svc.cluster.local
The two are exclusive and the chart says so rather than preferring one: database.host is required when
deploy.enabled is false, and refused when it is true, where the address is derived from the release name. A
deployed server is reached at <release>-postgres in the release's own namespace.
The role MailFathom connects as is never a superuser, in either arrangement. When the chart deploys the server, its
initialization script runs once on the empty data directory, creates the role that owns the database, and installs the
vector extension while a superuser is still connected — which is the same script, and the same reasoning, the Compose
deployment uses.
That is why a deployed database needs two Secrets rather than one more key. secrets.existingSecret is mounted
whole into the application pod, because the keys MailFathom reads are the ones your own configuration names and the
chart cannot enumerate them; a superuser credential placed there would be readable by the process that serves the
network and parses untrusted mail, which is precisely the boundary the unprivileged role exists to draw. So the
superuser password lives in a Secret of its own, the application never mounts it, and the chart refuses a values
document that names one Secret for both. The database pod, in turn, sees exactly one key of the application's Secret —
the password it must create MailFathom's role with — and nothing else. Compose separates the same two credentials the
same way, by leaving the superuser password off the mailfathom service's own secret list.
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
# Only for a database the chart deploys, and named by database.deploy.superuserPasswordSecret.
kubectl --namespace mailfathom create secret generic mailfathom-postgres-superuser \
--from-literal=postgres-superuser-password='…'
Both database passwords are applied by initdb on the first start and never again, so changing either in its Secret
afterwards changes what is presented rather than what the server accepts — rotate them in the server as well, which
secret rotation covers.
The Secret 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 encrypted systemd credentials the native installation uses do not reach a pod, and they would work against this
shape if they did: nothing schedules a systemd unit here, and that encryption binds material to one machine while every
replica has to open what any other replica sealed. What protects these at rest is the cluster's own Secret encryption,
which is configured on the API server rather than here and is absent until the cluster enables it — upstream Kubernetes
stores a Secret's values unencrypted in etcd without an EncryptionConfiguration.
What an encrypted credential is bound to states the
binding that makes it a poor fit here.
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:
name: mailfathom
user: mailfathom
deploy:
# The chart deploys the database, so it names the Secret holding the superuser password — the second one
# created above. Replace this block with `deploy: {enabled: false}` and a `host:` to use your own server.
superuserPasswordSecret: mailfathom-postgres-superuser
secrets:
existingSecret: mailfathom-secrets
config:
files:
10-mailfathom.json: |
{
"MailSynchronization": {
"Enabled": true,
"Accounts": [
{
"AccountId": "primary",
"DisplayName": "Personal mail",
"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.
The notes an install prints carry the documentation for the version they installed, as a Docs: line naming that
version's own directory on the documentation site — https://krzysztof318.github.io/MailFathom/v<version>/, or
latest on the nightly channel, which is what a nightly actually carries. It is an address rather than a repository
path because somebody reading helm install output has no checkout to resolve one against, which is why the notes'
pointer to applying the schema is an address as well. The unpackaged directory states no version
to name a directory from, so it prints no Docs: line and its schema pointer is the site's version-agnostic address
instead.
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 it is reached by
neither the at-rest encryption a cluster can enable for Secrets nor the auditing a Secret gets. 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'
A database this chart deployed is reached through its own pod, and the extension half of the paragraph below is already
done there — the initialization script installed vector while a superuser was connected, so the script's
CREATE EXTENSION IF NOT EXISTS vector finds it present:
kubectl --namespace mailfathom exec -i statefulset/mailfathom-postgres -- \
psql --username mailfathom --dbname mailfathom \
--set ON_ERROR_STOP=on < 'mailfathom-schema-<version>.sql'
That applies the DDL as database.user, which then owns every object it created and needs no grants afterwards. Back
the database up the same way, with pg_dump through the pod, rather than by copying the claim's files: a file copy of
a running server's data directory is not a backup of it.
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 material — secretName 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, and — only with personalDataScanning.enabled — the analyzer answering for every configured category. 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.
Personal-data scanning
personalDataScanning.enabled is off, and off means the chart renders nothing for it: no Deployment, no Service, and no
configuration key in the application's environment. An opt-in nobody took pulls no image and holds no memory. The
personal-data scanner records what the feature
hides and what each category costs retrieval.
The block follows database's shape, because it is the same decision: one value decides whether the chart runs the
dependency, and the address is either derived from the release or stated, never both.
personalDataScanning:
enabled: true
# analyzer.deploy defaults to true: the chart runs the analyzer and points MailFathom at its own Service.
That renders a single-replica Deployment and a ClusterIP Service, and writes SensitiveContent__Pii__Enabled, the
derived endpoint, the language, and the confidence floor into the application's environment — one decision in one place
rather than a value here and a configuration file that could disagree. The schema refuses those four keys in
config.extraEnvironment for that reason: an address stated there would send mail content somewhere else while the pod
the release installed sat idle.
To use an analyzer you already operate:
personalDataScanning:
enabled: true
analyzer:
deploy: false
endpoint: http://presidio-analyzer.privacy.svc.cluster.local:3000
The chart refuses deploy: true together with an endpoint, deploy: false without one, an endpoint that is not an
absolute http or https address, and an endpoint set while the scanner is off — each with a message naming what to do.
Keep the address inside the cluster: the point of scanning is that content is inspected before it leaves the trust
boundary, and the feature page states what pointing it outside gives up.
The analyzer's Service is ClusterIP with no value to change it, no ingress rule is rendered for it, and its pod mounts no service-account token. It is the pod in the release that reads mail content in the clear.
Resources and readiness. The analyzer requests a gigabyte of memory and is limited to two, because it loads a language
model before it serves anything and holds it for the life of the pod; below roughly a gigabyte it is killed while loading.
Its startup probe allows five minutes of that, and MailFathom's own startup gate refuses to come up while the analyzer is
not answering — so on a first install the application pod may restart a few times before the analyzer is ready. resources,
nodeSelector, tolerations, affinity, and both security contexts are values under personalDataScanning.analyzer.
Spam scanning
spamScanning.enabled is off, and off means the chart renders nothing for it: no Deployment, no Service, and no
SpamClassification__* key in the application's environment. It follows personalDataScanning's shape exactly, because
it is the same decision — one value decides whether the chart runs the dependency, and the address is either derived
from the release or stated, never both. Spam classification records what a
classification holds and what the scanner adds to it.
spamScanning:
enabled: true
# scanner.deploy defaults to true: the chart runs Apache SpamAssassin and points MailFathom at its own Service.
That renders a single-replica Deployment and a ClusterIP Service, and writes SpamClassification__Enabled,
SpamClassification__UseScanner, the derived host and port, and the three bounds into the application's environment.
To use a daemon you already operate:
spamScanning:
enabled: true
scanner:
deploy: false
host: spamassassin.mailfathom.svc.cluster.local
The chart refuses deploy: true together with a host, deploy: false without one, and a host set while spam scanning
is off. Keep the address inside the cluster: the daemon is sent whole messages unredacted, and the feature page
states what pointing it outside gives up.
The scanner's Service is ClusterIP with no ingress rule and its pod mounts no service-account token. It is the second pod in the release that reads mail content in the clear.
Important
The scanner pod needs a baseline namespace. It starts as root to bind its port and drops to an unprivileged
account for every scan, which is what parses the mail, so it needs SETUID and SETGID back after dropping all
capabilities and cannot run under restricted Pod Security Standards. MailFathom's own pod is unaffected and stays
restricted-compatible; if your namespace enforces restricted, the scanner belongs in a namespace of its own with
deploy: false pointing at it.
Rule updates and DNS. DNS_CHECKS is off, so the daemon runs local rules and sends nothing derived from the
owner's mail to a third-party blocklist. Whether it can fetch rule updates is your cluster's egress policy rather than a
chart value; a corpus frozen at the image's build scores today's mail worse than a fresh one, and the feature page states
that trade.
Resources and readiness. The daemon compiles its rule corpus before it listens, so its startup probe allows time for
that and MailFathom's own startup gate refuses to come up while the daemon is not answering — on a first install the
application pod may restart a few times before the scanner is ready. resources, nodeSelector, tolerations,
affinity, both security contexts, and the digest-pinned image are values under spamScanning.scanner.
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
They are one number. Chart.yaml carries version: 0.0.0 as a placeholder and no appVersion at all, and the
release run supplies both from the VersionPrefix in Directory.Build.props that is the only file in the repository
carrying an application version:
version="$(bash scripts/read-declared-version.sh)"
helm package deploy/helm/mailfathom --version "$version" --app-version "$version"
Omitting --version packages the placeholder. Where the version is
observable is where that rule and its reasoning live.
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.
Related
- Applying the database schema — the release artifact, the privileges it needs, and the three startup failures it answers
- The container image — what is inside it, how it runs, and why it carries no schema tool
- Docker Compose — the same contract in the other shape
- Podman Quadlet — the single-machine shape that provisions secrets as systemd credentials
- The platform TLS policy — for a mail server whose handshake the pod's own OpenSSL refuses;
config.extraEnvironmentnames the file, and the chart currently has no hook for mounting it - Configuration sources, secret provisioning, the MCP endpoint