UzMaps docs

Self-hosting and operations

UzMaps 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. Pass --wikimedia=false on a host without outbound network.
--photo-cachestring<data>/cache/photosWhere photos and their metadata are cached. Should be writable: the production compose file sets /var/lib/uzmap/photos because the data mount is read-only. An unwritable directory is not fatal — photos are then cached in memory only, and the server logs that once.
--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. With --site-keys also empty, key checking is disabled entirely. A missing file loads as an empty key set.
--site-keysstringemptyA second key file, owned by the operator, merged with --keys; its entry wins when a key is in both. For keys issued on the host when --keys is the console's projection, which the console rewrites whole. Missing loads as empty.
--require-keyboolfalseReject requests to gated paths without a valid key. Ignored unless --keys or --site-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] [--key EXISTING]
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: register this existing key as it is instead of generating one
  • 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 both key files every --key-reload and re-reads them when either one's 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 on and --photo-cache is not setWikimedia photo cacheCreated at start if writable; otherwise photos are cached in memory
routing/npm run data:routingNot read by the server; portable copies of the Valhalla config and tile extract, which data releases carry to the VPS for deploy.sh data to install into the uzmap-valhalla volume—
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/, unless --photo-cache points elsewhere. If the directory cannot be created the server logs photos: cache directory … is not usable once and caches in memory instead, so each place is still looked up at most once per process rather than on every view. The production compose file avoids the question by passing --photo-cache=/var/lib/uzmap/photos, the writable state directory.

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/uzmapUZMAPS_KEYS_DIR/etc/uzmap in serverread-onlydirectory and files readable by uid 100: chown 100:101, chmod 640 on the filesHolds keys.json (the console's) and site-keys.json (yours). Read at start and polled every --key-reload; an unreadable file is fatal. The directory is mounted, not the files: the console replaces keys.json by rename, which a single-file bind mount never sees.
/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-serveruzmaps/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.uz to. Config bind-mounted read-only from ./web/server/nginx.conf. Starts only once server is healthy.
consoleuzmaps-consoleuzmaps/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.uz; config from ./web/console/nginx.conf.
postgresuzmaps-postgrespostgres:17-alpineinternalThe console's database. The map server never connects to it.
docsuzmaps-docsuzmaps/docs:${DOCS_IMAGE_TAG:-${SERVER_IMAGE_TAG}}proxyThe documentation site: static HTML in nginx, no side-car.
mapsuzmaps-mapsuzmaps/maps:${MAPS_IMAGE_TAG:-${SERVER_IMAGE_TAG}}proxyThe Flutter maps app built for the web, in nginx, no side-car.

The four uzmaps/… images are built on the host by deploy.sh and marked pull_policy: never. Nothing is pushed to or pulled from a registry, and a missing image is an error rather than an attempt to fetch uzmaps/server from Docker Hub, where anyone can publish under that name.

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 and the self-contained docs and maps sites 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. On a VPS it is normally filled by ./deploy/deploy.sh data instead, from the routing graph in the newest data release, so nothing is built there. 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 --site-keys=/etc/uzmap/site-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 any script. 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_DATA_TOKENdeploy.sh datanone; deploy.sh data refuses and explains how to create oneFine-grained GitHub token for this repository only, with Contents: Read-only. Lets the VPS download the private data releases. Never passed to a container.
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/uzmapHost directory holding keys.json and site-keys.json: read-write in the console, which publishes keys.json, and read-only in the server.
SERVER_IMAGE_TAG, CONSOLE_IMAGE_TAG, DOCS_IMAGE_TAG, MAPS_IMAGE_TAGcompose, from service-tags.envthe last three fall 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 console—Public URL of the map. The map server does not read it.
CONSOLE_URLcompose, into the console's AUTH_URLhttps://console.uzmaps.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 compose commands for the 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.
MAPS_API_KEYdeploy.sh (required for maps)The maps app's API key, compiled into the app as a build argument.
MAPS_SERVER_URLdeploy.shThe server the maps app talks to, compiled in. Default https://uzmaps.uz.
IMAGE_TAGrollout.sh (required)The tag to roll. deploy.sh sets it to git rev-parse HEAD.
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 holds:

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

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 UZMAPS_DATA_TOKEN 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 /etc/uzmap/site-keys.json >/dev/null
sudo chown 100:101 /etc/uzmap/*.json && sudo chmod 640 /etc/uzmap/*.json
sudo install -d -m 750 -o 100 -g 101 /var/lib/uzmap

# 3. Map data and routing graph: pulled from the newest data release (published from a workstation with
#    `npm run data:publish`). Needs UZMAPS_DATA_TOKEN in .env.prod — a fine-grained token for this repository
#    with Contents: Read-only. Fills UZMAPS_DATA and the external volume `uzmap-valhalla`.
./deploy/deploy.sh data

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.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.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, docs and maps

Arguments: server, console, docs, maps, all (the four buildable services; rollout.sh brings up their dependencies), data (pull map data; see deploy/data.sh), --dry-run, -h. Anything else is an error. Duplicates are removed, so all server builds the server once.

It runs on the VPS from a checkout, and nowhere else. The Docker daemon that builds each image is the one that runs it, so no registry, registry login or image transfer is involved; images are named uzmaps/<service>:<commit>.

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; for a console deploy, POSTGRES_PASSWORD and AUTH_SECRET are set in .env.prod; for a maps deploy, MAPS_API_KEY is set.
  2. Builds each service from the repo root with docker build -f services/server/Dockerfile -t uzmaps/server:$IMAGE_TAG ., and likewise for the console, docs and maps Dockerfiles (maps with --target web and its API key and server URL as build arguments). IMAGE_TAG is the current commit; uncommitted changes are included and flagged in the output.
  3. Copies docker-compose.yml, rollout.sh and the two nginx.conf files into DEPLOY_DIR. Never .env.prod, never service-tags.env.
  4. Runs IMAGE_TAG=<commit> rollout.sh <services> in DEPLOY_DIR.
  5. 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#

Both deploy.sh and a by-hand rollback go through it, so the host always ends up in the same shape.

  1. Refuses without DEPLOY_DIR, IMAGE_TAG and an existing .env.prod. Known services are server, console, docs and maps.
  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. A running container whose image still carries a registry name from before images were local gets its uzmaps/<svc>:<tag> name added, so compose can still recreate it. Each service named on the command line must already have uzmaps/<svc>:$IMAGE_TAG on the host — nothing is pulled, so a missing image stops the rollout here, before anything changes — and moves 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. up -d --remove-orphans <svc> for each named service.
  8. up -d --no-deps then restart on each <svc>-web side-car, so nginx re-resolves the recreated upstream.
  9. docker image prune -f, which removes only untagged layers.

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 commit whose image is still on the host can be rolled the same way (docker images uzmaps/server lists them):

bash
cd /home/ndc/docker/uzmaps
DEPLOY_DIR=$PWD IMAGE_TAG=<previous commit sha> ./rollout.sh server

docker image prune -f removes only untagged images, so images from earlier builds remain available until you delete them.

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/site-keys.json --name "Acme" --origins https://acme.uz

Add to site-keys.json, never to keys.json: the console rewrites keys.json whole on every key or project change, so a key added there by hand is gone at its next publish. 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.

The web app at / has its key compiled in (VITE_UZMAP_KEY, default in apps/demo/src/App.tsx). If the map on the site answers 401 unknown API key, that key is missing from both files; register it as it is with --key:

bash
docker run --rm -v /etc/uzmap:/etc/uzmap --user 100:101 \
  "$(docker inspect uzmaps-server --format '{{.Config.Image}}')" \
  keys add --keys /etc/uzmap/site-keys.json --key <the app's key> --name "uzmaps.uz" \
  --origins "https://uzmaps.uz,https://*.uzmaps.uz"

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
DEPLOY_DIR=$PWD IMAGE_TAG=$(sed -n 's/^SERVER_IMAGE_TAG=//p' service-tags.env) ./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.