Skip to content

Panel Deployment

Build, deploy, and serve the QS-Bridge admin panel.


Build Pipeline

The panel is a static single-page application. One command produces a deployable bundle:

cd panel
npm run build          # vite build

Build Output

dist/
├── index.html                    # SPA entry point
├── assets/
│   ├── index-[hash].js           # Application bundle (~180 KB gzipped)
│   ├── index-[hash].css          # Compiled Tailwind styles (~28 KB gzipped)
│   ├── vendor-[hash].js          # React + dependencies (~65 KB gzipped)
│   └── stdb-[hash].js            # SpacetimeDB SDK chunk (~22 KB gzipped)
├── favicon.ico
└── logo.svg

Code Splitting

Vite's chunk strategy keeps the initial payload under 300 KB:

Chunk Contents Size (gzip)
index App shell, router, pages ~180 KB
vendor React 19, React Router, clsx ~65 KB
stdb SpacetimeDB TypeScript SDK ~22 KB
CSS Tailwind utilities ~28 KB

Environment Variables

Build-time variables (embedded via import.meta.env):

Variable Default Description
VITE_API_URL /api API gateway base URL
VITE_STDB_HOST ws://localhost:3000 SpacetimeDB WebSocket endpoint
VITE_STDB_MODULE qs-bridge SpacetimeDB module name
VITE_APP_TITLE QS-Bridge Browser tab title

Runtime variables (read from window config):

Variable Description
PANEL_SESSION_TIMEOUT JWT expiry (default 24 hours)

Deployment Options

Option 1 — Bundled with API Gateway (Default)

The API gateway serves the panel's static files directly:

gateway/
├── server.js               # Express + API routes
├── dist/                   # ← Panel build output copied here
│   ├── index.html
│   └── assets/
└── Dockerfile
// gateway/server.js
app.use(express.static('dist'));
app.get('*', (req, res) => res.sendFile('dist/index.html'));

Advantages: Single container, single port, no CORS configuration needed.

Option 2 — Separate Static Server

Serve the panel from nginx or any static file server:

server {
    listen 443 ssl;
    server_name panel.example.com;

    root /var/www/panel/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    location /api {
        proxy_pass http://gateway:3100;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Option 3 — Docker

# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Serve stage
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

SPA Routing

The panel uses client-side routing with React Router. The server must return index.html for all non-asset paths:

GET /dashboard        → 200  index.html  (React Router handles route)
GET /players          → 200  index.html
GET /assets/index.js  → 200  (actual static file)
GET /api/servers      → proxy to API gateway

Without the try_files fallback (nginx) or wildcard route (Express), refreshing any page other than / returns 404.

Health Checks

Endpoint Source Response
GET / Panel (index.html) 200 OK
GET /api/health API Gateway { status: 'ok', uptime: ... }
ws://host:3000 SpacetimeDB WebSocket upgrade

Production Checklist

  • Build with NODE_ENV=production
  • Set VITE_API_URL to production gateway URL
  • Set VITE_STDB_HOST to production SpacetimeDB host
  • Enable gzip/brotli compression on the serving layer
  • Set Cache-Control: max-age=31536000 for /assets/ (files are content-hashed)
  • Set Cache-Control: no-cache for index.html
  • Configure HTTPS (TLS 1.2+ required for WebSocket)
  • Verify SPA fallback routing works (refresh on /players returns 200)