System Architecture
How GroundWave's containers, database, and real-time layers fit together.
8 min readContainer 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 togroundwave-appon port 3000. The Express application handles REST endpoints and Socket.IO manages persistent WebSocket connections. -
Requests to
/tiles/are proxied togroundwave-tileson port 8080. TileServer GL reads pre-downloaded.pmtilesor.mbtilesarchives 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 positionposition:broadcast— server broadcasts all connected users' positionschat:message— new chat message sent or receivedchat:typing— real-time typing indicatormarker:created/marker:updated/marker:deleted— map marker syncoverlay:created/overlay:updated/overlay:deleted— layer syncvoice:transmit— binary audio frame during PTT sessionfederation: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.