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#
| Flag | Type | Default | Meaning |
|---|---|---|---|
--data | string | data | Data directory. build/, fonts/, sprites/ and cache/ are resolved beneath it (layout below). |
--addr | string | :8080 | Listen address. |
--valhalla | string | http://127.0.0.1:8012 | Base 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. |
--wikimedia | bool | true | Wikimedia Commons photo enrichment. Makes outbound HTTP calls. Pass --wikimedia=false on a host without outbound network. |
--photo-cache | string | <data>/cache/photos | Where 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. |
--static | string | empty | Directory 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. |
--keys | string | empty | JSON API-key file. With --site-keys also empty, key checking is disabled entirely. A missing file loads as an empty key set. |
--site-keys | string | empty | A 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-key | bool | false | Reject 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-rate | int | 600 | Default requests per minute for keys that do not set their own rate_limit. |
--key-reload | duration | 5s | How often the key file is polled for changes. |
--usage | string | empty | SQLite usage-metering database. Empty disables metering. |
--usage-flush | duration | 30s | How often in-memory usage counters are written to the database. |
--sdk | string | empty | Directory 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,
/healthincluded, since the process never starts. --require-keywith zero keys issued does start, but logsWARN: no API keys issued — every request will be rejected.- A usage database that cannot be opened is not fatal: metering is disabled with a
WARNand 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.sqliteorbuild/uzbekistan.pmtilesis a warning, not an error; the corresponding feature is disabled. - On
SIGTERMorSIGINTthe 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 normaldocker composerestart loses no counters.
Which paths a key gates:
| Paths | Key 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 KEYKeys 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:
| Flag | Type | Default | Used by |
|---|---|---|---|
--keys | string | keys.json | all: the key file |
--name | string | empty | add (required, so a key can be traced to a project) |
--origins | string | empty | add: comma-separated allowed origins, e.g. https://acme.uz,https://*.acme.uz |
--note | string | empty | add: free text |
--rate | int | 0 | add: requests per minute; 0 means the server's --key-rate |
--quota | int64 | 0 | add: billable requests per calendar month (UTC); 0 means unlimited |
--key | string | empty | revoke (required); add: register this existing key as it is instead of generating one |
addprints the new key, 32 hex characters, on stdout and nothing else there, soKEY=$(uzmap keys add ...)works. If--originsis empty it also prints a warning on stderr: a browser key with no origin allowlist is usable from anywhere.listprints one tab-separated line per key, oldest first: key,activeordisabled, name, origins or(unrestricted), quota asunlimitedorN/mo, and the creation date asYYYY-MM-DD.revokeprintsrevoked, or exits non-zero withno such key: ….
No subcommand sets disabled; it is a field in the file.
File format: a JSON array.
[
{
"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.
| Subcommand | Flags | Notes |
|---|---|---|
index | --pbf (required), --out (default search.sqlite), --geonames, --geonames-alt, --overture, --overture-out, --overture-min-confidence (float, 0.5), --datasets, --datasets-out | Builds 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 --data | Produced by | Served or used as | If absent |
|---|---|---|---|
build/uzbekistan.pmtiles | npm run data:tiles | Basemap: /tiles/{z}/{x}/{y}, /tiles/tilejson.json, /tiles/uzbekistan.pmtiles | WARN, no tiles |
build/places-overture.pmtiles | npm run data:places | Supplementary tile source places-overture; sources.places in /api/status | omitted |
build/buildings-ml.pmtiles | npm run data:buildings | Supplementary source buildings-ml; sources.buildings_ml | omitted |
build/furniture.pmtiles | node data/pipeline/furniture.mjs | Supplementary source furniture; sources.furniture | omitted |
build/*.pmtiles (any other) | — | Every other .pmtiles in build/ becomes a tile source named after its file stem | — |
build/search.sqlite | npm run data:index | Search and geocoding. Opened read-only (mode=ro, internal/store) | WARN, search and geocoding disabled |
build/search.cache | the server itself | Gzipped snapshot of the in-memory index, keyed on the SQLite file's mtime and size | Index 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.json | npm run data:terrain | /terrain/{z}/{x}/{y}.png and /terrain/tilejson.json; meta.json present sets sources.terrain | 404, 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 set | Wikimedia photo cache | Created at start if writable; otherwise photos are cached in memory |
routing/ | npm run data:routing | Not 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:download | Pipeline 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 mountos.Createfails and the server logssearch 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-cachepoints elsewhere. If the directory cannot be created the server logsphotos: cache directory … is not usableonce 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:
docker run --rm --entrypoint id "$(docker inspect uzmaps-server --format '{{.Config.Image}}')"| Host path (compose default) | Variable | Mounted at | Mode | Requirement | Why |
|---|---|---|---|---|---|
value of UZMAPS_DATA | UZMAPS_DATA (required) | /data in server | read-only | readable by uid 100 | The server only reads map data. Mounting it read-only means nothing running can corrupt several GB of build output. |
/etc/uzmap | UZMAPS_KEYS_DIR | /etc/uzmap in server | read-only | directory and files readable by uid 100: chown 100:101, chmod 640 on the files | Holds 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/uzmap | UZMAPS_STATE | /var/lib/uzmap in server | read-write | directory writable by uid 100: install -d -m 750 -o 100 -g 101 | Holds 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/uzmap | UZMAPS_KEYS_DIR | /etc/uzmap in console | read-write | writable by uid 100 | The 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#
| Service | Container | Image | Networks | Purpose |
|---|---|---|---|---|
server | uzmaps-server | uzmaps/server:${SERVER_IMAGE_TAG} | internal | The 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. |
valhalla | uzmaps-valhalla | ghcr.io/valhalla/valhalla:latest | internal | Routing. 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-web | uzmaps-web | nginx:1.27-alpine | proxy, internal | The side-car NPM forwards uzmaps.uz to. Config bind-mounted read-only from ./web/server/nginx.conf. Starts only once server is healthy. |
console | uzmaps-console | uzmaps/console:${CONSOLE_IMAGE_TAG:-${SERVER_IMAGE_TAG}} | internal | Developer 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-web | uzmaps-console-web | nginx:1.27-alpine | proxy, internal | Side-car for console.uzmaps.uz; config from ./web/console/nginx.conf. |
postgres | uzmaps-postgres | postgres:17-alpine | internal | The console's database. The map server never connects to it. |
docs | uzmaps-docs | uzmaps/docs:${DOCS_IMAGE_TAG:-${SERVER_IMAGE_TAG}} | proxy | The documentation site: static HTML in nginx, no side-car. |
maps | uzmaps-maps | uzmaps/maps:${MAPS_IMAGE_TAG:-${SERVER_IMAGE_TAG}} | proxy | The 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:
- Edge isolation. The app container never joins the shared
proxynetwork. Only a stock nginx with a read-only config is exposed to it. - 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.
- Log volume. One map session pulls hundreds of tiles; access logging is off for map data so the log stays readable.
| Location | What nginx does |
|---|---|
= /health | proxied, 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.
| Variable | Read by | Default | Meaning |
|---|---|---|---|
UZMAPS_DATA | compose | none; compose refuses with set UZMAPS_DATA in .env.prod to the host path of the built map data | Host path of the built data tree, mounted read-only at /data. |
UZMAPS_DATA_TOKEN | deploy.sh data | none; deploy.sh data refuses and explains how to create one | Fine-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_KEY | compose, into --require-key= | false | true to enforce keys on the gated paths. |
UZMAPS_STATE | compose | /var/lib/uzmap | Host directory for usage.sqlite. Not listed in .env.prod.example; set it only to move the state directory. |
UZMAPS_KEYS_DIR | compose | /etc/uzmap | Host 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_TAG | compose, from service-tags.env | the last three fall back to SERVER_IMAGE_TAG | Git SHAs of the last successful deploy per service. Written by rollout.sh; do not set them in .env.prod. |
APP_URL | the console | — | Public URL of the map. The map server does not read it. |
CONSOLE_URL | compose, into the console's AUTH_URL | https://console.uzmaps.uz | Public URL of the console. |
POSTGRES_PASSWORD | compose, into postgres and the console's DATABASE_URL; checked by rollout.sh and deploy.sh before a console roll | empty | Required 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_DB | compose | uzmaps | Console database identity. |
AUTH_SECRET | the console via env_file; checked by rollout.sh and deploy.sh | empty | Session 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:
| Variable | Used by | Meaning |
|---|---|---|
DEPLOY_DIR | deploy.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_KEY | deploy.sh (required for maps) | The maps app's API key, compiled into the app as a build argument. |
MAPS_SERVER_URL | deploy.sh | The server the maps app talks to, compiled in. Default https://uzmaps.uz. |
IMAGE_TAG | rollout.sh (required) | The tag to roll. deploy.sh sets it to git rev-parse HEAD. |
COMPOSE_FILE, TAGS_FILE, ENV_FILE | rollout.sh | Override 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:
| File | Origin |
|---|---|
docker-compose.yml | copied from deploy/docker-compose.yml by deploy.sh |
rollout.sh | copied from deploy/rollout.sh |
web/server/nginx.conf, web/console/nginx.conf | copied from deploy/web/…; bind-mounted into the side-cars |
.env.prod | created once by hand; never copied |
service-tags.env | written by rollout.sh; the host's own record of what is deployed; never copied |
Preparing a host once#
# 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 dataCreating 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#
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 mapsArguments: 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:
- Preflight, before building anything:
dockerreachable;DEPLOY_DIRset, absolute and not the checkout;$DEPLOY_DIR/docker-compose.ymland.env.prodexist; for a console deploy,POSTGRES_PASSWORDandAUTH_SECRETare set in.env.prod; for a maps deploy,MAPS_API_KEYis set. - 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 weband its API key and server URL as build arguments).IMAGE_TAGis the current commit; uncommitted changes are included and flagged in the output. - Copies
docker-compose.yml,rollout.shand the twonginx.conffiles intoDEPLOY_DIR. Never.env.prod, neverservice-tags.env. - Runs
IMAGE_TAG=<commit> rollout.sh <services>inDEPLOY_DIR. - Waits up to 120 s for every container to be
runningand, 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.
- Refuses without
DEPLOY_DIR,IMAGE_TAGand an existing.env.prod. Known services areserver,console,docsandmaps. - 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 itsuzmaps/<svc>:<tag>name added, so compose can still recreate it. Each service named on the command line must already haveuzmaps/<svc>:$IMAGE_TAGon the host — nothing is pulled, so a missing image stops the rollout here, before anything changes — and moves toIMAGE_TAG; the rest keep their last tag. A service never deployed keeps an empty tag, which compose's${CONSOLE_IMAGE_TAG:-…}default tolerates. - Writes
service-tags.envbefore touching anything, so a failure mid-rollout still leaves a truthful record. - Creates the
proxynetwork if it does not exist. up -d valhalla. Failure is reported, not fatal: tiles and search work without routing.- For a console roll: checks
POSTGRES_PASSWORDandAUTH_SECRET, thenup -d postgres. up -d --remove-orphans <svc>for each named service.up -d --no-depsthenrestarton each<svc>-webside-car, so nginx re-resolves the recreated upstream.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):
cd /home/ndc/docker/uzmaps
DEPLOY_DIR=$PWD IMAGE_TAG=<previous commit sha> ./rollout.sh serverdocker 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.
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 publishedIssuing 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):
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.uzAdd 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:
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:
cd /home/ndc/docker/uzmaps
DEPLOY_DIR=$PWD IMAGE_TAG=$(sed -n 's/^SERVER_IMAGE_TAG=//p' service-tags.env) ./rollout.sh serverDo 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):
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/distEverything 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.