From 6a03ea7294938d122055b8bef32ef44a8fd9c663 Mon Sep 17 00:00:00 2001 From: Erik Vroon <11857441+evroon@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:35:40 +0100 Subject: [PATCH] Allow serving frontend via backend (#1492) This should make it easier to run Bracket in development/simple production environments because you only need to run 1 Docker container. Also, it avoids CORS problems because the frontend and backend run on the same domain. --- .gitignore | 1 + Dockerfile | 50 +++++++++++++++++++++++++++++++++++++++ README.md | 2 +- backend/Dockerfile | 30 +++++++++++++---------- backend/bracket/app.py | 23 ++++++++++++++++++ backend/bracket/config.py | 1 + frontend/Dockerfile | 2 +- frontend/index.html | 2 +- 8 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 Dockerfile diff --git a/.gitignore b/.gitignore index e051fd9f..afc84472 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ coverage.xml coverage.json backend/static +backend/frontend-dist /process-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..4f7710d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Build static frontend files +FROM node:24-alpine AS builder + +WORKDIR /app + +ENV NODE_ENV=production + +COPY frontend . + +RUN corepack enable && CI=true pnpm install && pnpm build + +# Build backend image that also serves frontend (stored in `/app/frontend-dist`) +FROM python:3.14-alpine3.22 +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +RUN rm -rf /var/cache/apk/* + +COPY backend /app +WORKDIR /app + +# -- Install dependencies: +RUN addgroup --system bracket && \ + adduser --system bracket --ingroup bracket && \ + chown -R bracket:bracket /app +USER bracket + +RUN uv sync --no-dev --locked + +COPY --from=builder /app/dist /app/frontend-dist + +EXPOSE 8400 + +HEALTHCHECK --interval=3s --timeout=5s --retries=10 \ + CMD ["wget", "-O", "/dev/null", "http://0.0.0.0:8400/ping"] + +CMD [ \ + "uv", \ + "run", \ + "--no-dev", \ + "--locked", \ + "--", \ + "gunicorn", \ + "-k", \ + "uvicorn.workers.UvicornWorker", \ + "bracket.app:app", \ + "--bind", \ + "0.0.0.0:8400", \ + "--workers", \ + "1" \ +] diff --git a/README.md b/README.md index 2a0e0076..2ed1d8c2 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ be able to view bracket at http://localhost:3000. You can log in with the follow To insert dummy rows into the database, run: ```bash -sudo docker exec bracket-backend uv run ./cli.py create-dev-db +docker exec bracket-backend uv run --no-dev ./cli.py create-dev-db ``` See also the [quickstart docs](https://docs.bracketapp.nl/docs/running-bracket/quickstart). diff --git a/backend/Dockerfile b/backend/Dockerfile index e0372b14..35dbc397 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,20 +1,18 @@ -FROM python:3.12-alpine3.17 -ARG packages -RUN apk --update add ${packages} \ - && rm -rf /var/cache/apk/* \ - && pip3 install --upgrade pip uv wheel virtualenv +FROM python:3.14-alpine3.22 +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +RUN rm -rf /var/cache/apk/* COPY . /app WORKDIR /app # -- Install dependencies: -RUN addgroup --system bracket && adduser --system bracket --ingroup bracket \ - && chown -R bracket:bracket /app +RUN addgroup --system bracket && \ + adduser --system bracket --ingroup bracket && \ + chown -R bracket:bracket /app USER bracket -RUN set -ex \ - && pip3 install --upgrade pip uv wheel virtualenv \ - && uv sync --no-dev +RUN uv sync --no-dev --locked EXPOSE 8400 @@ -24,9 +22,15 @@ HEALTHCHECK --interval=3s --timeout=5s --retries=10 \ CMD [ \ "uv", \ "run", \ + "--no-dev", \ + "--locked", \ + "--", \ "gunicorn", \ - "-k", "uvicorn.workers.UvicornWorker", \ + "-k", \ + "uvicorn.workers.UvicornWorker", \ "bracket.app:app", \ - "--bind", "0.0.0.0:8400", \ - "--workers", "1" \ + "--bind", \ + "0.0.0.0:8400", \ + "--workers", \ + "1" \ ] diff --git a/backend/bracket/app.py b/backend/bracket/app.py index 3180a245..c10a65e3 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -1,8 +1,11 @@ +import glob import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, Request +from fastapi.responses import FileResponse from starlette.exceptions import HTTPException from starlette.middleware.base import RequestResponseEndpoint from starlette.middleware.cors import CORSMiddleware @@ -153,3 +156,23 @@ app.mount("/static", StaticFiles(directory="static"), name="static") for tag, router in routers.items(): app.include_router(router, tags=[tag]) + +if config.serve_frontend: + frontend_root = Path("frontend-dist").resolve() + allowed_paths = list(glob.iglob("frontend-dist/**/*", recursive=True)) + + @app.get("/{full_path:path}") + async def frontend(full_path: str) -> FileResponse: + path = (frontend_root / Path(full_path)).resolve() + + # Checking `str(path) in allowed_paths` should be enough here but we check for more cases + # to be sure and avoid AI tools raising false positives. + if ( + path.exists() + and path.is_file() + and str(path) in allowed_paths + and frontend_root in path.parents + ): + return FileResponse(path) + + return FileResponse(frontend_root / Path("index.html")) diff --git a/backend/bracket/config.py b/backend/bracket/config.py index a0f54fdd..e4052f44 100644 --- a/backend/bracket/config.py +++ b/backend/bracket/config.py @@ -40,6 +40,7 @@ class Config(BaseSettings): auto_run_migrations: bool = True pg_dsn: PostgresDsn = PostgresDsn("postgresql://user:pass@localhost:5432/db") sentry_dsn: str | None = None + serve_frontend: bool = False def is_cors_enabled(self) -> bool: return self.cors_origins != "*" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8819ea8d..20fecca8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # Build static files -FROM node:22-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app diff --git a/frontend/index.html b/frontend/index.html index f34c8606..b60e26c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@
- +