UzMap docs

Self-hosting and operations

UzMap is one Go binary, uzmap, plus a directory of built map data. In production it runs as a Docker Compose stack behind Nginx Proxy Manager, with a Valhalla container for routing and an optional developer console. This page covers the binary's flags, the data directory it expects, the compose topology, the environment, and how to deploy without CI.

Every path, flag and default below was read from the source; each section names the file it came from.

The uzmap binary#

uzmap <subcommand> [flags]. Subcommands: index, buildings, furniture, keys, serve. Flags use Go's flag package, so --flag value, --flag=value and single-dash forms are all accepted, and booleans can be bare (--require-key) or explicit (--require-key=false). Anything else prints usage: uzmap index|buildings|furniture|keys|serve [flags] and exits 2.

uzmap serve#

FlagTypeDefaultMeaning
--datastringdataData directory. build/, fonts/, sprites/ and cache/ are resolved beneath it (layout below).
--addrstring:8080Listen address.
--valhallastringhttp://127.0.0.1:8012Base URL of the Valhalla service. The server POSTs to <base>/route and checks <base>/status when answering /api/status. The compose file overrides this to http://valhalla:8002.
--wikimediabooltrueWikimedia Commons photo enrichment. Makes outbound HTTP calls and caches results under <data>/cache/photos. Pass --wikimedia=false on a host without outbound network.
--staticstringemptyDirectory served at / as a single-page app: a path that does not exist on disk (or is a directory) gets index.html. Empty means / is not served.
--keysstringemptyJSON API-key file. Empty disables key checking entirely. A missing file loads as an empty key set.
--require-keyboolfalseReject requests to gated paths without a valid key. Ignored unless --keys is set. Without it keys are still validated and metered, but nothing is refused.
--key-rateint600Default requests per minute for keys that do not set their own rate_limit.
--key-reloadduration5sHow often the key file is polled for changes.
--usagestringemptySQLite usage-metering database. Empty disables metering.
--usage-flushduration30sHow often in-memory usage counters are written to the database.
--sdkstringemptyDirectory holding the built browser SDK, served at /v1/. If empty, or if <dir>/uzmaps.js does not exist, /v1/ is not served.

Start-up behaviour:

  • A key file that exists but cannot be read is fatal. The error names the uid the server runs as, because the usual cause is a file created by root that uid 100 cannot open. This takes the whole site down, /health included, since the process never starts.
  • --require-key with zero keys issued does start, but logs WARN: no API keys issued — every request will be rejected.
  • A usage database that cannot be opened is not fatal: metering is disabled with a WARN and the server serves normally. The asymmetry is deliberate — an unreadable key file means the server cannot tell allowed traffic from refused, whereas a lost counter only costs accounting.
  • A missing build/search.sqlite or build/uzbekistan.pmtiles is a warning, not an error; the corresponding feature is disabled.
  • On SIGTERM or SIGINT the server stops accepting connections, waits up to 8 s for in-flight requests, flushes usage counters and exits. The comment notes Docker's default stop grace is 10 s, so a normal docker compose restart loses no counters.

Which paths a key gates:

