Skip to content

Adding a Data-node to an Existing Hub

This page is for operators who already have a running hub + data-node-ui setup on one host and want to add more regional backends without touching the existing stack.

If you are setting up from scratch, see Federated Deployment instead.

How it works

The hub is client-side: browsers fetch playground data directly from each data-node URL listed in registry.json. Every data-node must therefore be reachable over public HTTPS — a backend accessible only on localhost cannot be reached by a user's browser.

Prerequisites

  • Existing hub + at least one data-node-ui running on the host.
  • A reverse proxy (Traefik or nginx) that can front additional backends on new ports or subdomains.
  • A public domain / subdomain for the new backend (e.g. berlin.example.com).
  • OSM relation ID and Geofabrik PBF URL for the new region — see Manual Deploy § Step 1.

Step 1 — Create the backend directory

Each backend is an independent Compose project in its own directory.

mkdir ~/spieli-berlin
cd ~/spieli-berlin
cp ~/spieli/compose.yml .   # copy from your existing deploy dir
cp -r ~/spieli/db .          # copy db/init.sql — required for DB initialisation

Why copy db/init.sql?

The PostgreSQL image runs every file in /docker-entrypoint-initdb.d/ on first start. compose.yml bind-mounts ./db/init.sql into that directory. Without the file, Docker creates a directory at the mount target instead of a file, the init script silently fails, and PostgREST cannot connect.

Step 2 — Write .env

COMPOSE_PROJECT_NAME=spieli-berlin   # unique per backend — avoids Docker name collisions
DEPLOY_MODE=data-node-ui
APP_PORT=8082                         # unique port — increment for each new backend
OSM_RELATION_ID=62422
PBF_URL=https://download.geofabrik.de/europe/germany/berlin-latest.osm.pbf
POSTGRES_PASSWORD=<strong-random-password>
REIMPORT_INTERVAL_MIN_DAYS=1
REIMPORT_INTERVAL_MAX_DAYS=1

# Legal / Impressum (required under German law for public instances)
IMPRESSUM_NAME=Max Mustermann
IMPRESSUM_ADDRESS=Musterstraße 1, 12345 Berlin
IMPRESSUM_EMAIL=kontakt@example.com

Generate a strong password: openssl rand -base64 24

Legal requirement (Germany)

Public instances in Germany require an Impressum. Set at minimum IMPRESSUM_NAME, IMPRESSUM_ADDRESS, and IMPRESSUM_EMAIL. The importer writes the generated HTML to api.legal_content and get_meta() exposes impressum_url / privacy_url so the Hub can surface legal links. Omitting these leaves the backend with has_legal: false in get_meta().

To apply legal content without a full re-import (e.g. after updating the .env):

docker compose --profile data-node-ui run --rm -e API_ONLY=true importer

Special characters in POSTGRES_PASSWORD

