Architecture

How Meratyn is deployed and how the codebase is organized. Two views, same system.

Delivery

Meratyn ships three ways from one Bun workspaces monorepo. The viewer is a React app served as static files by nginx (Docker) or installed as a Tauri v2 desktop app for Windows, macOS and Linux. The admin console and the share/admin API deploy as containers, and a Jupyter anywidget embeds the viewer in notebooks. Distribution is closed-source: Docker images plus signed desktop installers, no app stores and no update servers.

Runtime topology

Meratyn runtime topology Deployment topology of Meratyn: browser, desktop and Jupyter clients reach the nginx-hosted viewer and its sidecar, the admin console reaches a control plane of API, object store and plugin registry, an AI relay fronts model providers, and a GeoNode stack serves hosted layers. CLIENTS VIEWER · NGINX CONTROL PLANE RELAY GEONODE STACK HTTPS:443 LOCAL HTTPS:443 SIGNED URLS HOSTED LAYERS /SIDECAR Browser · PWA any modern engine Desktop Tauri v2 Jupyter anywidget Admin console admin UI x1 Static React app nginx · dist/ x1 FastAPI sidecar /sidecar · Whitebox conversion · raster · SQL Local sidecar 127.0.0.1:8765 x1 Admin API Bun · SQLite share.db Clerk auth RustFS S3 object store Plugin registry CDN · registry.json AI relay edge proxy · rate limits Redis relay queue x1 GeoNode Django bridge API PostGIS layers DB GeoServer WMS · WFS · tiles Celery + Redis async jobs LEGEND LIGHT BLUE · CROSSES BOUNDARY SOLID · SERVICE CALL FOCAL · CONTROL-PLANE HUB

Runtime topology. Light-blue links cross a trust or network boundary; the admin API is the control-plane hub. Full editorial overview

Meratyn container fleet Container-level deployment of the Meratyn self-hosted profile: the mappview container running nginx and the uvicorn sidecar, the control-plane containers for admin console, object store and AI relay, a remote AI worker submitting jobs to the relay, the full GeoNode container stack, and the observability set, all on one Docker host. Browser viewers AI worker remote · MAPPVIEW_EDGE_URL DOCKER HOST · SELF-HOSTED PROFILE VIEWER & CONTENT CONTROL PLANE GEONODE STACK OBSERVABILITY AI JOBS HTTP:80 HTTP:8765 · LOOPBACK /AI S3 API HTTP OWS 5432 CACHE BROKER SCRAPE SCRAPE SCRAPE PROMQL REGISTRY CONTAINER mappview python:3.12-slim-bookworm nginx :80 · PID 1 uvicorn sidecar · 127.0.0.1:8765 static web app · /data docs docs site plugins plugin registry CDN admin admin console · 127.0.0.1:8787 rustfs S3 object store · rustfs-data edge AI relay · 127.0.0.1:8788 geonode-proxy geonode/nginx:1.31.2 geonode-django Django + bridge API geonode-celery async jobs geonode-memcached memcached:1.6-alpine geonode-redis redis:7-alpine geonode-db geonode/postgis:15-3.5 geoserver geonode/geoserver:2.28.4 status gatus healthchecks grafana dashboards prometheus metrics scrapes node-exporter host metrics cadvisor container metrics LEGEND RUNTIME CALL INGRESS / CROSS-STACK OPTIONAL / EXTERNAL FOCAL · VIEWER STACK

Container fleet of the self-hosted profile: the viewer container runs nginx and the sidecar as one image; GeoNode deploys as its own seven-container stack.

Trust boundaries

  • The desktop app talks to its local sidecar over 127.0.0.1 only; heavier Python processing never leaves the machine unless a remote processing service is configured.
  • The viewer container reaches the sidecar same-origin at /sidecar — no CORS surface. Set MAPPVIEW_DISABLE_SIDECAR=1 to serve static files only.
  • The admin API fronts object storage with signed, time-limited layer URLs (LAYER_ACCESS_TTL_SECONDS) and per-tenant storage quotas.
  • The edge AI proxy authenticates viewers with a token, allowlists models, and rate-limits per client; provider keys never reach the browser.
  • Air-gapped viewer images set MAPPVIEW_NO_EXTERNAL_CDN=1 at build time. The Vite output guard rejects Meratyn-controlled executable or model CDN URLs, while VITE_PYODIDE_INDEX_URL supplies the required operator-hosted Python mirror; project-authored data and service endpoints remain deployment policy.

Deployment

The same React build ships three ways: the Tauri desktop shell with an on-demand sidecar, an nginx container that reverse-proxies its bundled sidecar same-origin, and the Jupyter wheel with the app embedded in the kernel. The desktop keeps heavy processing local, the container proxies it, and the embed leaves it out.

Deployment map Desktop app, containerized nginx, local FastAPI sidecar, browser PWA, and remote tile services and how they connect. USER MACHINE CONTAINER EXTERNAL NETWORK HTTPS:443 /SIDECAR HTTP HTTP:8765 NATIVE FETCH DESKTOP Tauri desktop web app bundle Rust core · guarded_http_client MANAGED FastAPI sidecar uv run --frozen · 127.0.0.1:8765 CONTAINER nginx static dist/ reverse proxy /sidecar EXTERNAL Remote tiles & OGC services BROWSER Browser / PWA CROSSES NETWORK BOUNDARY LOCAL LOOP NATIVE OUTBOUND OPTIONAL COMPONENT

Open the full editorial deployment map for ports, chips, and per-surface details.

Container image

