System Architecture

How GroundWave's containers, database, and real-time layers fit together.

8 min read

Container Topology

GroundWave runs as four Docker containers orchestrated by Docker Compose. Each container has a single responsibility. Only the Nginx proxy exposes ports to the host network; all other containers communicate over a private internal Docker network.

Container Technology Port Visibility
groundwave-web Nginx 80 / 443 Exposed to host & clients
groundwave-app Node.js / Express / Socket.IO 3000 Internal only
groundwave-tiles TileServer GL 8080 Internal only
groundwave-db PostgreSQL + PostGIS 5432 Internal only

Request Data Flow

All client traffic — HTTP, HTTPS, WebSocket — enters through groundwave-web. Nginx handles TLS termination and routes requests based on URL prefix:

  • Requests to /api/ and /socket.io/ are proxied upstream to groundwave-app on port 3000. The Express application handles REST endpoints and Socket.IO manages persistent WebSocket connections.
  • Requests to /tiles/ are proxied to groundwave-tiles on port 8080. TileServer GL reads pre-downloaded .pmtiles or .mbtiles archives from a shared volume and serves vector or raster tile responses.
  • All other requests are served as static files from the compiled React SPA, which Nginx serves directly from the container filesystem.

The application container is the only process that communicates with groundwave-db. The database is never reachable from the host or from groundwave-tiles, limiting the attack surface.

An optional fifth container — groundwave-bridge — is available for CoT / TAK interoperability. It is activated via Docker Compose profiles (--profile bridge) and listens on TCP port 4242 for ATAK/iTAK connections.

Technology Stack

Each technology in the stack was chosen for a specific reason. The driving constraints are offline viability, low resource consumption (targeting Raspberry Pi 4), and long-term maintainability with a small team.

Technology Role Rationale
Node.js Server runtime Lightweight event-driven I/O; low idle memory footprint on single-board hardware
Express HTTP framework Minimal overhead; straightforward middleware composition; wide ecosystem
Socket.IO Real-time transport Built-in auto-reconnect, rooms, binary event support, and WebSocket/polling fallback
PostgreSQL + PostGIS Primary database Native spatial indexing and geographic query functions; scales from Pi to cloud
MapLibre GL JS Map rendering GPU-accelerated vector tile rendering; offline-capable; fully open-source fork of Mapbox GL
TileServer GL Tile server Serves MBTiles and PMTiles archives locally; no internet required after tile download
React + Vite Frontend framework Mature ecosystem; Vite provides fast HMR in dev and optimized static bundles for production
Tailwind CSS Styling Utility-first; small production bundle; no runtime CSS-in-JS overhead
Docker + Compose Container runtime Reproducible deployment; single-command setup; consistent across x86 and ARM targets
Nginx Reverse proxy Static file serving, TLS termination, and upstream proxying in a single low-overhead process

Database Schema

All geographic columns use the PostGIS GEOGRAPHY type with SRID 4326 (WGS 84). Coordinate order follows the PostGIS convention: (longitude, latitude). Spatial indexes use the GiST index type for efficient bounding-box and distance queries.

Table Description
users Registered callsigns. Stores argon2id password hash, role (observer / operator / admin), enabled flag, and last-seen timestamp.
positions Time-series position reports from clients. Stores user ID, callsign, GEOGRAPHY(POINT), speed, heading, accuracy, and battery level. Purged by a scheduled retention job.
channels Chat channel definitions. Includes name, type (public / group / direct), and optional description.
channel_members Join table linking users to channels they are members of. Used for group and direct message channel membership.
messages Chat messages. References channel, author user ID, and message body. Supports cursor-based pagination via sequential IDs.
markers Map features (points, lines, polygons). Stores GeoJSON geometry as GEOGRAPHY, marker type, name, category, description, color, label, optional photo file reference, and creator.
files File sharing metadata. Records original filename, MIME type, size, uploader, storage path on the Docker volume, and upload timestamp.
overlays Spatial overlay layers (GeoJSON, GPX, georeferenced images). Stores type, GeoJSON content or file reference, computed bounding box, style properties, and corner coordinates for image overlays.
voice_transmissions Voice PTT session records. Links to channel, transmitting user, start/end time, and WebM/Opus recording file reference.
federation_peers Remote GroundWave server connection configuration. Stores URL, display name, pre-shared API key, and connection state.
federation_inbound_keys API keys accepted from remote servers initiating inbound federation connections.
federation_events Deduplication log for federated data. Stores event fingerprints to prevent echo and duplicate ingestion across servers.
server_config Key-value configuration store for persistent server settings (setup state, active tileset, feature flags, etc.).

All database queries use parameterized statements. String concatenation in SQL is prohibited by convention. This eliminates SQL injection as an attack vector regardless of input content.

Real-Time Layer

Real-time communication between server and clients is handled entirely by Socket.IO. The library operates over WebSocket with automatic fallback to HTTP long-polling for restrictive networks.

Event Naming

All Socket.IO events follow a domain:action naming convention. This makes the event stream easy to filter and debug:

  • position:update — client emits its current GPS position
  • position:broadcast — server broadcasts all connected users' positions
  • chat:message — new chat message sent or received
  • chat:typing — real-time typing indicator
  • marker:created / marker:updated / marker:deleted — map marker sync
  • overlay:created / overlay:updated / overlay:deleted — layer sync
  • voice:transmit — binary audio frame during PTT session
  • federation:event — cross-server data relay

Rooms

Socket.IO rooms scope message delivery. Each chat channel has a corresponding room; clients join rooms for channels they are members of. This prevents unnecessary message delivery to unrelated clients and is how direct message privacy is enforced at the transport layer.

Binary Events

Voice PTT audio is transmitted as raw binary Socket.IO events to avoid base64 encoding overhead. The server relays binary frames to all other participants in the channel room with minimal processing. This keeps PTT latency around 60ms on a local network.

Auto-Reconnect

Socket.IO's built-in reconnection logic handles temporary disconnects transparently. When a client reconnects, it re-emits its identity and rejoins its channel rooms. The client-side offline queue replays any operations that were submitted while disconnected.

Security Model

GroundWave's security model is designed for field deployment where physical access to the server network is the primary trust boundary. The following controls are applied in layers:

Transport Security

On first run, GroundWave generates a private Certificate Authority and a server TLS certificate signed by that CA. Nginx serves all traffic over HTTPS using this certificate. Clients that install the CA cert get a fully trusted HTTPS connection — important for mobile browsers that require HTTPS to access device GPS.

Password Hashing

User passwords are hashed with argon2id before storage. Argon2id is the winner of the Password Hashing Competition and is resistant to both GPU-based brute-force attacks and side-channel timing attacks.

JWT Sessions

After successful login, the server issues a signed JWT. The token encodes the user's ID, callsign, and role. All REST endpoints and Socket.IO connections validate the token on every request. Tokens expire after a configurable window; the client detects expiry and redirects to the login page.

Role-Based Access Control

Three roles are enforced at every API boundary and Socket.IO handler:

  • Observer — read-only access to all data; can receive position updates and read chat; cannot send messages, create markers, or upload files
  • Operator — full read/write access to all operational features
  • Admin — all operator capabilities plus user management, system configuration, and data export/import

Role checks are performed against a fresh database read for Socket.IO connections, ensuring that a role change takes effect immediately without requiring reconnection.

Feature Toggles

Individual subsystems can be disabled at the server level via the FEATURES_ENABLED environment variable. Disabled features reject requests at the middleware layer before any business logic executes. The client conditionally renders UI elements based on the feature set reported by the server at startup.