compose.yml uses the password in a PostgreSQL URI (postgres://osm:${POSTGRES_PASSWORD}@db:5432/osm). URI-special characters (/, +, =, @, #) break the URI parser, causing PostgREST to fail with could not look up local user ID. Fix: change PGRST_DB_URI in compose.yml to the key-value format (see Step 3).

Step 3 — Patch compose.yml

Two changes are needed before the first start. Edit ~/spieli-berlin/compose.yml:

3a — Fix PGRST_DB_URI (key-value format)

Find the postgrest service and replace the URI-format connection string:

# Before (URI format — breaks with special-char passwords):
PGRST_DB_URI: postgres://osm:${POSTGRES_PASSWORD}@db:5432/osm

# After (key-value format — safe for any password):
PGRST_DB_URI: "host=db port=5432 dbname=osm user=osm password=${POSTGRES_PASSWORD}"

3b — Add POSTGRES_HOST_AUTH_METHOD to the db service

Without this, PostgreSQL only allows loopback connections and PostgREST (in a separate container) cannot connect.

  db:
    environment:
      POSTGRES_DB:                osm
      POSTGRES_USER:              osm
      POSTGRES_PASSWORD:          ${POSTGRES_PASSWORD:-change-me}
      POSTGRES_HOST_AUTH_METHOD:  scram-sha-256   # ← add this line

Step 4 — Start the stack

cd ~/spieli-berlin
docker compose --profile data-node-ui up -d

Do not add --profile auto-update

Your existing Watchtower instance (in the first backend's stack) already watches all containers on the Docker host. Adding a second Watchtower via --profile auto-update causes both instances to fight over the same containers, killing each other on startup.

Step 5 — Import OSM data

The importer started automatically in Step 4 (daemon mode). Watch progress:

docker logs -f spieli-berlin-importer-1

Berlin (~93 MB PBF) takes a few minutes. The import is complete when you see [importer] Import completed successfully. The importer then sleeps until the next scheduled run.

Verify playground count

curl -s https://berlin.example.com/api/rpc/get_meta | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print(d['playground_count'], d['relation_id'])"

playground_count must be greater than zero. If it returns 0 despite a successful import, see Wrong relation ID below.

Forcing a re-import (corrupt cache)

If get_meta returns playground_count: 0 after the import, the bbox/tags cache in pbf_cache may be corrupt (this can happen when the importer ran against a broken DB and cached empty osmium output). Fix:

# Replace berlin-latest with your region's PBF basename
docker run --rm -v spieli-berlin_pbf_cache:/cache alpine \
  sh -c "rm -f /cache/*_${OSM_RELATION_ID}.pbf /cache/*_${OSM_RELATION_ID}_tags.pbf"

docker exec -u postgres spieli-berlin-db-1 psql -U osm osm \
  -c "DELETE FROM api.import_status WHERE id = 1;"

docker compose --profile data-node-ui up -d --force-recreate importer
docker logs -f spieli-berlin-importer-1

Wrong relation ID (playground_count: 0)

If get_meta returns playground_count: 0 after a successful import (osm2pgsql ran, no errors), the most likely cause is a wrong OSM_RELATION_ID. The playground_stats view filters playgrounds by the state boundary polygon — if the relation ID doesn't match any polygon in the database, no playgrounds are counted.

Diagnose: check which admin boundary was actually imported:

docker exec spieli-berlin-db-1 psql -U osm osm \
  -c "SELECT osm_id, name FROM planet_osm_polygon WHERE boundary='administrative' AND admin_level='4';"

If the returned osm_id is -62423 but your .env has OSM_RELATION_ID=62422, fix the .env and re-apply the API schema without a full re-import:

sed -i 's/OSM_RELATION_ID=62422/OSM_RELATION_ID=62423/' .env
docker compose --profile data-node-ui run --rm -e API_ONLY=true importer

The API_ONLY run rebuilds playground_stats with the corrected relation ID in under a minute.

OSM relation IDs for German Bundesländer

Several commonly-cited relation IDs are off. Confirmed correct values: Mecklenburg-Vorpommern → 28322, Sachsen-Anhalt → 62607, Schleswig-Holstein → 51529. After any new import, run the boundary check above to confirm.

Step 6 — Expose via reverse proxy

The browser must reach the backend at a stable public HTTPS URL. If you use Traefik with the file provider (as set up by install-traefik.sh), drop a new file in the dynamic/ directory — Traefik picks it up immediately without a restart.

~/spieli-traefik/dynamic/berlin.yml:

http:
  routers:
    berlin:
      rule: "Host(`berlin.example.com`)"
      entryPoints:
        - websecure
      tls:
        certResolver: le
      service: berlin
      middlewares:
        - security-headers

  services:
    berlin:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:8082"

security-headers is defined in your existing app.yml and is shared across all files in the same dynamic/ directory.

Path-prefix approach (same domain)

Use this when you cannot add a new DNS subdomain.

~/spieli-traefik/dynamic/berlin.yml:

http:
  routers:
    berlin-api:
      rule: "Host(`your-domain.example.com`) && PathPrefix(`/berlin/api/`)"
      entryPoints:
        - websecure
      tls:
        certResolver: le
      service: berlin
      middlewares:
        - strip-berlin
        - security-headers

  middlewares:
    strip-berlin:
      stripPrefix:
        prefixes:
          - "/berlin"

  services:
    berlin:
      loadBalancer:
        servers:
          - url: "http://host.docker.internal:8082"

With the path-prefix approach the registry URL must include the prefix: "url": "https://your-domain.example.com/berlin/api".

Verify

curl -i -H "Origin: https://your-hub-domain.example.com" \
  https://berlin.example.com/api/rpc/get_meta
# expect: 200 OK, Access-Control-Allow-Origin: *

Step 7 — Update registry.json and restart hub

Edit ~/spieli/registry.json (or wherever your hub's registry.json lives):

{
  "instances": [
    { "slug": "hessen", "url": "https://your-domain.example.com/api", "name": "Hessen" },
    { "slug": "berlin", "url": "https://berlin.example.com/api",      "name": "Berlin" }
  ]
}

The file is bind-mounted — editing it takes effect on the next hub poll. Restart the app container to pick it up immediately:

cd ~/spieli
docker compose --profile ui restart app

Verify end-to-end

Open the hub in a browser. The instance pill should show the new region. If a backend appears red ("unreachable"), open DevTools → Network and check the get_meta request — the URL in the request must match the backend's actual public path.

Port layout reference

Stack APP_PORT COMPOSE_PROJECT_NAME
Hub 8080 spieli (or spieli-hub)
First data-node (e.g. Hessen) 8080 spieli
Second data-node (e.g. Berlin) 8082 spieli-berlin
Third data-node 8083 spieli-<region>

Keep ports consecutive and project names unique.

See also