Deploy with Docker
Outcome. You have a geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. endpoint running in a container, with the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. mounted read-only from outside the image, and you know when to build your own image instead of pulling the published one.
The image installs the published @mailwoman/* packages from npm and bakes the model weightsmodel weightsThe learned parameters of the neural classifier, shipped as ONNX files in the @mailwoman/neural-weights-* packages. Weights are locale-specific bundles that include the model, tokenizer, and a model-card.json metadata file. in. The
gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. stays out: it is 1.65 GB and up, it changes on a different schedule from the code, and it
mounts read-only at /data. Parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. works with no volume at all.
How this page was verified. Docker is not installed on the machine these docs are written on, so no
container was built or run here. Every command below is sourced from the repository assets that produce
the published image — the repo-root Dockerfile, docker/package.json, docker/docker-compose.yml and
docker/README.md — and the registry facts in step 1 come from the GHCR API. The
example server in step 4 was executed, outside a container, in both
states the image can start in: with a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. on disk, and with an empty data root, which is what a
first run with no volume looks like. Those transcripts are real.
Treat the docker run lines as sourced, not as executed output.
Prerequisites
- Docker, on
linux/amd64. The image is amd64 only:onnxruntime-nodeships glibc x64 prebuilds, and arm64 has not been verified. - Disk for a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. if you want coordinates. Your first ten minutes
covers what the
candidatebundleevidence bundleThe pair of retrieval-augmented input channels (street-type + locality-surface) that feed lexicon membership as soft per-token evidence alongside the text. Shipped in 6.7.0; trained natively from step 0 in the from-scratch base line. is.
1. Run the published image
docker run --rm -p 3000:3000 ghcr.io/sister-software/mailwoman:latest
curl -s -X POST localhost:3000/v1/parse \
-H 'content-type: application/json' \
-d '{"address":"350 5th Ave, New York, NY 10118"}'
Parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. answers immediately because the weightsparameterA single learned number inside a model — one weight or bias. Mailwoman's encoder has roughly 30 million of them; training is the search for good values. are in the image. /v1/geocode and /v1/batch return a
503 with {"error":"geocoder not available"} rather than crashing, so a first run needs no data at
all.
Check the tag before you depend on it. As of 2026-08-04 the GHCR tag list is 7.3.0 and latest,
both pointing at the same digest — so :latest is the 7.3.0 build while npm is on the 8.x line. The
publish workflow (.github/workflows/docker-publish.yml) has a release: published trigger, but its
run history shows one manual dispatch and no release-triggered rebuild. Pin a digest and re-check the
tag rather than assuming :latest tracks the current package line.
2. Mount the gazetteer read-only
docker run --rm -p 3000:3000 \
-v /path/to/mailwoman-data:/data:ro \
ghcr.io/sister-software/mailwoman:latest
The mount is the whole configuration. The image sets $MAILWOMAN_DATA_ROOT=/data, and since 8.7.0
the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. falls back to <data root>/wof/candidate.db, so a gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. at that path inside the
volume is picked up with no -e flag. Earlier versions of this page passed
-e MAILWOMAN_CANDIDATE_DB=/data/wof/candidate.db; that is now redundant.
:ro is correct for both resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. backends: every runtime lookup opens SQLite read-only, and the only
writable open is an explicit FTS index build, which no serve path performs.
Two mount details decide whether this works on the first try.
- A symlinked
wof/candidate.dbdangles inside the container. Host-side symlinks point at host paths, and discovery follows the link, so a dangling one reads as absent — the container boots with geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. off rather than reporting a broken path. Mount the real file, or point-e MAILWOMAN_CANDIDATE_DB=at its real filename inside the mount. - Per-state rooftoprooftopGeocoding precision at the building or parcel level — coordinates within a few meters — the highest tier of the geocode cascade. Sourced from address-point and situs data. shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. are WAL-mode. Their
-waland-shmsiblings have to be in the same mount. A missing sidecar drops that address to a coarser resolution tier rather than failing the request, which makes it a quiet failure worth checking for.
If you have no gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. yet, the image can fetch one, so your host still needs no Node toolchain:
mkdir -p mailwoman-data
docker run --rm -v "$(pwd)/mailwoman-data:/data" \
ghcr.io/sister-software/mailwoman:latest \
node node_modules/mailwoman/out/cli.js data pull candidate
That one run needs the volume writable — drop the :ro. The image's own $MAILWOMAN_DATA_ROOT is
/data, so the file lands at /data/wof/candidate.db, which is exactly where the read-only run above
expects it.
3. Run a drop-in instead of the native API
The same image ships the three compatible servers. Override the command:
# Nominatim-compatible — /search /reverse /status, port 8080
docker run --rm -p 8080:8080 -v /path/to/data:/data:ro \
ghcr.io/sister-software/mailwoman:latest \
node node_modules/@mailwoman/nominatim/out/cli.js serve
The image's baked health check targets :3000/health, so overriding the command without overriding the
health check leaves the container permanently unhealthy. docker/docker-compose.yml sets a per-service
health check for exactly this reason; copy that shape rather than dropping the check.
4. Build your own image when the published one does not fit
Three reasons to build your own: you want versions pinned rather than latest, you want a smaller
dependency set than every drop-in server, or you want your own entrypoint.
mailwoman.Dockerfile is that image. It differs from the published one in three ways and is otherwise the same file:
RUN npm init -y >/dev/null \
&& npm install --omit=dev --no-audit --no-fund --loglevel=error \
mailwoman@8.7.0 \
@mailwoman/neural@8.7.0 \
@mailwoman/neural-weights-en-us@8.7.0 \
@mailwoman/resolver@8.7.0 \
@mailwoman/resolver-wof-sqlite@8.7.0 \
&& npm cache clean --force
RUN rm -rf node_modules/onnxruntime-node/bin/napi-v6/win32 \
node_modules/onnxruntime-node/bin/napi-v6/darwin \
&& rm -f node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so \
node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_tensorrt.so
The trim is the one worth copying. onnxruntime-node installs 500 MB, most of it the other two
platforms plus the CUDA and TensorRT execution providers. Measured on this host, dropping them takes
node_modules from 746 MB to 303 MB, and CPU inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. is unaffected — it loads
libonnxruntime.so.1 and onnxruntime_binding.node and nothing else.
Deploy on a serverless runtime has the full measurement.
The entrypoint is mailwoman-server.mjs: node:http, no framework,
three routes, everything expensive built once at module scope. It was run outside a container against a
real gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture.:
PORT=13783 MAILWOMAN_DATA_ROOT=/tmp/mw-fresh-demo node mailwoman-server.mjs
curl -s "http://127.0.0.1:13783/health"
curl -s "http://127.0.0.1:13783/geocode?address=1600+Pennsylvania+Ave+NW,+Washington,+DC+20500"
listening on http://0.0.0.0:13783 (geocoder: ready)
{"ok":true,"geocoder":true}
{"lat":38.904831,"lon":-77.016216,"resolution_tier":"admin","locality":"Washington"}
Note what is not in that command: a candidate-database path. /tmp/mw-fresh-demo is a data root
holding one file, wof/candidate.db, which is the shape of the mounted volume above. One line in the
server is why that is enough — and why the same server also boots when the volume is missing:
// Gate on the gazetteer being THERE, not on anything being configured.
const candidateDB = resolveCandidateDBPath()
resolveCandidateDBPath from mailwoman/resolver-backend tries an explicit path, then
$MAILWOMAN_CANDIDATE_DB, then <data root>/wof/candidate.db, and returns nothing unless one of them
is on disk. The last position is what made the -e flag unnecessary. The existence check is what keeps
a no-volume run alive: a server that took a configured path on trust would open a file that is not
there and die at import with SQLITE_ERROR: unable to open database file, before binding a port. The
published image's own entrypoint uses the same guard.
That is verifiable without Docker — point the data root at a directory with no gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. in it, which is exactly the container's first-run state, and run it under plain node:
PORT=13782 MAILWOMAN_DATA_ROOT=/tmp/mw-verify-trial node mailwoman-server.mjs
curl -s "http://127.0.0.1:13782/health"
curl -s "http://127.0.0.1:13782/geocode?address=1600+Pennsylvania+Ave+NW"
curl -s -o /dev/null -w 'parse status %{http_code}\n' "http://127.0.0.1:13782/parse?address=1600+Pennsylvania+Ave+NW,+Washington+DC"
listening on http://0.0.0.0:13782 (geocoder: off)
{"ok":true,"geocoder":false}
{"error":"no gazetteer found — mount one at <data-root>/wof/candidate.db"}
parse status 200
The process is alive, parsingaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. answers 200, and geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. reports 503. That is the "parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. works with no volume" claim from step 1, demonstrated rather than assumed.
One trap survives the change, and it is worth knowing because it is the opposite of what the fallback
suggests. A $MAILWOMAN_CANDIDATE_DB that names a file which is not there does not fall through to
the convention path — discovery stops at the first position that is set and reports nothing found. So a
stale -e flag turns a correctly mounted volume into a container with geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. off. Measured with the
same server on 8.7.0: the variable set to /data/wof/candidate.db with no volume gives the transcript
above, and it gives it even when /tmp/mw-fresh-demo/wof/candidate.db exists.
Build it with the two example files side by side, saved under the names the docs site serves them as:
curl -O https://mailwoman.sister.software/examples/mailwoman.Dockerfile
curl -O https://mailwoman.sister.software/examples/mailwoman-server.mjs
docker build -f mailwoman.Dockerfile -t my-mailwoman .
The COPY and CMD lines name mailwoman-server.mjs, so a directory holding those two downloads
builds without renaming anything.
Verify
If you built your own image in step 4:
those pins are ahead of npm as this page is written — mailwoman.Dockerfile names 8.7.0, npm view mailwoman version reports 8.6.0 — so that build fails at npm install until 8.7.0 publishes. Check the
published version before you build, and back the pins off to it if you hit this first.
The health route is the image's own health-check target, so a green check and a successful curl are the same signal:
docker run -d --name mw -p 3000:3000 \
-v /path/to/mailwoman-data:/data:ro \
ghcr.io/sister-software/mailwoman:latest
docker inspect --format '{{.State.Health.Status}}' mw
curl -s localhost:3000/health
Allow for the start period: the baked health check gives 60 s before it starts counting failures, because the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' load is the slow part of boot.
Limits
linux/amd64only. An arm64 image needs the arm64 ONNX RuntimeONNX (Open Neural Network Exchange). An open format for machine learning models that enables interoperability between training frameworks and inference runtimes. Mailwoman ships its trained model as an ONNX file so it can run in Node.js and the browser via onnxruntime. prebuild verified first. On Apple silicon this runs under emulation, slowly.- No Alpine base.
onnxruntime-nodehas no musl prebuild.node:24-slimis Debian, andlibgomp1is installed on top of it for threaded inferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece.. - The container listens on 3000 and the port is not configurable in the published image. Remap on
the host side with
-p <host>:3000. - One container is one CPU-bound worker. InferenceinferenceRunning the trained model on new input to get predictions, as opposed to training, which produces the model. In Mailwoman that means a small transformer encoder reads an address string and classifies every token — house number, street, locality, region, postcode, and the rest. A Who's On First gazetteer can feed soft location hints into the pass, but the model makes the final call on every label. Where a generative model writes text token by token, Mailwoman's output is a retrieval-augmented token classification: one label per input piece. blocks the JavaScript threadthreadA parallel workstream within a release. Threads compose; they are not sequential milestones like phases., so concurrency comes from replicas. Geocode a large file at volume has the measurements.
- The published image's dependencies are pinned to
latestat build time. That is deliberate — each rebuild integration-tests the current published packages — but it means two builds of the same Dockerfile can differ. Pin a digest for a deployment.
Related
- Deploy on a serverless runtime — the same disk story without a container.
- Swap in a drop-in for an existing client — what the drop-in servers in step 3 answer.
- Run the API server — the
/v1surface the default command serves.