Skip to main content

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-node ships 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 candidate bundleevidence 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.db dangles 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 -wal and -shm siblings 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/amd64 only. 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-node has no musl prebuild. node:24-slim is Debian, and libgomp1 is 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 latest at 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.