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#
| 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 and caches results under <data>/cache/photos. Pass --wikimedia=false on a host without outbound network. |
--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. Empty disables key checking entirely. A missing file loads as an empty key set. |
--require-key | bool | false | Reject 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-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]
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) |
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 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.
| 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 | Wikimedia photo cache | Created at start if the directory is writable |
routing/ | npm run data:routing | Not read by the server; portable copies of the Valhalla config and tile extract | — |
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/.photos.NewcallsMkdirAll, and the fetch path callsWriteFile, 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:
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/keys.json | UZMAPS_KEYS | /etc/uzmap/keys.json in server | read-only | readable by uid 100: chown 100:101, chmod 640 | Read at start and polled every --key-reload. Unreadable is fatal. |
/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 | ${IMAGE_REPO}/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.ndc.uz to. Config bind-mounted read-only from ./web/server/nginx.conf. Starts only once server is healthy. |
console | uzmaps-console | ${IMAGE_REPO}/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.ndc.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. |
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:
- 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 CI or by deploy.sh. 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_KEYS | compose | /etc/uzmap/keys.json | Host path of the key file the server reads. |
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 | Directory the console mounts read-write to publish keys.json. |
IMAGE_REPO | compose (image names); rollout.sh and deploy.sh read it from the shell environment, not from .env.prod | none in compose; deploy.sh derives ghcr.io/<owner>/<repo> from the git remote | Registry 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_TAG | compose, from service-tags.env | CONSOLE_IMAGE_TAG falls 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.ndc.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 docker compose pull 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. |
VPS_HOST, VPS_USER | deploy.sh | Set VPS_HOST to deploy over ssh; unset means "deploy this machine". VPS_USER is then required. |
VPS_PORT, SSH_KEY | deploy.sh | ssh port (default 22) and identity file. |
TRANSPORT | deploy.sh | ssh (default: docker save | ssh docker load) or registry (push to GHCR, host pulls). The --registry flag sets it. |
IMAGE_TAG | rollout.sh (required) | The tag to roll. deploy.sh sets it to git rev-parse HEAD. |
SKIP_PULL | rollout.sh | 1 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_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 (the CI pipeline calls it VPS_PATH) holds:
| File | Origin |
|---|---|
docker-compose.yml | synced from deploy/docker-compose.yml by CI or deploy.sh |
rollout.sh | synced from deploy/rollout.sh |
web/server/nginx.conf, web/console/nginx.conf | synced from deploy/web/…; bind-mounted into the side-cars |
.env.prod | created once by hand; never synced |
service-tags.env | written by rollout.sh; the host's own record of what is deployed; never synced |
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 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:routingCreating 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#
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 docsArguments: 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:
- Preflight, before building anything:
dockerreachable;DEPLOY_DIRset, absolute and not the checkout;$DEPLOY_DIR/docker-compose.ymland.env.prodexist on the target; for a console deploy,POSTGRES_PASSWORDandAUTH_SECRETare set in.env.prod. In ssh mode it also checks that ssh works. - Builds each service from the repo root with
docker build -f services/server/Dockerfile -t $IMAGE_REPO/server:$IMAGE_TAG .(andapps/console/Dockerfilefor the console).IMAGE_TAGis the current commit; uncommitted changes are included and flagged in the output. - Gets the image to the host: nothing in local mode,
docker save | ssh docker loadin ssh mode,docker pushwith--registry. - Syncs
docker-compose.yml,rollout.shand the twonginx.conffiles intoDEPLOY_DIR. Never.env.prod, neverservice-tags.env. - Runs
rollout.sh <services>inDEPLOY_DIR, withSKIP_PULL=1unless--registrywas used. - 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#
It is the same script CI runs, so a manual deploy leaves the host in the state CI expects.
- Refuses without
DEPLOY_DIR,IMAGE_REPO,IMAGE_TAGand an existing.env.prod. Known services areserver,consoleanddocs. - 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 tolatest. Services named on the command line move 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. pullthe named services, or withSKIP_PULL=1verify eachIMAGE_REPO/<svc>:<tag>is present locally.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.
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:
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 GHCRdocker 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.
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/keys.json --name "Acme" --origins https://acme.uzThe 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:
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 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.