The root Dockerfile packages the browser version of the app. It uses a Node build stage to run the workspace build for mappview-desktop, then copies apps/mappview/dist into an nginx runtime image. The nginx config serves static assets and falls back to index.html for browser-entry URLs.

The Container build GitHub Actions workflow verifies that the image builds on every push to main. It does not log in to a registry or publish an image. Build and tag the image locally for development or publish it through infrastructure owned by Meratyn.

The image also bundles the optional Python sidecar (uvicorn) and reverse-proxies it at /sidecar, so the browser reaches it same-origin with no CORS; set MAPPVIEW_DISABLE_SIDECAR=1 to run nginx alone. MAPPVIEW_CAPABILITIES is published to both the runtime web configuration and sidecar; it gates authority rather than simply hiding UI. See deployment capabilities for the exact contract. The container does not run the Tauri desktop shell, so workflows that depend on desktop filesystem access still require the installed desktop app. See Run with Docker for what the bundled sidecar does and does not back.

Security

  • Tauri CSP allowlists tile and style hosts (OpenFreeMap, CARTO).
  • File access uses dialog-selected paths only.

Native HTTP trust store and mutual TLS

The desktop app issues some remote fetches (tile/URL resolution, OGC GetCapabilities, and similar) from the native Rust process rather than the WebView. That path (guarded_http_client in src-tauri/src/lib.rs) trusts the OS/system certificate store in addition to the bundled Mozilla roots, so a server signed by an enterprise CA installed on the machine is accepted without extra configuration.

For endpoints behind mutual TLS (mTLS), the WebView fetch path shows an interactive OS certificate prompt; the native Rust client cannot, so point it at a client certificate with environment variables:

VariablePurpose
MAPPVIEW_HTTP_CA_CERTPath to a PEM bundle of extra CA certificate(s) to trust, on top of the OS store (for a private CA not installed system-wide).
MAPPVIEW_HTTP_CLIENT_CERTPath to the client certificate to present. A .pem file (certificate chain plus an unencrypted PKCS#8 private key) or a PKCS#12 bundle (.p12/.pfx).
MAPPVIEW_HTTP_CLIENT_CERT_PASSWORDPassphrase for a PKCS#12 client certificate. Its presence also forces the PKCS#12 code path.

A .p12/.pfx extension (or a supplied passphrase) selects PKCS#12; any other path is read as PEM. PEM identities use the default rustls backend; PKCS#12 identities use the platform native-tls backend (SChannel on Windows, Secure Transport on macOS, OpenSSL on Linux), which also reads the OS trust store. Convert a PKCS#12 export to PEM with openssl pkcs12 -in cert.p12 -out cert.pem -nodes if you prefer the rustls path. A set-but-empty value for any of these variables is treated as unset.

The configured client certificate is held on the shared native HTTP client and is therefore presented to any HTTPS server that requests one during a native fetch (tile, style, and OGC hosts a project points at), not only the endpoint the certificate was issued for. TLS sends a client certificate only when the server asks for one, so this is not an unconditional disclosure, but configure a client certificate only when the hosts the app talks to are trusted.

Performance: map rendering on Linux (WebKitGTK)

The desktop app uses the system WebView. On Linux that is WebKitGTK, whose WebGL/JavaScript pipeline is materially slower than the Chromium engine the browser build runs in. The most visible effect is map panning at low zoom:

  • A blank map (no tile layer) pans at a steady 60 FPS at any zoom.
  • With any tile layer (vector or raster XYZ), FPS collapses to single digits while tiles are loading, then snaps back to 60 once loading stops, at the same zoom. Low zoom only makes it constant because panning across the whole world loads tiles continuously and the cache never settles.

The cost is WebKitGTK processing each newly-loaded tile on the main thread: the GPU upload of its texture (raster) or vertex buffers (vector) via synchronous WebGL calls, plus the subsequent fade-in repaint frames. Vector tile parsing and bucket building run in MapLibre’s Web Workers, so they are not the bottleneck here. Each tile-integration render cycle measured ~125 ms wall-clock in WebKitGTK versus a few ms in Chromium. The gap is in WebKitGTK’s WebGL implementation and compositor pipeline (driver command-stream flush, TextureMapper GL surface composition), not solely its JavaScriptCore JS engine. This is a WebView-engine limitation, not a bug in Meratyn, and it does not affect the browser build or (untested) the macOS/Windows WebViews.

Ruled out during diagnosis (so future investigation does not repeat them): software rendering (the GPU is used, Intel i915 confirmed), GPU saturation (the render engine stays ~20% idle), the Tauri IPC file read (~126 ms for a 22 MB GeoJSON), JSON.parse (~36 ms), KWin compositor latency, renderWorldCopies, and the globe vs. mercator projection. Preserving the WebGL drawing buffer was not the cause of those tile-integration spikes, but it still imposed a global GPU memory/throughput cost. It is now disabled; print and video capture perform a synchronous redraw immediately before reading the canvas.

To reproduce the measurement: temporarily log MapLibre render events, dataloading (tile) events, and a requestAnimationFrame counter once per second; FPS tracks tile-load count inversely. The quickest check is this frame-rate meter pasted into the WebView devtools console, then pan while watching the logged FPS:

let f = 0,
  t = performance.now();
(function loop(n) {
  f++;
  if (n - t >= 1000) {
    console.log("FPS", f);
    f = 0;
    t = n;
  }
  requestAnimationFrame(loop);
})(t);

Mitigations (reduce how many tiles load during a pan, since per-tile cost is fixed by the engine) are not yet implemented: a larger maxTileCacheSize, 512px raster tiles instead of 256px, and fadeDuration: 0, ideally gated to WebKitGTK so the Chromium-based builds keep full fidelity.