refactor(docker): build and ship a single API + UI image

Collapse the separate backend (Node) and frontend (nginx) images into
one image where the Express backend serves the built React app in
addition to the API and game content, so deployment is a single
container and one origin.

- Add a root Dockerfile with frontend-builder + backend-builder stages
  producing one runtime image; remove backend/ and frontend/ Dockerfiles,
  the nginx config, and the frontend entrypoint
- Serve the frontend dist and SPA fallback from Express (config.serveFrontend
  / FRONTEND_DIST_PATH), before auth/maintenance so the shell always loads and
  before the API 404 so client routes resolve to index.html
- Port the SPA-oriented CSP from nginx into Helmet, gated on serveFrontend;
  keep the strict API-only policy otherwise
- Reduce both compose files to a single flashpoint-web service on WEB_PORT
  -> 3100; drop API_PORT, BACKEND_HOST, BACKEND_PORT
- Build one image in the release workflow instead of a two-service matrix
- Update .env.example, README, workflow README, and env var docs

Also add .gitattributes (*.sh eol=lf) and normalize the entrypoint line
endings — without it, Windows checkouts bake CRLF into the image and the
shebang fails with "no such file or directory".
This commit is contained in:
Darkraise
2026-08-11 16:02:06 +07:00
parent a41eac0101
commit 3ba60aedf9
16 changed files with 289 additions and 592 deletions
+8 -12
View File
@@ -11,17 +11,17 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint
JWT_SECRET=replace-with-a-secure-random-string
# ---------------------------------------------------------------------------
# Container Images (production only)
# Container Image (production only)
# ---------------------------------------------------------------------------
# Override image tag (images from Docker Hub: darkraise/flashpoint-*)
# Override image tag (image from Docker Hub: darkraise/flashpoint-web)
# IMAGE_TAG=latest
# ---------------------------------------------------------------------------
# Ports (host mapping)
# Port (host mapping)
# ---------------------------------------------------------------------------
# These map host ports to container ports (container ports are hardcoded)
# Maps a host port to the container, which serves both the UI and the API
# on port 3100
WEB_PORT=80
API_PORT=3100
# ---------------------------------------------------------------------------
# Data Storage
@@ -43,7 +43,7 @@ LOG_LEVEL=info
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_API_KEY=your-api-key-here
OTEL_SERVICE_NAME=flashpoint-web-backend
OTEL_SERVICE_NAME=flashpoint-web
OTEL_TRACES_ENABLED=true
OTEL_METRICS_ENABLED=true
# OTEL_METRICS_EXPORT_INTERVAL=60000
@@ -93,10 +93,6 @@ ENABLE_CACHE_PREWARM=true
# ---------------------------------------------------------------------------
# HOME_RECENT_HOURS=24
# ENABLE_CGI=false
# ---------------------------------------------------------------------------
# Frontend Nginx (Docker only)
# ---------------------------------------------------------------------------
# BACKEND_HOST=backend
# BACKEND_PORT=3100
# Serve the built frontend from the backend (on by default in production)
# SERVE_FRONTEND=true
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must use LF so they run inside Linux containers
*.sh text eol=lf
+21 -42
View File
@@ -6,18 +6,17 @@ This directory contains automated workflows for the Flashpoint Web project.
### 1. Docker Build and Push (`docker-build-push.yml`)
Builds and pushes Docker images for all three services to container registries.
Builds and pushes the single Docker image to the container registry.
#### Triggers
- **Version tags** (`v*.*.*`): Builds and pushes images with version tags and `latest` tag
- **Version tags** (`v*.*.*`): Builds and pushes the image with version tags and `latest` tag
- **Manual dispatch**: Can be triggered manually from GitHub Actions tab
#### Services Built
#### Image Built
- `flashpoint-backend` - REST API server
- `flashpoint-frontend` - React web UI (Nginx)
- `flashpoint-game-service` - Game content proxy and ZIP server
- `flashpoint-web` - Single image serving both the REST API (including game
content) and the React web UI
#### Container Registry
@@ -43,11 +42,9 @@ The workflow pushes images to **Docker Hub**.
- `DOCKERHUB_USERNAME`: Your Docker Hub username
- `DOCKERHUB_TOKEN`: The access token from step 2
4. **Images will be pushed to**:
4. **Image will be pushed to**:
```
docker.io/darkraise/flashpoint-backend:latest
docker.io/darkraise/flashpoint-frontend:latest
docker.io/darkraise/flashpoint-game-service:latest
docker.io/darkraise/flashpoint-web:latest
```
#### Image Tags
@@ -98,51 +95,34 @@ The workflow uses GitHub Actions cache to speed up builds:
#### Usage Examples
##### Pull and Run Images
##### Pull and Run the Image
```bash
# Pull latest images
docker pull darkraise/flashpoint-backend:latest
docker pull darkraise/flashpoint-frontend:latest
docker pull darkraise/flashpoint-game-service:latest
# Pull the latest image
docker pull darkraise/flashpoint-web:latest
# Or use a specific version
docker pull darkraise/flashpoint-backend:1.0.0
docker pull darkraise/flashpoint-web:1.0.0
```
##### Use in Docker Compose
Update your `docker-compose.yml` to use pre-built images:
Update your `docker-compose.yml` to use the pre-built image:
```yaml
version: '3.8'
services:
backend:
image: darkraise/flashpoint-backend:latest
flashpoint-web:
image: darkraise/flashpoint-web:latest
# Remove build section
ports:
- "3100:3100"
# ... rest of config
frontend:
image: darkraise/flashpoint-frontend:latest
ports:
- "80:8080"
# ... rest of config
game-service:
image: darkraise/flashpoint-game-service:latest
ports:
- "22500:22500"
- "22501:22501"
- "80:3100"
# ... rest of config
```
Then simply run:
```bash
docker-compose pull # Pull latest images
docker-compose up -d # Start services
docker compose pull # Pull the latest image
docker compose up -d # Start the service
```
#### Making Images Public
@@ -150,7 +130,7 @@ docker-compose up -d # Start services
By default, Docker Hub images are private. To make them public:
1. Go to https://hub.docker.com/repositories
2. Click on the repository (e.g., `flashpoint-backend`)
2. Click on the repository (e.g., `flashpoint-web`)
3. Click "Settings"
4. Change visibility to "Public"
@@ -179,10 +159,9 @@ docker login docker.io
#### Performance
Typical build times (optimized for amd64 only):
- First build: 3-5 minutes per service
- Subsequent builds (with cache): 1-2 minutes per service
- Parallel execution: All 3 services build simultaneously
- Total workflow time: ~6-10 minutes (down from 12+ minutes)
- First build: 3-5 minutes
- Subsequent builds (with cache): 1-2 minutes
- Frontend and backend build stages run within a single image build
**Optimizations applied:**
- Single platform (amd64) instead of multi-platform
+14 -27
View File
@@ -13,16 +13,6 @@ jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
service:
- name: backend
context: .
dockerfile: ./backend/Dockerfile
- name: frontend
context: .
dockerfile: ./frontend/Dockerfile
permissions:
contents: read
@@ -52,7 +42,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-${{ matrix.service.name }}
images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web
tags: |
# Tag with full version (v1.0.0 -> 1.0.0)
type=semver,pattern={{version}}
@@ -63,16 +53,16 @@ jobs:
# Latest tag
type=raw,value=latest
- name: Build and push Docker images
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ${{ matrix.service.context }}
file: ${{ matrix.service.dockerfile }}
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.service.name }}
cache-to: type=gha,scope=${{ matrix.service.name }},mode=max
cache-from: type=gha,scope=flashpoint-web
cache-to: type=gha,scope=flashpoint-web,mode=max
platforms: linux/amd64
build-args: |
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
@@ -90,17 +80,15 @@ jobs:
run: |
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Images Built:" >> $GITHUB_STEP_SUMMARY
echo "- \`flashpoint-backend\`" >> $GITHUB_STEP_SUMMARY
echo "- \`flashpoint-frontend\`" >> $GITHUB_STEP_SUMMARY
echo "### Image Built:" >> $GITHUB_STEP_SUMMARY
echo "- \`flashpoint-web\` (API + UI)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-*\`" >> $GITHUB_STEP_SUMMARY
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Commands:" >> $GITHUB_STEP_SUMMARY
echo "### Pull Command:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-backend:latest" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-frontend:latest" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:latest" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
create-release:
@@ -127,13 +115,12 @@ jobs:
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "## Docker Images" >> release_notes.md
echo "## Docker Image" >> release_notes.md
echo "" >> release_notes.md
echo "Pull the images from Docker Hub:" >> release_notes.md
echo "Pull the image from Docker Hub:" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-backend:${{ steps.version.outputs.version }}" >> release_notes.md
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-frontend:${{ steps.version.outputs.version }}" >> release_notes.md
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:${{ steps.version.outputs.version }}" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
+115
View File
@@ -0,0 +1,115 @@
# Production Dockerfile for Flashpoint Web (single image: API + UI)
#
# The backend serves both the REST API / game content and the built React UI,
# so this image runs a single Node process. Build from the repo root:
# docker build -t flashpoint-web .
# =============================================================================
# Stage 1: Frontend builder - Build the React app
# =============================================================================
FROM node:20.18.2-alpine AS frontend-builder
WORKDIR /app
# Copy workspace root and frontend package files
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
# Install frontend dependencies (frozen lockfile for reproducible builds)
RUN npm ci --workspace=frontend --include-workspace-root --prefer-offline --no-audit
# Copy frontend source and build static assets
COPY frontend ./frontend/
WORKDIR /app/frontend
RUN npm run build
# =============================================================================
# Stage 2: Backend builder - Compile TypeScript
# =============================================================================
FROM node:20.18.2-alpine AS backend-builder
WORKDIR /app
# Copy workspace root and backend package files
COPY package.json package-lock.json ./
COPY backend/package.json ./backend/
# Install backend dependencies (frozen lockfile for reproducible builds)
RUN npm ci --workspace=backend --include-workspace-root --prefer-offline --no-audit
# Copy backend source and build
COPY backend ./backend/
WORKDIR /app/backend
RUN npm run build
# =============================================================================
# Stage 3: Production - Optimized runtime
# =============================================================================
FROM node:20.18.2-alpine
# Metadata labels
LABEL org.opencontainers.image.title="Flashpoint Web"
LABEL org.opencontainers.image.description="Self-hosted web app for Flashpoint Archive (API + UI)"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web"
WORKDIR /app
# Install runtime dependencies
# - curl: health checks
# - tzdata: timezone support
# - su-exec: lightweight tool to drop privileges (like gosu)
# - shadow: provides usermod/groupmod for dynamic UID/GID
RUN apk add --no-cache curl tzdata su-exec shadow
# Copy workspace root package files
COPY --from=backend-builder /app/package.json /app/package-lock.json ./
# Copy backend package files
COPY --from=backend-builder /app/backend/package.json ./backend/
# Install production dependencies only and clean npm cache to reduce image size
RUN npm ci --workspace=backend --include-workspace-root --omit=dev --prefer-offline --no-audit && \
npm cache clean --force
# Copy built backend
COPY --from=backend-builder /app/backend/dist ./backend/dist
# Copy migration files to dist directory (needed at runtime)
COPY --from=backend-builder /app/backend/src/migrations ./backend/dist/migrations
# Copy entrypoint script
COPY --from=backend-builder /app/backend/docker-entrypoint.sh ./backend/docker-entrypoint.sh
# Copy built frontend — served by the backend (config.frontendDistPath resolves
# to /app/frontend/dist at runtime)
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Create data and logs directories, make entrypoint executable, fix ownership
# Uses existing 'node' user (UID 1000) from base image
RUN mkdir -p /app/data /app/logs && \
chmod +x /app/backend/docker-entrypoint.sh && \
chown -R node:node /app
# Set working directory to backend (entrypoint runs `node dist/server.js`)
WORKDIR /app/backend
# Expose port (serves both API and UI)
EXPOSE 3100
# Health check is configured in docker-compose.yml for flexibility
# Environment variables
ENV NODE_ENV=production
ENV PUID=1000
ENV PGID=1000
# Start as root, entrypoint will drop privileges after setting up user
USER root
# Production command using entrypoint script
# The entrypoint handles:
# 1. Dynamic UID/GID setup (if PUID/PGID differ from defaults)
# 2. Database copy (if enabled)
# 3. Privilege drop and application startup
ENTRYPOINT ["./docker-entrypoint.sh"]
+6 -29
View File
@@ -74,12 +74,12 @@ mkdir flashpoint-web && cd flashpoint-web
<!-- prettier-ignore -->
```yaml
services:
backend:
image: darkraise/flashpoint-backend:${IMAGE_TAG:-latest}
container_name: flashpoint-backend
flashpoint-web:
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
container_name: flashpoint-web
restart: unless-stopped
ports:
- "${API_PORT:-3100}:3100"
- "${WEB_PORT:-80}:3100"
volumes:
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
- ${DATA_PATH:-./data}:/app/data
@@ -102,28 +102,6 @@ services:
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest}
container_name: flashpoint-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:8080"
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-UTC}
- BACKEND_HOST=${BACKEND_HOST:-backend}
- BACKEND_PORT=${BACKEND_PORT:-3100}
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
```
### 3. Create `.env`
@@ -136,11 +114,10 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint
JWT_SECRET=your-secure-secret-key
# Optional — uncomment and edit as needed
# WEB_PORT=80 # Frontend port (default: 80)
# API_PORT=3100 # Backend API port (default: 3100)
# WEB_PORT=80 # Host port for the app (UI + API) (default: 80)
# PUID=1000 # Host user ID (Linux — run 'id -u')
# PGID=1000 # Host group ID (Linux — run 'id -g')
# DOMAIN=http://localhost # Public URL of the frontend
# DOMAIN=http://localhost # Public URL of the app
# LOG_LEVEL=info # debug, info, warn, error
# TZ=UTC # Timezone
# DATA_PATH=./data # Persistent app data
-94
View File
@@ -1,94 +0,0 @@
# Production Dockerfile for Flashpoint Web Backend
# =============================================================================
# Stage 1: Builder - Build TypeScript
# =============================================================================
FROM node:20.18.2-alpine AS builder
WORKDIR /app
# Copy workspace root package files
COPY package.json package-lock.json ./
# Copy backend package files
COPY backend/package.json ./backend/
# Install all dependencies for the backend workspace
# Use npm ci with frozen lockfile for faster, reproducible builds
RUN npm ci --workspace=backend --include-workspace-root --prefer-offline --no-audit
# Copy backend source code
COPY backend ./backend/
# Build TypeScript (run from backend directory)
WORKDIR /app/backend
RUN npm run build
# =============================================================================
# Stage 2: Production - Optimized runtime
# =============================================================================
FROM node:20.18.2-alpine
# Metadata labels
LABEL org.opencontainers.image.title="Flashpoint Web Backend"
LABEL org.opencontainers.image.description="REST API for Flashpoint Archive"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web"
WORKDIR /app
# Install runtime dependencies
# - curl: health checks
# - tzdata: timezone support
# - su-exec: lightweight tool to drop privileges (like gosu)
# - shadow: provides usermod/groupmod for dynamic UID/GID
RUN apk add --no-cache curl tzdata su-exec shadow
# Copy workspace root package files
COPY --from=builder /app/package.json /app/package-lock.json ./
# Copy backend package files
COPY --from=builder /app/backend/package.json ./backend/
# Install production dependencies only and clean npm cache to reduce image size
RUN npm ci --workspace=backend --include-workspace-root --omit=dev --prefer-offline --no-audit && \
npm cache clean --force
# Copy built application
COPY --from=builder /app/backend/dist ./backend/dist
# Copy migration files to dist directory (needed at runtime)
COPY --from=builder /app/backend/src/migrations ./backend/dist/migrations
# Copy entrypoint script
COPY --from=builder /app/backend/docker-entrypoint.sh ./backend/docker-entrypoint.sh
# Create data and logs directories
# Make entrypoint script executable
# Use existing 'node' user (UID 1000) from base image
RUN mkdir -p /app/data /app/logs && \
chmod +x /app/backend/docker-entrypoint.sh && \
chown -R node:node /app
# Set working directory to backend
WORKDIR /app/backend
# Expose port
EXPOSE 3100
# Health check is configured in docker-compose.yml for flexibility
# Environment variables
ENV NODE_ENV=production
ENV PUID=1000
ENV PGID=1000
# Start as root, entrypoint will drop privileges after setting up user
USER root
# Production command using entrypoint script
# The entrypoint handles:
# 1. Dynamic UID/GID setup (if PUID/PGID differ from defaults)
# 2. Database copy (if enabled)
# 3. Privilege drop and application startup
ENTRYPOINT ["./docker-entrypoint.sh"]
+10
View File
@@ -69,6 +69,16 @@ export const config = {
port: 3100, // Hardcoded — Docker EXPOSE and healthcheck depend on this value
host: '0.0.0.0',
// Single-image deployment: the backend also serves the built frontend and its
// SPA fallback. On by default in production; opt in elsewhere via SERVE_FRONTEND.
serveFrontend: process.env.SERVE_FRONTEND
? process.env.SERVE_FRONTEND === 'true'
: process.env.NODE_ENV === 'production',
// __dirname is backend/dist at runtime, so this resolves to the frontend build
// copied next to the backend in the image (/app/frontend/dist).
frontendDistPath:
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
flashpointPath,
flashpointDbPath: `${flashpointPath}/Data/flashpoint.sqlite`,
flashpointHtdocsPath: `${flashpointPath}/Legacy/htdocs`,
+76 -5
View File
@@ -1,6 +1,8 @@
// IMPORTANT: OpenTelemetry must be imported FIRST for auto-instrumentation
import './telemetry';
import path from 'path';
import fs from 'fs';
import express, { Express } from 'express';
import cors from 'cors';
import helmet from 'helmet';
@@ -82,13 +84,37 @@ async function startServer() {
app.use('/game-proxy', gameCors, gameProxyRouter);
app.use('/game-zip', gameCors, gameZipRouter);
// When the backend also serves the SPA, the CSP mirrors the policy the former
// nginx frontend used. 'unsafe-inline' (styles) is required by Radix/shadcn
// inline style attributes; 'unsafe-eval' (scripts) is required by Ruffle's WASM.
// No upgrade-insecure-requests, so self-hosted HTTP deployments keep working.
// When running API-only, keep the strict 'none' policy.
// TODO(security): tighten via per-request nonces + 'wasm-unsafe-eval' once the
// SPA and Ruffle are verified to work without the broad 'unsafe-*' allowances.
const cspDirectives: Record<string, string[]> = config.serveFrontend
? {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
}
: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
};
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
useDefaults: !config.serveFrontend,
directives: cspDirectives,
},
crossOriginEmbedderPolicy: false,
frameguard: { action: 'deny' },
@@ -121,6 +147,51 @@ async function startServer() {
app.use(cookieParser());
app.use(compression());
// Single-image deployment: the backend serves the built frontend and its SPA
// fallback. Registered before auth/maintenance so the app shell always loads
// (the SPA then reflects login/maintenance state from API responses), and
// before the API catch-all 404 in setupRoutes so client routes reach index.html.
if (config.serveFrontend) {
const distPath = config.frontendDistPath;
const indexHtml = path.join(distPath, 'index.html');
if (fs.existsSync(indexHtml)) {
app.use(
express.static(distPath, {
index: false,
maxAge: '1y',
immutable: true,
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
})
);
const backendPrefixes = ['/api/', '/game-proxy/', '/game-zip/', '/proxy/'];
app.get('*', (req, res, next) => {
if (
req.path === '/health' ||
backendPrefixes.some((prefix) => req.path.startsWith(prefix))
) {
next();
return;
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(indexHtml);
});
logger.info(`🖥️ Serving frontend from ${distPath}`);
} else {
logger.warn(
`⚠️ SERVE_FRONTEND is enabled but no build was found at ${indexHtml} — skipping static serving`
);
}
}
app.use(requestTimeout(TimeoutConfig.DEFAULT));
app.use((req, res, next) => {
@@ -282,7 +353,7 @@ async function startServer() {
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
server.headersTimeout = 66000; // Must be > keepAliveTimeout
server.timeout = 120000; // 2 min max for any request (including game file streaming)
server.maxConnections = 500; // Defense-in-depth; nginx handles primary limiting
server.maxConnections = 500; // Defense-in-depth against connection exhaustion
const playTrackingService = new PlayTrackingService();
const playSessionCleanupInterval = setInterval(
+10 -39
View File
@@ -1,14 +1,11 @@
# =============================================================================
# Flashpoint Web - Development Docker Compose
# =============================================================================
# Builds images locally from source code.
# Builds the single API + UI image locally from source.
#
# Usage:
# docker compose -f docker-compose.dev.yml up -d --build
#
# Rebuild specific service:
# docker compose -f docker-compose.dev.yml up -d --build backend
#
# View logs:
# docker compose -f docker-compose.dev.yml logs -f
#
@@ -18,24 +15,25 @@
# =============================================================================
services:
backend:
flashpoint-web:
build:
context: .
dockerfile: ./backend/Dockerfile
image: flashpoint-backend:dev
container_name: flashpoint-backend
dockerfile: ./Dockerfile
image: flashpoint-web:dev
container_name: flashpoint-web
restart: unless-stopped
ports:
- "${API_PORT:-3100}:3100"
# Backend now includes game content serving on /game-proxy and /game-zip
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
- "${WEB_PORT:-80}:3100"
volumes:
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
- backend-db:/app/data
- ${LOGS_PATH:-./logs}/backend:/app/logs
- ${LOGS_PATH:-./logs}:/app/logs
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- NODE_ENV=${NODE_ENV:-development}
- SERVE_FRONTEND=true
- TZ=${TZ:-UTC}
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
- LOG_LEVEL=${LOG_LEVEL:-info}
@@ -56,7 +54,7 @@ services:
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_API_KEY=${OTEL_API_KEY:-}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
@@ -70,33 +68,6 @@ services:
networks:
- flashpoint-network
frontend:
build:
context: .
dockerfile: ./frontend/Dockerfile
image: flashpoint-frontend:dev
container_name: flashpoint-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:8080"
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-UTC}
- BACKEND_HOST=${BACKEND_HOST:-backend}
- BACKEND_PORT=${BACKEND_PORT:-3100}
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
networks:
- flashpoint-network
networks:
flashpoint-network:
driver: bridge
+9 -44
View File
@@ -1,7 +1,8 @@
# =============================================================================
# Flashpoint Web - Production Docker Compose
# =============================================================================
# Uses pre-built images from container registry.
# Single image serving both the API and the web UI.
# Uses a pre-built image from the container registry.
#
# Usage:
# 1. Copy .env.example to .env and configure required variables
@@ -16,13 +17,13 @@
# =============================================================================
services:
backend:
image: darkraise/flashpoint-backend:${IMAGE_TAG:-latest}
container_name: flashpoint-backend
flashpoint-web:
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
container_name: flashpoint-web
restart: unless-stopped
ports:
- "${API_PORT:-3100}:3100"
# Backend now includes game content serving on /game-proxy and /game-zip
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
- "${WEB_PORT:-80}:3100"
volumes:
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
- ${DATA_PATH:-./data}:/app/data
@@ -31,6 +32,7 @@ services:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- NODE_ENV=production
- SERVE_FRONTEND=true
- TZ=${TZ:-UTC}
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
- LOG_LEVEL=${LOG_LEVEL:-info}
@@ -51,7 +53,7 @@ services:
- OTEL_ENABLED=${OTEL_ENABLED:-false}
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
- OTEL_API_KEY=${OTEL_API_KEY:-}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
@@ -78,43 +80,6 @@ services:
networks:
- flashpoint-network
frontend:
image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest}
container_name: flashpoint-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-80}:8080"
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-UTC}
- BACKEND_HOST=${BACKEND_HOST:-backend}
- BACKEND_PORT=${BACKEND_PORT:-3100}
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
cpus: "0.25"
memory: 128M
networks:
- flashpoint-network
networks:
flashpoint-network:
driver: bridge
+18 -18
View File
@@ -67,6 +67,16 @@ openssl rand -hex 64
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
**Frontend Serving:**
The backend serves the built React UI in the single-image deployment, so the app
and API share one origin.
| Variable | Default | Description |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
**OpenTelemetry:**
| Variable | Default | Description |
@@ -74,7 +84,7 @@ openssl rand -hex 64
| `OTEL_ENABLED` | false | Enable distributed tracing and metrics |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OTLP collector endpoint |
| `OTEL_API_KEY` | - | API key for authentication |
| `OTEL_SERVICE_NAME` | flashpoint-web-backend | Service name in traces/metrics |
| `OTEL_SERVICE_NAME` | flashpoint-web | Service name in traces/metrics |
| `OTEL_TRACES_ENABLED` | true | Enable trace export (when OTEL enabled) |
| `OTEL_METRICS_ENABLED` | true | Enable metrics export (when OTEL enabled)|
| `OTEL_METRICS_EXPORT_INTERVAL`| 60000 | Metrics export interval (ms) |
@@ -82,11 +92,11 @@ openssl rand -hex 64
## Frontend Variables
The frontend has no environment variables in development or production. API
calls are proxied through the backend.
The frontend has no environment variables of its own. API calls use relative
paths (`/api`), so they resolve against whatever origin serves the app.
- **Development**: Via Vite proxy to `http://localhost:3100`
- **Production**: Via Nginx reverse proxy (configured by `BACKEND_HOST`/`BACKEND_PORT`)
- **Development**: Vite dev server on port 5173 proxies `/api` to `http://localhost:3100`
- **Production**: The backend serves the built UI and the API from the same origin
## Docker Environment Variables
@@ -101,10 +111,9 @@ Location: `.env` in project root (used by docker-compose)
**Port Mapping:**
| Variable | Default | Description |
| ---------- | ------- | --------------------- |
| `WEB_PORT` | 80 | Frontend exposed port |
| `API_PORT` | 3100 | Backend exposed port |
| Variable | Default | Description |
| ---------- | ------- | ------------------------------------------------------ |
| `WEB_PORT` | 80 | Host port mapped to the container (serves UI + API) |
**Container Settings:**
@@ -117,13 +126,6 @@ Location: `.env` in project root (used by docker-compose)
| `DATA_PATH` | ./data | Host path for persistent data (production compose) |
| `LOGS_PATH` | ./logs | Host path for log files |
**Frontend Nginx:**
| Variable | Default | Description |
| -------------- | -------- | --------------------------------------- |
| `BACKEND_HOST` | backend | Backend hostname (Docker service name) |
| `BACKEND_PORT` | 3100 | Backend port for Nginx proxy_pass |
All backend variables listed above are also passable via docker-compose. See
`docker-compose.yml` for the full list with defaults.
@@ -137,7 +139,6 @@ FLASHPOINT_HOST_PATH=/path/to/flashpoint
JWT_SECRET=$(openssl rand -hex 64)
DOMAIN=https://flashpoint.example.com
WEB_PORT=80
API_PORT=3100
LOG_LEVEL=warn
EOF
```
@@ -168,7 +169,6 @@ FLASHPOINT_HOST_PATH=/data/flashpoint
JWT_SECRET=CHANGE-THIS-TO-A-RANDOM-64-CHARACTER-STRING
DOMAIN=https://flashpoint.example.com
WEB_PORT=80
API_PORT=3100
LOG_LEVEL=warn
```
-125
View File
@@ -1,125 +0,0 @@
# Nginx configuration for Flashpoint Web Frontend
# Serves static files and handles SPA routing
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression for better performance
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Content Security Policy
# Using 'self' for connect-src and frame-src since nginx proxies all backend
# traffic through the same origin — no need for localhost references
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always;
# Permissions Policy (formerly Feature-Policy)
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Backend API
location ^~ /api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Game proxy/media passthrough (through backend)
location ^~ /proxy/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Game content proxy - routes to backend (game service is integrated)
location ^~ /game-proxy/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Game ZIP content - routes to backend (game service is integrated)
location ^~ /game-zip/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets with versioned filenames
# NOTE: Must repeat security headers — nginx add_header in location blocks
# replaces ALL parent-level add_header directives
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
}
# Don't cache index.html to ensure updates are seen
# NOTE: Must repeat security headers — see comment above
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
}
# Error handling
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
-51
View File
@@ -1,51 +0,0 @@
# Main nginx configuration for Flashpoint Web Frontend
# Optimized for Docker container running as non-root user
# Don't use 'user' directive when running as non-root
# worker_processes auto is fine for most cases
worker_processes auto;
# Log to stdout/stderr for Docker (no file permission issues)
error_log /dev/stderr warn;
# PID file location (must be writable by non-root user)
pid /tmp/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# Hide nginx version in headers and error pages
server_tokens off;
# Include mime types
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging to stdout for Docker
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Temp directories (must be writable by non-root user)
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Include server configurations
include /etc/nginx/conf.d/*.conf;
}
-72
View File
@@ -1,72 +0,0 @@
# Production Dockerfile for Flashpoint Web Frontend
# =============================================================================
# Stage 1: Builder - Build for production
# =============================================================================
FROM node:20.18.2-alpine AS builder
WORKDIR /app
# Copy workspace root package files
COPY package.json package-lock.json ./
# Copy frontend package files
COPY frontend/package.json ./frontend/
# Install dependencies for frontend workspace
# Use npm ci with frozen lockfile for faster, reproducible builds
RUN npm ci --workspace=frontend --include-workspace-root --prefer-offline --no-audit
# Copy frontend source code
COPY frontend ./frontend/
# Build application (run from frontend directory)
WORKDIR /app/frontend
RUN npm run build
# =============================================================================
# Stage 2: Production - Nginx serving static files
# =============================================================================
FROM nginx:1.27-alpine
# Metadata labels
LABEL org.opencontainers.image.title="Flashpoint Web Frontend"
LABEL org.opencontainers.image.description="React web UI for Flashpoint Archive"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web"
# Install runtime dependencies
# - gettext: provides envsubst for nginx config templating
RUN apk add --no-cache gettext
# Copy built files from builder
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# Copy nginx configuration from .nginx/ folder
# Main config: logs to stdout/stderr, uses /tmp for temp files (no permission issues)
COPY frontend/.nginx/nginx.conf /etc/nginx/nginx.conf
COPY frontend/.nginx/default.conf.template /etc/nginx/templates/default.conf.template
# Copy entrypoint script
COPY frontend/docker-entrypoint.sh /docker-entrypoint.sh
# Default backend host (can be overridden at runtime)
ENV BACKEND_HOST=backend \
BACKEND_PORT=3100
# Set up permissions for nginx user (already exists in nginx:alpine)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /etc/nginx/conf.d && \
chmod +x /docker-entrypoint.sh
# Expose port (8080 for non-root user)
EXPOSE 8080
# Health check is configured in docker-compose.yml for flexibility.
# When running standalone (docker run), add --health-cmd manually.
# Run as non-root user (nginx user from base image)
USER nginx
# Production command using entrypoint script
ENTRYPOINT ["/docker-entrypoint.sh"]
-34
View File
@@ -1,34 +0,0 @@
#!/bin/sh
set -e
# =============================================================================
# Docker Entrypoint for Flashpoint Web Frontend
# =============================================================================
# This script handles:
# 1. Nginx configuration templating
# 2. Application startup
# =============================================================================
echo "Flashpoint Web Frontend - Starting..."
# =============================================================================
# Nginx Configuration Templating
# =============================================================================
# Process nginx config template with environment variables
# Set defaults for environment variables (envsubst doesn't support :-default syntax)
export BACKEND_HOST="${BACKEND_HOST:-backend}"
export BACKEND_PORT="${BACKEND_PORT:-3100}"
echo "Processing nginx configuration..."
envsubst '${BACKEND_HOST} ${BACKEND_PORT}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
# Display configuration summary
echo ""
echo "Configuration Summary:"
echo " Backend: ${BACKEND_HOST}:${BACKEND_PORT}"
echo ""
# Start nginx
echo "Starting Nginx..."
exec nginx -g "daemon off;"