fix: serve frontend in production — multi-stage Dockerfile with Node build + StaticFiles mount

- Dockerfile: add Node.js frontend build stage (vite build)
- Dockerfile: copy frontend/dist into runtime image
- app/main.py: mount /assets static files + SPA catch-all fallback
- app/main.py: serve index.html for all non-API routes
- Fixes: https://crm.media-on.de/ returning 404 (frontend not served)
This commit is contained in:
leocrm-bot
2026-07-02 09:55:50 +02:00
parent 5b7b1575de
commit f477efc366
2 changed files with 50 additions and 19 deletions
+25 -18
View File
@@ -1,13 +1,24 @@
# syntax=docker/dockerfile:1
# =============================================================================
# CRM System v1.0 - Production Dockerfile
# Multi-stage build: builder (with build tools) + runtime (slim, non-root)
# Base: python:3.12-slim
# LeoCRM v1.0 - Production Dockerfile
# Multi-stage build: frontend (Node) + builder (Python) + runtime (slim)
# =============================================================================
# === Stage 1: Builder ===
# Installs build dependencies (needed for compiling asyncpg, cryptography, etc.)
# === Stage 0: Frontend Build ===
FROM node:20-slim AS frontend
WORKDIR /frontend
# Copy package files first for layer caching
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --silent 2>/dev/null || npm install --silent
# Copy frontend source and build
COPY frontend/ ./
RUN npx vite build
# === Stage 1: Python Builder ===
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -15,7 +26,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# Build tools (gcc, libpq-dev) — needed for asyncpg + python-jose[cryptography]
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
@@ -24,14 +34,10 @@ RUN apt-get update \
WORKDIR /app
# Copy ONLY requirements first for optimal layer caching
COPY requirements.txt .
# Install all production dependencies into a user-local prefix
RUN pip install --user --no-cache-dir -r requirements.txt
# === Stage 2: Runtime ===
# Slim image, non-root user, no build tools
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -40,7 +46,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PATH=/home/appuser/.local/bin:$PATH
# Runtime dependencies: libpq5 (for asyncpg), curl (for healthcheck)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq5 \
@@ -54,21 +59,23 @@ WORKDIR /app
# Copy installed Python packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application source (static files included via app/webui/)
# Copy application source
COPY --chown=appuser:appuser . .
# Copy built frontend from frontend stage
COPY --from=frontend --chown=appuser:appuser /frontend/dist /app/frontend/dist
# Make prestart.sh executable
RUN chmod +x /app/prestart.sh
# Create storage directory
RUN mkdir -p /data/storage && chown -R appuser:appuser /data
USER appuser
# Internal port (Coolify/Traefik terminate SSL on 443 externally)
EXPOSE 8000
# Healthcheck: hits the root-level /health endpoint defined in app/main.py
# Interval 30s, timeout 10s, 3 retries, 15s start-period (migrations need time)
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8000/api/v1/health || exit 1
# Entrypoint runs DB migrations first, then starts uvicorn as PID 1
ENTRYPOINT ["/app/prestart.sh"]
+25 -1
View File
@@ -6,9 +6,12 @@ import time
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import os
from app.config import get_settings
from app.core.db import close_engine, get_engine
@@ -123,6 +126,27 @@ def create_app() -> FastAPI:
app.include_router(ai_copilot.router)
app.include_router(workflows.router)
# ─── Serve frontend static files (SPA) ───────────────────────────────
# Mount built frontend assets (JS, CSS, images)
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
if os.path.isdir(frontend_dist):
# Serve static assets at /assets
assets_path = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_path):
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
# SPA catch-all: serve index.html for all non-API routes
@app.get("/{full_path:path}")
async def spa_spa(full_path: str):
"""Serve index.html for all non-API routes (SPA fallback)."""
# Don't intercept API routes
if full_path.startswith(("api/", "docs", "openapi", "redoc")):
raise HTTPException(status_code=404, detail="Not Found")
index_path = os.path.join(frontend_dist, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend not built")
return app