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.
This commit is contained in:
Erik Vroon
2025-12-29 19:35:40 +01:00
committed by GitHub
parent e5105a614c
commit 6a03ea7294
8 changed files with 95 additions and 16 deletions

1
.gitignore vendored
View File

@@ -15,5 +15,6 @@ coverage.xml
coverage.json
backend/static
backend/frontend-dist
/process-compose.yml

50
Dockerfile Normal file
View File

@@ -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" \
]

View File

@@ -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).

View File

@@ -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" \
]

View File

@@ -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"))

View File

@@ -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 != "*"

View File

@@ -1,5 +1,5 @@
# Build static files
FROM node:22-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bracket</title>
<meta charSet="UTF-8" />