feat(project): containerize as a single image serving SPA + API

Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static,
no Node runtime in the final image) and the Go server, then ships an Alpine
runtime that serves both the static frontend and the API on one port.

- Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw)
- Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go)
- Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume,
  healthcheck on /health; secrets passed at runtime, not baked in

To serve the SPA on the API port, the Go server now optionally hosts static
files behind a new STATIC_DIR env var: a request maps to a real file when one
exists, otherwise falls back to index.html for client-side routes; unknown
/api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API
standalone while Vite serves the UI. Cache-Control is tuned to adapter-static
output (immutable hashed assets, no-cache service worker) and .webmanifest is
registered so nosniff doesn't reject the PWA manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:52:27 +03:00
parent aab62cbe41
commit f5f7db6c2a
8 changed files with 220 additions and 1 deletions
+7
View File
@@ -38,6 +38,11 @@ type Config struct {
// Import
ImportPath string
// Static SPA. When set, the server serves the built frontend (and falls
// back to index.html for client routes) on the same port as the API. Empty
// in local development, where the Vite dev server serves the UI separately.
StaticDir string
}
// Load reads a .env file (if present) then loads all configuration from
@@ -120,6 +125,8 @@ func Load() (*Config, error) {
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
ImportPath: requireStr("IMPORT_PATH"),
StaticDir: defaultStr("STATIC_DIR", ""),
}
if len(errs) > 0 {