From 1b5c1e2a0672d9b1de4a10c26710036be3c2ad6f Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 5 Jul 2026 11:24:19 -0300 Subject: [PATCH 1/4] feat: separate frontend container (Axum static serving + API proxy) A dedicated Rust/Axum binary (web/frontend-server/) that: - Serves the built Vite SPA bundle from dist/ via tower-http ServeDir - Falls back to index.html for SPA routing (deep links work) - Reverse-proxies /api/* to the backend container (same-origin, no CORS) - Listens on port 8080 (configurable via FRONTEND_PORT) Dockerfile (web/normogen-web/Dockerfile): 3-stage build: 1. Node: npm ci + npm run build -> dist/ 2. Rust: cargo build --release the frontend-server binary 3. Runtime: debian-slim + binary + dist/ docker-compose.yml: new 'frontend' service on :8080, depends on backend healthy, proxies to http://backend:6500. Independently scalable (run N replicas behind a load balancer). Architecture: Browser -> :8080 (frontend container) /api/* -> proxy -> backend:6500 /* -> static dist/ (SPA) --- backend/docker-compose.yml | 20 +++++ web/frontend-server/Cargo.toml | 14 ++++ web/frontend-server/src/main.rs | 139 ++++++++++++++++++++++++++++++++ web/normogen-web/Dockerfile | 54 +++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 web/frontend-server/Cargo.toml create mode 100644 web/frontend-server/src/main.rs create mode 100644 web/normogen-web/Dockerfile diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index bb68275..afaf49b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -42,5 +42,25 @@ services: retries: 3 start_period: 40s + frontend: + build: + context: ../web/normogen-web + container_name: normogen-frontend + restart: unless-stopped + ports: + - "8080:8080" + environment: + - BACKEND_URL=http://backend:6500 + - FRONTEND_PORT=8080 + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + volumes: mongodb_data: diff --git a/web/frontend-server/Cargo.toml b/web/frontend-server/Cargo.toml new file mode 100644 index 0000000..00b6189 --- /dev/null +++ b/web/frontend-server/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "normogen-frontend-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "normogen-frontend-server" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.5", features = ["fs", "trace"] } +reqwest = { version = "0.12", features = ["json"] } diff --git a/web/frontend-server/src/main.rs b/web/frontend-server/src/main.rs new file mode 100644 index 0000000..4a8bf6a --- /dev/null +++ b/web/frontend-server/src/main.rs @@ -0,0 +1,139 @@ +//! Normogen frontend server — a minimal Axum app that: +//! +//! 1. Serves the built Vite SPA bundle from `dist/` (static files). +//! 2. Falls back to `index.html` for any unmatched route (SPA history mode). +//! 3. Reverse-proxies `/api/*` requests to the backend container. +//! +//! Designed to run as a standalone container, independently scalable. +//! No business logic, no DB — just static serving + proxying. + +use axum::{ + body::Body, + extract::{Request, State}, + http::{HeaderValue, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::any, + Router, +}; +use std::env; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; + +/// Shared state: the backend URL + a reqwest client for proxying. +#[derive(Clone)] +struct ProxyState { + backend_url: String, + client: reqwest::Client, +} + +#[tokio::main] +async fn main() { + let backend_url = + env::var("BACKEND_URL").unwrap_or_else(|_| "http://backend:6500".to_string()); + let port: u16 = env::var("FRONTEND_PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse() + .expect("FRONTEND_PORT must be a number"); + + let client = reqwest::Client::builder() + .build() + .expect("failed to build reqwest client"); + + let state = ProxyState { + backend_url: backend_url.clone(), + client, + }; + + // Static file serving with SPA fallback. + let serve_dir = ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")); + + let app = Router::new() + // Proxy /api/* to the backend. + .route("/api/{*rest}", any(proxy_to_backend)) + .with_state(state) + // Everything else: static files + SPA fallback. + .fallback_service(serve_dir) + .layer(TraceLayer::new_for_http()); + + let addr = format!("0.0.0.0:{port}"); + eprintln!("Frontend server listening on {addr}"); + eprintln!("Proxying /api/* to {backend_url}"); + eprintln!("Serving static files from dist/"); + + let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind failed"); + axum::serve(listener, app).await.expect("server error"); +} + +/// Forward a request to the backend, preserving method, headers, body, and +/// query string. Return the backend's response. +async fn proxy_to_backend( + State(state): State, + mut req: Request, +) -> Response { + // Build the target URL: backend_url + original path + query string. + let path = req.uri().path(); + let query = req.uri().query().map(|q| format!("?{q}")).unwrap_or_default(); + let target_url = format!("{}{}{}", state.backend_url, path, query); + + // Map the axum request into a reqwest request. + let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) + .unwrap_or(reqwest::Method::GET); + + // Collect headers (skip host — reqwest sets its own). + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in req.headers().iter() { + if name == "host" { + continue; + } + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()), + reqwest::header::HeaderValue::from_bytes(value.as_bytes()), + ) { + headers.insert(name, value); + } + } + + // Collect the body. + let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) + .await + .unwrap_or_default(); + + let backend_req = state + .client + .request(method, &target_url) + .headers(headers) + .body(body_bytes); + + let backend_resp = match backend_req.send().await { + Ok(r) => r, + Err(e) => { + eprintln!("Proxy error to {target_url}: {e}"); + return ( + StatusCode::BAD_GATEWAY, + "Failed to reach backend", + ) + .into_response(); + } + }; + + // Map the reqwest response back into an axum response. + let status = StatusCode::from_u16(backend_resp.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + let mut resp_headers = axum::http::HeaderMap::new(); + for (name, value) in backend_resp.headers().iter() { + if let (Ok(n), Ok(v)) = ( + axum::http::HeaderName::from_bytes(name.as_str().as_bytes()), + axum::http::HeaderValue::from_bytes(value.as_bytes()), + ) { + resp_headers.insert(n, v); + } + } + + let body = backend_resp.bytes().await.unwrap_or_default(); + + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + *response.headers_mut() = resp_headers; + response +} diff --git a/web/normogen-web/Dockerfile b/web/normogen-web/Dockerfile new file mode 100644 index 0000000..4b22a88 --- /dev/null +++ b/web/normogen-web/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage Dockerfile for the Normogen frontend server. +# +# Stage 1: Build the Vite SPA bundle. +# Stage 2: Build the Axum frontend-server binary. +# Stage 3: Slim runtime with the binary + static files. + +# ============================================================================ +# Stage 1: Node — build the Vite SPA +# ============================================================================ +FROM node:20-slim AS node-builder + +WORKDIR /app + +# Copy package files and install deps. +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy the source and build. +COPY . . +RUN npm run build + +# ============================================================================ +# Stage 2: Rust — build the frontend-server binary +# ============================================================================ +FROM rust:latest AS rust-builder + +WORKDIR /server + +# Copy the frontend-server crate. +COPY frontend-server/ ./ + +# Build release. +RUN cargo build --release + +# ============================================================================ +# Stage 3: Runtime — slim image with binary + static files +# ============================================================================ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the frontend-server binary. +COPY --from=rust-builder /server/target/release/normogen-frontend-server /app/frontend-server + +# Copy the built static files. +COPY --from=node-builder /app/dist /app/dist + +EXPOSE 8080 + +CMD ["./frontend-server"] From 2a2f14cbdadd742a086fa38dc9684b6d13da2b84 Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 5 Jul 2026 11:29:24 -0300 Subject: [PATCH 2/4] fix: Dockerfile paths for repo-root build context --- backend/docker-compose.yml | 3 ++- web/normogen-web/Dockerfile | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index afaf49b..ab2d838 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -44,7 +44,8 @@ services: frontend: build: - context: ../web/normogen-web + context: .. + dockerfile: web/normogen-web/Dockerfile container_name: normogen-frontend restart: unless-stopped ports: diff --git a/web/normogen-web/Dockerfile b/web/normogen-web/Dockerfile index 4b22a88..d4b56f7 100644 --- a/web/normogen-web/Dockerfile +++ b/web/normogen-web/Dockerfile @@ -12,11 +12,11 @@ FROM node:20-slim AS node-builder WORKDIR /app # Copy package files and install deps. -COPY package.json package-lock.json ./ +COPY web/normogen-web/package.json web/normogen-web/package-lock.json ./ RUN npm ci # Copy the source and build. -COPY . . +COPY web/normogen-web/ ./ RUN npm run build # ============================================================================ @@ -27,7 +27,7 @@ FROM rust:latest AS rust-builder WORKDIR /server # Copy the frontend-server crate. -COPY frontend-server/ ./ +COPY web/frontend-server/ ./ # Build release. RUN cargo build --release From 300072a5d31c83e21120d2cd11b1311b0a5f8079 Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 5 Jul 2026 11:39:41 -0300 Subject: [PATCH 3/4] fix: use Axum 0.7 /*path syntax for API proxy routes --- web/frontend-server/src/main.rs | 34 +++++++++++---------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/web/frontend-server/src/main.rs b/web/frontend-server/src/main.rs index 4a8bf6a..e55427b 100644 --- a/web/frontend-server/src/main.rs +++ b/web/frontend-server/src/main.rs @@ -1,16 +1,13 @@ //! Normogen frontend server — a minimal Axum app that: //! -//! 1. Serves the built Vite SPA bundle from `dist/` (static files). -//! 2. Falls back to `index.html` for any unmatched route (SPA history mode). -//! 3. Reverse-proxies `/api/*` requests to the backend container. -//! -//! Designed to run as a standalone container, independently scalable. -//! No business logic, no DB — just static serving + proxying. +//! 1. Reverse-proxies `/api/*` requests to the backend container. +//! 2. Serves the built Vite SPA bundle from `dist/` (static files). +//! 3. Falls back to `index.html` for any unmatched route (SPA history mode). use axum::{ body::Body, extract::{Request, State}, - http::{HeaderValue, Method, StatusCode, Uri}, + http::StatusCode, response::{IntoResponse, Response}, routing::any, Router, @@ -19,7 +16,6 @@ use std::env; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; -/// Shared state: the backend URL + a reqwest client for proxying. #[derive(Clone)] struct ProxyState { backend_url: String, @@ -48,8 +44,9 @@ async fn main() { let serve_dir = ServeDir::new("dist").fallback(ServeFile::new("dist/index.html")); let app = Router::new() - // Proxy /api/* to the backend. - .route("/api/{*rest}", any(proxy_to_backend)) + // Proxy /api and everything under /api/ to the backend. + .route("/api", any(proxy_to_backend)) + .route("/api/*path", any(proxy_to_backend)) .with_state(state) // Everything else: static files + SPA fallback. .fallback_service(serve_dir) @@ -68,32 +65,28 @@ async fn main() { /// query string. Return the backend's response. async fn proxy_to_backend( State(state): State, - mut req: Request, + req: Request, ) -> Response { - // Build the target URL: backend_url + original path + query string. let path = req.uri().path(); let query = req.uri().query().map(|q| format!("?{q}")).unwrap_or_default(); let target_url = format!("{}{}{}", state.backend_url, path, query); - // Map the axum request into a reqwest request. let method = reqwest::Method::from_bytes(req.method().as_str().as_bytes()) .unwrap_or(reqwest::Method::GET); - // Collect headers (skip host — reqwest sets its own). let mut headers = reqwest::header::HeaderMap::new(); for (name, value) in req.headers().iter() { if name == "host" { continue; } - if let (Ok(name), Ok(value)) = ( + if let (Ok(n), Ok(v)) = ( reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()), reqwest::header::HeaderValue::from_bytes(value.as_bytes()), ) { - headers.insert(name, value); + headers.insert(n, v); } } - // Collect the body. let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) .await .unwrap_or_default(); @@ -108,15 +101,10 @@ async fn proxy_to_backend( Ok(r) => r, Err(e) => { eprintln!("Proxy error to {target_url}: {e}"); - return ( - StatusCode::BAD_GATEWAY, - "Failed to reach backend", - ) - .into_response(); + return (StatusCode::BAD_GATEWAY, "Failed to reach backend").into_response(); } }; - // Map the reqwest response back into an axum response. let status = StatusCode::from_u16(backend_resp.status().as_u16()) .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); From 7143840ea28d5be751fcbeaa3f44ce783c5aa46b Mon Sep 17 00:00:00 2001 From: goose Date: Sun, 5 Jul 2026 11:47:05 -0300 Subject: [PATCH 4/4] fix: add curl to frontend runtime image for healthcheck --- backend/docker-compose.yml | 2 +- web/normogen-web/Dockerfile | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index ab2d838..8b5e777 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -57,7 +57,7 @@ services: backend: condition: service_healthy healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/"] + test: ["CMD", "curl", "-sf", "http://localhost:8080/"] interval: 30s timeout: 10s retries: 3 diff --git a/web/normogen-web/Dockerfile b/web/normogen-web/Dockerfile index d4b56f7..a456606 100644 --- a/web/normogen-web/Dockerfile +++ b/web/normogen-web/Dockerfile @@ -39,6 +39,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app