PathsKey required under --require-key
/api/*, /tiles/*, /terrain/*, /fonts/*, /sprites/*, /styles/*yes
/health, /api/status, /v1/*, the SPA shell and its /assets/no

/health returns {"ok":true} and is what every healthcheck in the stack uses. /api/status returns uptime_s, tiles, search, routing, photos, docs, built_at, attribution, bounds, maxzoom and a sources object (tiles, terrain, places, buildings_ml, furniture), and is the quickest way to see which data a deployment actually has.

uzmap keys#

uzmap keys add    --keys FILE --name NAME [--origins a,b] [--rate N] [--quota N] [--note TEXT]
uzmap keys list   --keys FILE
uzmap keys revoke --keys FILE --key KEY

Keys are managed from the command line, not from an HTTP endpoint. The comment in main.go gives the reason: issuing keys is rare, and an authenticated admin endpoint would be a larger attack surface than the feature justifies.

The same flag set is parsed for every subcommand:

FlagTypeDefaultUsed by
--keysstringkeys.jsonall: the key file
--namestringemptyadd (required, so a key can be traced to a project)
--originsstringemptyadd: comma-separated allowed origins, e.g. https://acme.uz,https://*.acme.uz
--notestringemptyadd: free text
--rateint0add: requests per minute; 0 means the server's --key-rate
--quotaint640add: billable requests per calendar month (UTC); 0 means unlimited
--keystringemptyrevoke (required)
  • add prints the new key, 32 hex characters, on stdout and nothing else there, so KEY=$(uzmap keys add ...) works. If --origins is empty it also prints a warning on stderr: a browser key with no origin allowlist is usable from anywhere.
  • list prints one tab-separated line per key, oldest first: key, active or disabled, name, origins or (unrestricted), quota as unlimited or N/mo, and the creation date as YYYY-MM-DD.
  • revoke prints revoked, or exits non-zero with no such key: ….

No subcommand sets disabled; it is a field in the file.

File format: a JSON array.

json
[
  {
    "key": "9f1c0b4e2d7a4c1e8b3f6a5d0c2e7b19",
    "name": "Acme delivery",
    "origins": ["https://acme.uz", "https://*.acme.uz"],
    "rate_limit": 300,
    "monthly_quota": 100000,
    "created_at": "2026-09-05T10:00:00Z"
  }
]

key, name and created_at are always written; origins, rate_limit, monthly_quota, disabled and note are omitted when empty. A body of [] is valid and yields no keys. Entries with an empty key are ignored.

How writes happen: the store writes <file>.tmp with mode 0600, copies the owner and mode of the existing file onto it, then renames it over the original. The parent directory is created with 0700 if absent. So the first write is owned by whoever ran the CLI, which on most hosts is root, and every later write keeps whatever ownership you have since set. This is why the chown in the host preparation below is a one-off.

Hot reload: the server stats the file every --key-reload and re-reads it when the mtime or size changes, so a new key is live within about five seconds. No restart. A file that becomes malformed or unreadable is logged and ignored, keeping the previously loaded set. Polling rather than inotify is deliberate: the file arrives over a bind mount, and host-side writes do not generate inotify events inside the container.

Data-building subcommands#

These run wherever the map data is built; the data/pipeline/*.mjs scripts wrap them.

SubcommandFlagsNotes
index--pbf (required), --out (default search.sqlite), --geonames, --geonames-alt, --overture, --overture-out, --overture-min-confidence (float, 0.5), --datasets, --datasets-outBuilds the search and geocoding SQLite index from an OSM PBF extract. Run by npm run data:index.
buildings--pbf, --ml-dir, --out (all required), --min-area (float, 12, m²)Merges Microsoft ML building footprints, de-duplicated against OSM.
furniture--pbf, --out (both required)Extracts street furniture as GeoJSONL for a tileset.

Data directory layout#

Path under --dataProduced byServed or used asIf absent
build/uzbekistan.pmtilesnpm run data:tilesBasemap: /tiles/{z}/{x}/{y}, /tiles/tilejson.json, /tiles/uzbekistan.pmtilesWARN, no tiles
build/places-overture.pmtilesnpm run data:placesSupplementary tile source places-overture; sources.places in /api/statusomitted
build/buildings-ml.pmtilesnpm run data:buildingsSupplementary source buildings-ml; sources.buildings_mlomitted
build/furniture.pmtilesnode data/pipeline/furniture.mjsSupplementary source furniture; sources.furnitureomitted
build/*.pmtiles (any other)Every other .pmtiles in build/ becomes a tile source named after its file stem
build/search.sqlitenpm run data:indexSearch and geocoding. Opened read-only (mode=ro, internal/store)WARN, search and geocoding disabled
build/search.cachethe server itselfGzipped snapshot of the in-memory index, keyed on the SQLite file's mtime and sizeIndex rebuilt from SQLite at every start
build/styles/npm run style:emit/styles/ (static, Cache-Control: public, max-age=86400)404
build/terrain/ with meta.jsonnpm run data:terrain/terrain/{z}/{x}/{y}.png and /terrain/tilejson.json; meta.json present sets sources.terrain404, sources.terrain: false
fonts/npm run data:fonts/fonts/ (static, max-age=86400)404
sprites/npm run data:sprites/sprites/ (static, max-age=86400)404
cache/photos/img/, cache/photos/meta/the server, when --wikimedia is onWikimedia photo cacheCreated at start if the directory is writable
routing/npm run data:routingNot read by the server; portable copies of the Valhalla config and tile extract
raw/npm run data:downloadPipeline inputs only

Tile URL templates in /tiles/tilejson.json carry ?v=<build token>, so rebuilding the data changes the URLs rather than the bytes behind cached ones.

npm run data:all runs the required steps (download, fonts, sprites, tiles, index, routing) and the optional ones (overture, places, buildings, terrain) in order; a failed optional step is reported and the run continues.

What the server writes inside the data directory#

Two things, and both fail quietly when the mount is read-only, which it is in production:

  • build/search.cache. After building the index from SQLite the server saves a snapshot in the background. On a read-only mount os.Create fails and the server logs search cache: …; the cost is that the index is rebuilt from SQLite on every start instead of loaded from the snapshot. The snapshot is only used when the SQLite file's mtime and size still match, so a copy that does not preserve timestamps invalidates it.
  • cache/photos/. photos.New calls MkdirAll, and the fetch path calls WriteFile, both ignoring errors. On a read-only mount nothing is cached and each photo request goes back to Wikimedia. If that matters, pass --wikimedia=false.

Writable paths and the uid#

Both images run as user uzmap. The console image pins it to uid 100, gid 101 (apps/console/Dockerfile: addgroup -S -g 101 uzmap && adduser -S -u 100 -G uzmap uzmap). The server image creates the user with addgroup -S uzmap && adduser -S -G uzmap uzmap and no explicit ids (services/server/Dockerfile); on alpine:3.20 that yields 100:101, which is what the compose file, server.go and docs/api-keys.md all assume. Confirm on your build with:

bash
docker run --rm --entrypoint id "$(docker inspect uzmaps-server --format '{{.Config.Image}}')"
Host path (compose default)VariableMounted atModeRequirementWhy
value of UZMAPS_DATAUZMAPS_DATA (required)/data in serverread-onlyreadable by uid 100The server only reads map data. Mounting it read-only means nothing running can corrupt several GB of build output.
/etc/uzmap/keys.jsonUZMAPS_KEYS/etc/uzmap/keys.json in serverread-onlyreadable by uid 100: chown 100:101, chmod 640Read at start and polled every --key-reload. Unreadable is fatal.
/var/lib/uzmapUZMAPS_STATE/var/lib/uzmap in serverread-writedirectory writable by uid 100: install -d -m 750 -o 100 -g 101Holds usage.sqlite. Opened in WAL mode, so SQLite creates usage.sqlite-wal and usage.sqlite-shm beside it; a writable file inside a read-only directory is not enough.
/etc/uzmapUZMAPS_KEYS_DIR/etc/uzmap in consoleread-writewritable by uid 100The console writes keys.json; the server reads it. Both run as the same uid because the file is 0640.

The key file lives outside the data directory on purpose (Config.KeysFile comment in server.go): the data mount is read-only, and the key file has to be writable to issue and revoke.

Compose topology#

ServiceContainerImageNetworksPurpose
serveruzmaps-server${IMAGE_REPO}/server:${SERVER_IMAGE_TAG}internalThe Go server: API, tiles, search, the built web app at /, the browser SDK at /v1/. Healthcheck wget -qO- http://127.0.0.1:8080/health, every 30 s, 40 s start period.
valhallauzmaps-valhallaghcr.io/valhalla/valhalla:latestinternalRouting. Runs valhalla_service /valhalla/valhalla.json 1 on port 8002 from the external volume. Healthcheck curl -fs http://localhost:8002/status, 60 s start period. Stock image; this repo never builds it.
server-webuzmaps-webnginx:1.27-alpineproxy, internalThe side-car NPM forwards uzmaps.ndc.uz to. Config bind-mounted read-only from ./web/server/nginx.conf. Starts only once server is healthy.
consoleuzmaps-console${IMAGE_REPO}/console:${CONSOLE_IMAGE_TAG:-${SERVER_IMAGE_TAG}}internalDeveloper console (Next.js, port 3100). Its entrypoint runs prisma migrate deploy and exits non-zero if that fails, so a failed migration kills the container rather than serving against a half-migrated schema.
console-webuzmaps-console-webnginx:1.27-alpineproxy, internalSide-car for console.uzmaps.ndc.uz; config from ./web/console/nginx.conf.
postgresuzmaps-postgrespostgres:17-alpineinternalThe console's database. The map server never connects to it.

Networks: proxy is external and shared with Nginx Proxy Manager (rollout.sh creates it if missing; NPM must be attached to it separately). internal is a plain bridge. No service publishes a host port. NPM reaches uzmaps-web and uzmaps-console-web by container name over proxy, and only those two side-cars are on that network.

Volumes: postgres is a compose-managed named volume. valhalla is external, named uzmap-valhalla, and is created and filled by npm run data:routing, which runs the Valhalla build inside that volume on whatever Docker daemon the script talks to. It is a named volume rather than a bind mount because Valhalla mmaps its tiles, which is unreliable on bind mounts.

All services: restart: unless-stopped and json-file logging capped at 5 × 10 MB. All except valhalla: no-new-privileges:true.

The server's command as compose runs it (the image's entrypoint is uzmap):

serve --data=/data --addr=:8080 --static=/app/web --valhalla=http://valhalla:8002
      --keys=/etc/uzmap/keys.json --require-key=${UZMAPS_REQUIRE_KEY:-false}
      --usage=/var/lib/uzmap/usage.sqlite --sdk=/app/sdk

--require-key is passed with an explicit value so an environment variable can control it: compose cannot add or drop an argument conditionally, but it can interpolate one. /app/web and /app/sdk are baked into the image (services/server/Dockerfile copies apps/demo/dist and apps/sdk/dist there). The map data is not in the image, so a code deploy never pushes gigabytes and a data rebuild never rebuilds the image.

There is no Redis and no database for the map server. Search is an in-memory index built from a read-only SQLite file; tiles are PMTiles archives read from disk. Postgres exists only for the console, and the console publishes its keys to the server as keys.json, so a console or database outage does not stop maps rendering.

The nginx side-car#

Source: deploy/web/server/nginx.conf.

The Go server already serves everything on one origin and sets its own Cache-Control headers. The side-car exists for three reasons a Go binary is the wrong place for:

  1. Edge isolation. The app container never joins the shared proxy network. Only a stock nginx with a read-only config is exposed to it.
  2. Different caching per path class. Tiles are immutable and fingerprinted, the SPA shell must never be cached, the API is dynamic. nginx applies these rules at the edge in one place.
  3. Log volume. One map session pulls hundreds of tiles; access logging is off for map data so the log stays readable.
LocationWhat nginx does
= /healthproxied, access log off
~ ^/tiles/.*tilejson\.json$adds Cache-Control: no-store. Declared before the bulk block because nginx takes the first matching regex in file order. A cached tilejson pins clients to an old build token, which presents as "the map did not update after a rebuild".
~ ^/(tiles|terrain|fonts|sprites)/proxied with buffering, access log off; the server's own max-age passes through
/api/proxied, proxy_read_timeout 60s for slow routing calls on a cold Valhalla
/assets/adds Cache-Control: public, max-age=31536000, immutable; the hash in the filename is the version
/v1/proxied with buffering and no added Cache-Control: the server sets one hour on uzmaps.js and a year on the chunks beside it, and a second header from nginx would leave the browser to pick
/adds Cache-Control: no-store. index.html names the current asset hashes; a stale copy asks for files that no longer exist, the 404 falls through to this handler as text/html, and the browser refuses it as a module script

Other settings: gzip only for JSON, JavaScript, CSS, plain text and SVG above 1 KB (tile payloads are already compressed by the server), client_max_body_size 4m, and X-Forwarded-Proto forwarded from NPM, which terminates TLS.

The upstream is uzmaps-server:8080, resolved once when nginx loads its config. Recreating the server container gives it a new IP, so the side-car must be restarted after every server rollout or it returns 502s against the old address. rollout.sh does this; if you recreate server by hand, run docker compose restart server-web afterwards.

The console side-car (deploy/web/console/nginx.conf) is separate because its needs are the opposite: everything except /_next/static/ gets Cache-Control: no-store, private, and it forwards X-Forwarded-Host so Auth.js builds callback URLs from the real domain rather than the container name.

Environment variables#

The Go server reads no environment variables: there is no os.Getenv in services/server outside tests. Everything reaches it as a flag, and the variables below are consumed by compose interpolation or by the deploy scripts. Compose nonetheless passes .env.prod into the server container through env_file, so its contents, POSTGRES_PASSWORD included, are visible in that container's environment.

.env.prod lives in the deploy directory, is created once by hand from .env.prod.example, and is never written by CI or by deploy.sh. chmod 600 it.

VariableRead byDefaultMeaning
UZMAPS_DATAcomposenone; compose refuses with set UZMAPS_DATA in .env.prod to the host path of the built map dataHost path of the built data tree, mounted read-only at /data.
UZMAPS_KEYScompose/etc/uzmap/keys.jsonHost path of the key file the server reads.
UZMAPS_REQUIRE_KEYcompose, into --require-key=falsetrue to enforce keys on the gated paths.
UZMAPS_STATEcompose/var/lib/uzmapHost directory for usage.sqlite. Not listed in .env.prod.example; set it only to move the state directory.
UZMAPS_KEYS_DIRcompose/etc/uzmapDirectory the console mounts read-write to publish keys.json.
IMAGE_REPOcompose (image names); rollout.sh and deploy.sh read it from the shell environment, not from .env.prodnone in compose; deploy.sh derives ghcr.io/<owner>/<repo> from the git remoteRegistry path images are named under. Setting it in .env.prod is what lets hand-run docker compose commands resolve image names.
SERVER_IMAGE_TAG, CONSOLE_IMAGE_TAGcompose, from service-tags.envCONSOLE_IMAGE_TAG falls back to SERVER_IMAGE_TAGGit SHAs of the last successful deploy per service. Written by rollout.sh; do not set them in .env.prod.
APP_URLthe consolePublic URL of the map. The map server does not read it.
CONSOLE_URLcompose, into the console's AUTH_URLhttps://console.uzmaps.ndc.uzPublic URL of the console.
POSTGRES_PASSWORDcompose, into postgres and the console's DATABASE_URL; checked by rollout.sh and deploy.sh before a console rollemptyRequired for the console. Defaulted to empty rather than :? in compose so a map-only deployment can run docker compose pull server without it.
POSTGRES_USER, POSTGRES_DBcomposeuzmapsConsole database identity.
AUTH_SECRETthe console via env_file; checked by rollout.sh and deploy.shemptySession signing secret. Rotating it signs everyone out.

Variables the deploy tooling uses, from deploy/.deploy.env (read only by deploy.sh; copy deploy/.deploy.env.example) or the shell environment:

VariableUsed byMeaning
DEPLOY_DIRdeploy.sh, rollout.sh (required)The directory on the host holding docker-compose.yml and .env.prod. Must be absolute (~ does not expand inside quoted strings) and must not be the git checkout.
VPS_HOST, VPS_USERdeploy.shSet VPS_HOST to deploy over ssh; unset means "deploy this machine". VPS_USER is then required.
VPS_PORT, SSH_KEYdeploy.shssh port (default 22) and identity file.
TRANSPORTdeploy.shssh (default: docker save | ssh docker load) or registry (push to GHCR, host pulls). The --registry flag sets it.
IMAGE_TAGrollout.sh (required)The tag to roll. deploy.sh sets it to git rev-parse HEAD.
SKIP_PULLrollout.sh1 skips docker compose pull and instead checks each image is already present. deploy.sh sets it in every mode except --registry.
COMPOSE_FILE, TAGS_FILE, ENV_FILErollout.shOverride the defaults $DEPLOY_DIR/docker-compose.yml, $DEPLOY_DIR/service-tags.env, $DEPLOY_DIR/.env.prod.

Deploying by hand#

The deploy directory#

DEPLOY_DIR (the CI pipeline calls it VPS_PATH) holds:

FileOrigin
docker-compose.ymlsynced from deploy/docker-compose.yml by CI or deploy.sh
rollout.shsynced from deploy/rollout.sh
web/server/nginx.conf, web/console/nginx.confsynced from deploy/web/…; bind-mounted into the side-cars
.env.prodcreated once by hand; never synced
service-tags.envwritten by rollout.sh; the host's own record of what is deployed; never synced

Preparing a host once#

bash
# 1. The deploy directory and its config.
sudo mkdir -p /home/ndc/docker/uzmaps && sudo chown "$USER" /home/ndc/docker/uzmaps
cp .env.prod.example /home/ndc/docker/uzmaps/.env.prod
chmod 600 /home/ndc/docker/uzmaps/.env.prod
# edit .env.prod: UZMAPS_DATA and IMAGE_REPO at minimum

# 2. Key file and state directory, owned by the uid the containers run as.
sudo install -d -m 750 -o 100 -g 101 /etc/uzmap
echo '[]' | sudo tee /etc/uzmap/keys.json >/dev/null
sudo chown 100:101 /etc/uzmap/keys.json && sudo chmod 640 /etc/uzmap/keys.json
sudo install -d -m 750 -o 100 -g 101 /var/lib/uzmap

# 3. Map data: copy the built data/ tree (build/, fonts/, sprites/) to the path in UZMAPS_DATA,
#    readable by uid 100.

# 4. Routing graph: creates and fills the external volume `uzmap-valhalla` on this Docker daemon.
#    Needs data/raw/uzbekistan-latest.osm.pbf in the checkout.
npm run data:routing

Creating an empty keys.json ([] is a valid, empty key file) means the server never depends on the missing-file path, and the ownership is set before anything writes to it. .env.prod.example uses mode 750 for /etc/uzmap and docs/api-keys.md shows 755; nothing outside uid 100 needs to read the directory, so 750 is used here.

Then add the Nginx Proxy Manager proxy host: uzmaps.ndc.uz, forward host uzmaps-web, scheme http, port 80, websockets on. NPM must be attached to the proxy docker network. For the console, a second host: console.uzmaps.ndc.uz to uzmaps-console-web, http, port 80.

Running deploy.sh#

bash
cd ~/git/uzmaps && git pull
cp deploy/.deploy.env.example deploy/.deploy.env     # set DEPLOY_DIR=/home/ndc/docker/uzmaps
./deploy/deploy.sh --dry-run server                  # print the plan, change nothing
./deploy/deploy.sh server                            # the map server
./deploy/deploy.sh all                               # server, console and docs

Arguments: server, console, docs, all (the three buildable services; rollout.sh brings up their dependencies), --dry-run, --registry, -h. Anything else is an error. Duplicates are removed, so all server builds the server once.

Run on the VPS from a checkout, the Docker daemon that builds the image is the one that runs it, so no registry, credentials or image transfer are involved. With VPS_HOST set it builds locally and moves the image over ssh instead.

What it does, in order:

  1. Preflight, before building anything: docker reachable; DEPLOY_DIR set, absolute and not the checkout; $DEPLOY_DIR/docker-compose.yml and .env.prod exist on the target; for a console deploy, POSTGRES_PASSWORD and AUTH_SECRET are set in .env.prod. In ssh mode it also checks that ssh works.
  2. Builds each service from the repo root with docker build -f services/server/Dockerfile -t $IMAGE_REPO/server:$IMAGE_TAG . (and apps/console/Dockerfile for the console). IMAGE_TAG is the current commit; uncommitted changes are included and flagged in the output.
  3. Gets the image to the host: nothing in local mode, docker save | ssh docker load in ssh mode, docker push with --registry.
  4. Syncs docker-compose.yml, rollout.sh and the two nginx.conf files into DEPLOY_DIR. Never .env.prod, never service-tags.env.
  5. Runs rollout.sh <services> in DEPLOY_DIR, with SKIP_PULL=1 unless --registry was used.
  6. Waits up to 120 s for every container to be running and, where it has a healthcheck, healthy, prints a table, and exits 1 if anything is not. A container that starts and dies a few seconds later, which is what a failed migration does, is caught here rather than reported as success.

What rollout.sh does#

It is the same script CI runs, so a manual deploy leaves the host in the state CI expects.

  1. Refuses without DEPLOY_DIR, IMAGE_REPO, IMAGE_TAG and an existing .env.prod. Known services are server, console and docs.
  2. Loads service-tags.env; for a service with no recorded tag it falls back to the tag of the running container, so a lost ledger does not reset anything to latest. Services named on the command line move to IMAGE_TAG; the rest keep their last tag. A service never deployed keeps an empty tag, which compose's ${CONSOLE_IMAGE_TAG:-…} default tolerates.
  3. Writes service-tags.env before touching anything, so a failure mid-rollout still leaves a truthful record.
  4. Creates the proxy network if it does not exist.
  5. up -d valhalla. Failure is reported, not fatal: tiles and search work without routing.
  6. For a console roll: checks POSTGRES_PASSWORD and AUTH_SECRET, then up -d postgres.
  7. pull the named services, or with SKIP_PULL=1 verify each IMAGE_REPO/<svc>:<tag> is present locally.
  8. up -d --remove-orphans <svc> for each named service.
  9. up -d --no-deps then restart on each <svc>-web side-car, so nginx re-resolves the recreated upstream.
  10. docker image prune -f.

Every compose call is docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" --env-file "$TAGS_FILE", with the shell-exported tags taking precedence over both files.

Rolling back#

Any previously built or pushed tag can be rolled the same way:

bash
cd /home/ndc/docker/uzmaps
export DEPLOY_DIR=$PWD IMAGE_REPO=ghcr.io/national-development-community/uzmaps
export IMAGE_TAG=<previous commit sha>
SKIP_PULL=1 ./rollout.sh server     # image built locally by deploy.sh; drop SKIP_PULL to pull from GHCR

docker image prune -f removes only untagged images, so images from earlier local builds remain available.

Day-to-day commands#

Run from the deploy directory. Both env files are needed, or compose cannot resolve the image names and fails with invalid reference format.

bash
cd /home/ndc/docker/uzmaps
alias dc='docker compose -f docker-compose.yml --env-file .env.prod --env-file service-tags.env'
dc ps
dc logs --tail 50 server
dc restart server-web                                    # after recreating server by hand
dc exec server wget -qO- http://127.0.0.1:8080/api/status   # no host port is published

Issuing a key on a Docker host#

The binary only exists inside the image. Run it as a throwaway container, as uid 100 so the file keeps its ownership, with the key directory mounted read-write (the server's own mount is read-only):

bash
docker run --rm -v /etc/uzmap:/etc/uzmap --user 100:101 \
  "$(docker inspect uzmaps-server --format '{{.Config.Image}}')" \
  keys add --keys /etc/uzmap/keys.json --name "Acme" --origins https://acme.uz

The image's entrypoint is uzmap, so keys add … are its arguments. The running server picks the change up within --key-reload (5 s); no restart is needed.

Turning on key enforcement#

Set UZMAPS_REQUIRE_KEY=true in .env.prod, then recreate the server and restart its side-car. Re-running the rollout at the current tag does both:

bash
cd /home/ndc/docker/uzmaps
export DEPLOY_DIR=$PWD IMAGE_REPO=ghcr.io/national-development-community/uzmaps
export IMAGE_TAG=$(sed -n 's/^SERVER_IMAGE_TAG=//p' service-tags.env)
SKIP_PULL=1 ./rollout.sh server

Do this only after every client that needs a key has one, including the bundled demo app served at /, which fetches gated paths like any other client (.env.prod.example).

Running without Docker#

For development, or a host where you would rather run the binary directly (package.json scripts):

bash
npm ci
npm run build          # apps/demo/dist
npm run build:sdk      # apps/sdk/dist
npm run server:build   # bin/uzmap
./bin/uzmap serve --data data --static apps/demo/dist --sdk apps/sdk/dist

Everything under --data is then writable, so build/search.cache and cache/photos/ are created and used. Pass --valhalla if your Valhalla is not at the default http://127.0.0.1:8012.

Map data © OpenStreetMap contributors, licensed under the ODbL. © 2026 National Development Community.