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 includes 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.. 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. is kept out of the image because it is 2.88 GB or larger and changes on a different schedule from the code. It mounts read-only at /data. The parser works without any volume.

How this page was verified. Docker is not installed on the machine these docs are written on, so no container was built or run for this page. Every command below comes from the repository files that produce the published image: the repo-root Dockerfile, docker/package.json, docker/docker-compose.yml and docker/README.md. 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. The empty data root matches a first run without a volume. Those transcripts are real output. The docker run lines were not executed.

Prerequisites​

  • Docker, on linux/amd64. The image is amd64 only, because 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"}'

The parser 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"} instead of crashing, so a first run needs no data.

Check the tag before you depend on it. As of 2026-08-04 the GHCR tag list is 7.3.0 and latest, and both point at the same digest. :latest is therefore 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 instead of assuming that :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 only configuration needed. 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>/db/wof/candidate.db. 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 found without an -e flag. Earlier versions of this page passed -e MAILWOMAN_CANDIDATE_DB=/data/db/wof/candidate.db, which 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. The only writable open is an explicit FTS index build, and the servers never run one.

Two mount details decide whether this works on the first try.

  • A symlinked db/wof/candidate.db is broken inside the container. Host-side symlinks point at host paths, and discovery follows the link. A broken link looks like a missing file, so 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 instead of reporting a broken path. Mount the real file, or set -e MAILWOMAN_CANDIDATE_DB= to 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. databases use WAL mode. Their -wal and -shm sibling files must be in the same mount. If a sibling file is missing, the address falls back to a coarser resolution tier and the request still succeeds. This failure produces no error, so check for it.

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

This one run needs a writable volume, so omit :ro. The image's $MAILWOMAN_DATA_ROOT is /data, so the file is saved at /data/db/wof/candidate.db, which is where the read-only run above expects it.

3. Run a drop-in instead of the native API​

The same image includes 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/main.js serve

The image's built-in health check targets :3000/health. If you override the command without overriding the health check, the container stays unhealthy. docker/docker-compose.yml sets a health check for each service for this reason. Copy that approach instead of removing the check.

4. Build your own image when the published one does not fit​

Build your own image if you want pinned versions instead of latest, a smaller dependency set than the full set of drop-in servers, or your own entrypoint.

mailwoman.Dockerfile builds such an image. It differs from the published Dockerfile in three ways and is otherwise the same:

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 binary trim saves the most space of these changes. onnxruntime-node installs 500 MB, most of it for the other two platforms and for the CUDA and TensorRT execution providers. On this host, removing them reduced node_modules from 746 MB to 303 MB. 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, because it loads only libonnxruntime.so.1 and onnxruntime_binding.node. Deploy on a serverless runtime has the full measurement.

The entrypoint is mailwoman-server.mjs. It uses node:http without a framework, serves three routes, and builds every expensive object 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","epistemic_status":"observed","locality":"Washington"}

That command does not pass a candidate-database path. /tmp/mw-fresh-demo is a data root holding one file, db/wof/candidate.db, which matches the layout of the mounted volume above. One line in the server makes that layout sufficient, and the same line lets the server boot when the volume is missing:

// Check on the gazetteer being THERE rather than on anything being configured.
const candidateDB = await resolveCandidateDBPath()

resolveCandidateDBPath from mailwoman/resolver-backend tries an explicit path, then $MAILWOMAN_CANDIDATE_DB, then <data root>/db/wof/candidate.db, and returns no path unless one of them is on disk. The data-root fallback makes the -e flag unnecessary. The existence check keeps a run without a volume alive. A server that trusted a configured path would try to open a missing file and exit at import with SQLITE_ERROR: unable to open database file, before binding a port. The published image's entrypoint uses the same check.

You can verify this without Docker. Point the data root at a directory without 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.. That directory matches the container's first-run state. Then run the server 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>/db/wof/candidate.db"}
parse status 200

The process stays up, 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. returns 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. This output demonstrates the claim from step 1 that 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 without a volume.

One behavior contradicts what the fallback suggests. If $MAILWOMAN_CANDIDATE_DB points to a file that does not exist, discovery does not fall through to the default path. It stops at the first position that is set and reports that 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. was found. A stale -e flag therefore 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. This was measured with the same server on 8.7.0. Set the variable to /data/db/wof/candidate.db without a volume produces the transcript above, even when /tmp/mw-fresh-demo/db/wof/candidate.db exists.

To build the image, save the two example files side by side under the filenames the docs site uses:

curl -O https://mailwoman.ai/examples/mailwoman.Dockerfile
curl -O https://mailwoman.ai/examples/mailwoman-server.mjs
docker build -f mailwoman.Dockerfile -t my-mailwoman .

The COPY and CMD lines refer to mailwoman-server.mjs, so a directory holding those two downloads builds without renaming anything.

Verify​

If you built your own image in step 4, note that its pins were ahead of npm when this page was written. mailwoman.Dockerfile pins 8.7.0, while npm view mailwoman version reports 8.6.0, so the build fails at npm install until 8.7.0 is published. Check the published version before you build, and lower the pins to match it if needed.

The health route is the image's own health-check target, so a healthy status and a successful curl confirm the same thing:

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 built-in health check waits 60 s before it starts counting failures, because loading 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.' is the slow part of startup.

Limits​

  • The image is linux/amd64 only. An arm64 image requires verifying 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 first. On Apple silicon the image runs slowly under emulation.
  • The image cannot use an Alpine base. onnxruntime-node has no musl prebuild. node:24-slim is Debian, and the image installs libgomp1 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 published image does not let you change the port. Remap it 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 add replicas for concurrency. Geocode a large file at volume has the measurements.
  • The published image's dependencies resolve to latest at build time. This is deliberate, because each rebuild integration-tests the current published packages. It also means two builds of the same Dockerfile can differ. Pin a digest for a deployment.