The container image
deploy/docker/Dockerfile is the only image definition MailFathom has. Both deployment shapes in deploy/ build from it,
and nothing else produces an image, so what this page describes is what runs wherever MailFathom runs in a container.
The build context is the repository root, so the definition is named rather than found:
docker build --target runtime --file deploy/docker/Dockerfile --tag mailfathom:local .
It produces one image: the service. It carries no migration tool, no SQL, and no credential that could apply one, which
is what makes "the host never applies a migration" a property of what was built rather than a rule someone has to
remember. DatabaseSchemaStartupGate refuses to start against a schema this build does not recognize, in every
environment, and the reviewed artifact that answers that refusal is the idempotent SQL file each release ships rather
than anything in here. Applying the database schema documents it.
What is inside, and what is not
The runtime image is built on mcr.microsoft.com/dotnet/aspnet:10.0.10-noble-chiseled-extra and is about 77 MB.
Chiseled means there is no shell, no package manager, and no HTTP client: a process that reaches the container finds
almost nothing to use. -extra carries ICU and tzdata, which the plain chiseled image does not — MailFathom decodes
internationalized headers, folds case for search, and formats instants for several time zones, and the invariant
globalization the smaller image forces would quietly change how mail from outside one alphabet is read.
It contains the published application and nothing else — plus the two files that licensing requires travel with it,
/app/LICENSE and /app/NOTICE. No SDK, no source tree, no repository history, no test
artifacts, no build cache, no credential, and no certificate. The XML documentation files every project generates are
dropped at publish, because none is read at run time and shipping them would put the repository's commentary about its
own internal contracts into an artifact an operator can unpack. The portable symbol files stay, because they are what
turns a stack trace in a support report into file and line numbers.
deploy/docker/Dockerfile.dockerignore is an allow-list rather than a deny-list: it excludes everything and then names
what may reach the build. The build context is the repository root, which is also where a developer's .env, a mounted
secret, and a certificate live, and a rule that only excluded what someone remembered would send all of them to the
daemon. Docker looks for an ignore-file named after the Dockerfile before it looks for one at the context root, and
prefers it, so the file bounding the context travels with the definition that uses it.
Every base image is pinned to an explicit patch version rather than to a floating 10.0, so a rebuild months from now
resolves what the change was reviewed against.
How it runs
| Property | Value |
|---|---|
| User | 1654, the unprivileged app account the .NET base images define |
| Ports | 8080, plain HTTP, for /mcp, which McpEndpoint:Port moves; 8081 for the probes, on a listener of its own |
| Ports once TLS is configured | The HTTPS profiles' own ports, plus 8080 again — which becomes a redirect to them rather than serving the routes, so the port this image already publishes keeps answering |
| Writable paths | /tmp only, which a deployment supplies as a tmpfs or an emptyDir |
| Entrypoint | dotnet /app/MailFathom.Host.dll |
| Health check | None. See the health endpoints below. |
The application directory is owned by root and the process is not, so the service cannot rewrite its own code even
before the deployment imposes a read-only root filesystem on it. Both deployments do impose one, and both drop every
Linux capability.
The container speaks plain HTTP and terminates no TLS. A certificate belongs to the reverse proxy or the ingress in
front of it, which is also the only place one has to exist. An MCP endpoint reached over plain HTTP hands its API key
and every message it serves to anything on the network path. The public scheme and host reach the process from any peer
until you name that proxy in ReverseProxy:TrustedProxies, which is what stops every other container on the bridge
network from setting them — see
behind a TLS-terminating reverse proxy.
DOTNET_EnableDiagnostics=0 is set, so no diagnostic IPC socket is created. That socket can request a process dump,
and a dump is a way to read secret material out of managed memory — the residual exposure
secret provisioning documents and asks deployments to
close. Set it back to 1 deliberately, for one session, when a dump is genuinely needed.
The health endpoints
The host serves three probes on a listener of their own — port 8081 unless a deployment configures another — in every
environment. Kubernetes probes them over HTTP from the kubelet and needs nothing inside the container, which is what
the chart's probes use.
The image declares no HEALTHCHECK. Docker and Podman run one as a command inside the container, and a chiseled
image has no shell and no HTTP client for one to be written in. Adding either so the container could ask an endpoint
that is already reachable from outside would grow its attack surface for nothing; under Compose, the published probe
port is asked from the host instead.
The three answer different questions and are wired to different probes on purpose:
/startedreports whether the host's startup gates have completed: every secret reference resolved and the database schema verified. It is what an orchestrator's startup probe reads, so a slow first start extends the grace period rather than counting as a failure./healthconsults the dependencies a request needs, the database among them. It is readiness: a process that cannot reach its database stops receiving requests it cannot fulfil./aliveconsults only process-local state. It is liveness: a database outage must never become a restart loop that cannot fix what is actually broken.
All three are unauthenticated, and none of them is served on port 8080 — a probe path asked there is answered with
404, and /mcp asked on the probe port is too. The exposure control is which network the probe port is published on.
The health endpoints records the full contract, including the TLS transports and the switch that
turns the surface off.
Shutdown
SIGTERM starts a graceful stop. The host's own budget comes from MailSynchronization:ShutdownDrainTimeout, and a
deployment's grace period has to be longer than it or the process is killed with the drain still running. Both
deployments in deploy/ allow 60 seconds against a 10-second default; raise them together.
Labels
The image carries the OCI labels that let a pulled image be traced back to the commit it was built from —
org.opencontainers.image.source, .revision, .version, .created — supplied as build arguments.
IMAGE_VERSION has no useful default, and its 0.0.0-unversioned placeholder says so. The version is declared once,
as VersionPrefix in Directory.Build.props, and every build reads it with scripts/read-declared-version.sh rather
than restating it — which is what keeps a labelled version from drifting away from the stamped one, because there is no
second copy to drift. ADR 0004 records why the number lives in one
reviewed line.
IMAGE_REVISION is passed to the publish inside the build as SourceRevisionId, so the assemblies report the same
commit the label names rather than a second claim about it. The running process then reports its version and revision
in its startup record, and its version to an MCP client during initialize. A published
artifact identifies itself both ways: from outside, through the labels, without being run; and from inside, once it is.
docker build --target runtime --file deploy/docker/Dockerfile \
--build-arg "IMAGE_VERSION=$(bash scripts/read-declared-version.sh)" \
--build-arg "IMAGE_REVISION=$(git rev-parse HEAD)" \
--build-arg "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--tag mailfathom:local .
io.mailfathom.release-channel states which channel produced the image: release, nightly, or local for a build
nobody published. The version identifier already separates the channels — a nightly always carries a -nightly.<n>
prerelease identifier and a release never carries one — and this label is what still answers the question once a
reference has been re-tagged, mirrored, or reduced to a digest, which is the state a reference reaches long before
anyone asks what it is. It arrives as IMAGE_RELEASE_CHANNEL, and its default is deliberately neither channel.
org.opencontainers.image.licenses is fixed rather than passed in, at Apache-2.0, because it states MailFathom's own
license and a build must not be able to say otherwise. The label is only the claim a registry indexes; the terms
themselves are /app/LICENSE and /app/NOTICE, which arrive as part of the publish output the runtime stage copies.
Host fails its own publish when either is missing, so the image cannot be built without them. The third-party
notices that must accompany them are not in the image yet — see THIRD_PARTY_LICENSES.md and issue #191.
org.opencontainers.image.description is the sentence a registry shows beside the image, and it is also what the
release pushes as the Docker Hub repository's short description — that field is read off the published manifest rather
than written a second time in the workflow, so the registry page and the label every other registry indexes cannot come
to describe different products. Docker Hub accepts 100 bytes there and truncates anything longer without failing, so
the label is written to fit and the release asserts that it does; Verification states what happens when
it stops fitting.
io.artifacthub.package.logo-url is the only label the image carries that OCI does not define. The specification has no
field for a project icon, so a listing reads a vendor label instead. It points at assets/icon-1254.png in the
repository, which is the asset the Helm chart's icon names as well, and it stays a URL because a label carries no
payload of its own.
The schema
The image applies none and carries nothing that could: no migration tool, no SQL, and no credential that could reach a
database with DDL. What a released installation applies is a file beside the image rather than something inside it —
mailfathom-schema-<version>.sql, attached to the release — and running it is an explicit operator action each
deployment page describes.
The role that applies it needs more privilege than the service's: the schema installs the vector extension, which
PostgreSQL does not permit an ordinary role to create. That asymmetry is why the step is separate, and why a command
inside this image would be the wrong shape for it whatever else it cost — the credentials this process runs with are
not the ones that may run DDL. Grant the service a role that can read and write rows and nothing more, and give the
schema step a role that can do the rest. The Compose deployment installs the extension during initialization, while a
superuser is still connected, so neither of its roles has to be one.
Applying the database schema is the whole path, including the ownership grants a separate role leaves behind and the three startup failures a schema problem reports.
Published images
Images are published to two registries:
| Registry | Reference |
|---|---|
| GitHub Container Registry | ghcr.io/krzysztof318/mailfathom |
| Docker Hub | docker.io/krzysztof318/mailfathom |
Both carry the same manifest list under the same digest, for every version and on both channels. One build produces one manifest list and the publishing run pushes it to both, so they are mirrors rather than two artifacts that happen to share a name — which is why the registry you pull from is not part of what you have to trust, and why a network that can reach only one of them is not a reason a MailFathom version is unreachable. The run verifies that equality rather than assuming it, and a run that reached only one registry is a failed publication.
GHCR is the canonical reference to quote, because it is where the repository, the package page, and the chart all sit. Docker Hub is the convenience mirror.
The package is public, so a pull needs no authentication and no GitHub account:
docker pull ghcr.io/krzysztof318/mailfathom:nightly
A package's visibility is a setting of its own rather than something it inherits from the repository, and it is configured to follow the repository's access. Nothing in a workflow reads it back, so the settings page is where it is confirmed.
Two channels are published, and what separates them is the version identifier rather than where the image sits:
| Tag | Channel | Moves | Published by |
|---|---|---|---|
<major>.<minor>.<patch> |
release | never | Release, on an annotated v<major>.<minor>.<patch> tag |
latest |
release | to the highest release that carries no prerelease identifier | the same run |
<major>.<minor>.<patch>-nightly.<n>-<short revision> |
nightly | never, until it is pruned | Nightly |
nightly |
nightly | to the newest published nightly | the same run |
latest is chosen by excluding every version carrying a prerelease identifier and taking the highest of what remains,
never by taking a maximum — because main names the next release, a maximum would select a nightly. A patch to an
older line is a supported release and still does not move latest.
A nightly's identifier carries both the run number and the commit it was built from, so the tag says what it is without
anything having to be inspected; they travel in one dot-separated part because an OCI tag admits no + build metadata.
The consequence is worth knowing: 41-3f1c9ab contains letters, so SemVer compares it as text rather than as a number,
and nightlies do not sort against each other numerically. Nothing here depends on that — every nightly still sorts
below the release it previews, nightly names the newest one, and retention works from when a version was published —
but a tool that picks "the newest nightly" by sorting versions will get it wrong. Ask the registry for nightly
instead.
Release runs only on a tag push. It refuses to publish before it has checked that the tag is annotated and carries no
prerelease identifier, that the tagged commit is reachable from main or from that line's release/<major>.<minor>.x
branch, that the version equals the tagged commit's own VersionPrefix, that it advances its own major.minor line,
and that CHANGELOG.md has a non-empty section for it. scripts/assert-release-tag.sh is that check, and it runs
before anything is built.
Nightly runs at 00:00 UTC — 02:00 in Europe/Warsaw under CEST, 01:00 under CET — and publishes nothing when main
has not moved since the last published nightly. It reads that from the revision the published nightly image itself
carries, so a deleted tag or a pruned package leaves it right rather than stuck. A workflow_dispatch builds a snapshot
whether or not main moved, and refuses a ref that is not reachable from main.
The newest 30 nightly versions are kept and older ones are deleted, so a channel that publishes every night does not
grow without bound. Only versions whose every tag is a nightly identifier are ever deleted: a release, latest, and an
attestation manifest are out of that step's reach by construction.
What a nightly build risks
A nightly is whatever main was that night. It is published so a change can be tried, and running one is a decision
rather than a default — which is why both deployment shapes put an acknowledgement in front of it. What you take on:
- The database schema may be ahead of any release. A nightly can carry a schema change no release's script applies,
and MailFathom refuses to start against a schema it does not recognize. Its own script is on the
Nightlyrun that built it, underschema-artifact, and that run is the only place it exists; recovering from a schema a nightly established usually means restoring the database rather than downgrading the image. - There is no upgrade path, in either direction. Nothing tests that yesterday's nightly upgrades to today's, that a nightly upgrades to the release that follows it, or that a release can be put back after one. A production database that a nightly has touched may not be usable by a release.
- The four public surfaces may move without notice. A configuration key can be renamed, a tool contract can change, a default can flip, and none of it earns a changelog entry until the release that contains it is prepared.
- It is not supported and carries no promise. No defect report about a nightly is a release defect, and no nightly is patched — the fix is the next nightly.
- It disappears. Only the newest 30 are kept, so a nightly you deployed can stop being pullable, which is enough to break a node that has to re-pull the image.
- The vulnerability scan does not block it. A
HIGHorCRITICALfinding refuses to publish a release and is only reported on a nightly, so a nightly may carry a finding a release never would.
Use a release for anything you are not prepared to rebuild from scratch. Where a nightly is the right answer — trying a
change, reproducing a defect, previewing what the next release will contain — pin the exact -nightly.<n>-<short revision> tag or the
digest rather than the moving nightly tag, so what you are running does not change under you.
Verification
The Container image workflow builds this file for linux/amd64 and linux/arm64 and stops there. It is manual
dispatch only and publishes nothing; no registry credential reaches it, and it is what proves a Dockerfile change still
builds without waiting for a release.
Publication runs the gates instead, in an order that spends the cheap ones first:
Build, test, format, and migrations, against the commit being published rather than against a branch — the build, the unit-test and coverage gate,dotnet format, and the check that no model change outran its migration. Both channels wait for all four.CIcalls the same workflow for a pull request, so "this image passed CI" is one claim about one definition rather than about a copy of it. WhatCIkeeps to itself is the part that is about a pull request: skipping work the changed files cannot affect, and waiting for a draft to be marked ready. A publication skips neither.The migration check is the one worth naming here rather than leaving to
CI: an image whose committed model snapshot describes a schema no migration produces would refuse to start against any database an operator can actually have, and a nightly is installable long before a tag exists to catch it.The integration suite, for a release only, and only after CI has passed. It starts PostgreSQL, applies the baseline migration, and asserts against the result, which is minutes of container time no commit a unit test would have rejected is worth. A nightly does not run it.
The image gates. The image is built for one architecture, started, and required to report the version and revision its labels claim, to run as the unprivileged
1654account, and to expose both listeners; then Trivy scans it, which refuses to publish a release carrying a fixableHIGHorCRITICALfinding and only reports one on a nightly.
Nothing is built from the commit until the first two have passed — not the image, not the schema script, and not
the command binaries. They are jobs in Release and Nightly themselves rather than steps inside the workflow that
pushes the image, because the image is one of three things a channel builds and a gate inside it would gate only that
one. Everything above is therefore the order of a whole publication rather than of the image alone: what a red run
costs is the gate that refused it, not four dotnet publish invocations and a schema generation beside a failing
build. scripts/test-agent-workflow.sh reads both workflows' job graphs and fails one whose publishing job does not
wait for the gate, so a fifth artifact is gated by being added rather than by being reviewed.
Both channels also build the schema artifact, from one shared definition, and differ only in what they do with it. A release waits on it and refuses to push when it cannot be produced: an operator handed an image and no way to reach the schema it requires has a deployment that starts, fails the startup gate, and stays down. A nightly builds it beside the push rather than in front of it, for the same reason its vulnerability scan reports instead of blocking, and leaves the file on the workflow run — there is no release for it to be attached to. Applying the database schema is what that artifact is and how it is applied.
Only then is the multi-architecture manifest list built and pushed — once, to both registries, because every reference it takes in either of them is in one tag list. After the push it is inspected by digest and required to carry both platforms, to identify itself as the channel and version it was published as, and to resolve to the same digest in each registry. A failure anywhere above publishes nothing.
A release additionally synchronizes deploy/docker/README.md onto the Docker Hub repository page, which is the one
registry overview that is not rendered from the repository itself, together with the short description that sits above
it — taken from the published image's own org.opencontainers.image.description. GHCR reads the repository through the
image's org.opencontainers.image.source label and needs nothing pushed to it.
That page exists rather than the root README being pushed, because the two are read by different people. Somebody on Docker Hub already has an image reference and wants to run a container: the tags, what the image needs before it starts, how it runs, and how to verify it. Somebody on the repository is deciding whether to adopt the project at all. The chart listing still renders the root README, because an Artifact Hub page is read by the second reader rather than the first.
Docker Hub's two limits are checked before that write rather than left to the action performing it, which truncates
over-long content and reports success: a release fails if the overview exceeds 25000 bytes or if the description label
exceeds 100. The second is the one worth failing for. A truncated overview is visibly broken and would be noticed, while
a truncated short description is a sentence cut mid-word that reads like a sentence the project meant to write.
scripts/test-agent-workflow.sh reads the overview's length as well, so a page that outgrew the limit fails on the pull
request that wrote it rather than here, where the image is published by the time the check runs.
A re-run of a publication is safe and does not rebuild. A version already present in both registries from the same commit is reported and its image left untouched; a version present in one and missing from the other — what a partial publication leaves behind — is copied across by digest, so the artifact that reaches the second registry is the one the first already answers for rather than a second build of the same source. A version present under a different commit is refused outright, because a published tag is immutable.
The attestation is the one thing a re-run always redoes, because whether a digest is in a registry and whether it has been attested are different questions. A run whose push succeeded and whose attestation did not would otherwise be recovered by a re-run that skipped the attestation for good and reported success, leaving an image this page says can be verified and cannot. Re-attesting a digest adds a second valid statement, and verification accepts any of them.
A published image can be verified the same way from outside:
docker buildx imagetools inspect ghcr.io/krzysztof318/mailfathom:latest
gh attestation verify oci://ghcr.io/krzysztof318/mailfathom:latest --repo Krzysztof318/MailFathom
The first prints the manifest list, its platforms, and the labels above. The second checks the signed provenance statement that says this digest was built by this repository's workflow from a named commit; a release carries that attestation in the registry as well, so verification does not depend on reaching GitHub's attestation store.
Both commands take the Docker Hub reference in place of the GHCR one and answer the same, because the digest is the same and the run attests it under each repository name it published to:
docker buildx imagetools inspect docker.io/krzysztof318/mailfathom:latest
gh attestation verify oci://docker.io/krzysztof318/mailfathom:latest --repo Krzysztof318/MailFathom
Comparing the two digests is the check that the mirrors agree, and it needs no credential:
docker buildx imagetools inspect ghcr.io/krzysztof318/mailfathom:latest --format '{{ .Manifest.Digest }}'
docker buildx imagetools inspect docker.io/krzysztof318/mailfathom:latest --format '{{ .Manifest.Digest }}'
What no gate covers is the deployment around the image — that it reads its mounted configuration, resolves its mounted secret, reaches a real database, and then refuses an unrecognized schema. A change there is reviewed by reading it and, where it is worth running, by starting the Compose deployment by hand as Deploying with Docker Compose describes.