Skip to content

Commit 6a03ea7

Browse files
authored
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.
1 parent e5105a6 commit 6a03ea7

File tree

8 files changed

+95
-16
lines changed

8 files changed

+95
-16
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ coverage.xml
1515
coverage.json
1616

1717
backend/static
18+
backend/frontend-dist
1819

1920
/process-compose.yml

Dockerfile

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Build static frontend files
2+
FROM node:24-alpine AS builder
3+
4+
WORKDIR /app
5+
6+
ENV NODE_ENV=production
7+
8+
COPY frontend .
9+
10+
RUN corepack enable && CI=true pnpm install && pnpm build
11+
12+
# Build backend image that also serves frontend (stored in `/app/frontend-dist`)
13+
FROM python:3.14-alpine3.22
14+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
15+
16+
RUN rm -rf /var/cache/apk/*
17+
18+
COPY backend /app
19+
WORKDIR /app
20+
21+
# -- Install dependencies:
22+
RUN addgroup --system bracket && \
23+
adduser --system bracket --ingroup bracket && \
24+
chown -R bracket:bracket /app
25+
USER bracket
26+
27+
RUN uv sync --no-dev --locked
28+
29+
COPY --from=builder /app/dist /app/frontend-dist
30+
31+
EXPOSE 8400
32+
33+
HEALTHCHECK --interval=3s --timeout=5s --retries=10 \
34+
CMD ["wget", "-O", "/dev/null", "http://0.0.0.0:8400/ping"]
35+
36+
CMD [ \
37+
"uv", \
38+
"run", \
39+
"--no-dev", \
40+
"--locked", \
41+
"--", \
42+
"gunicorn", \
43+
"-k", \
44+
"uvicorn.workers.UvicornWorker", \
45+
"bracket.app:app", \
46+
"--bind", \
47+
"0.0.0.0:8400", \
48+
"--workers", \
49+
"1" \
50+
]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ be able to view bracket at http://localhost:3000. You can log in with the follow
9090

9191
To insert dummy rows into the database, run:
9292
```bash
93-
sudo docker exec bracket-backend uv run ./cli.py create-dev-db
93+
docker exec bracket-backend uv run --no-dev ./cli.py create-dev-db
9494
```
9595

9696
See also the [quickstart docs](https://docs.bracketapp.nl/docs/running-bracket/quickstart).

backend/Dockerfile

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
1-
FROM python:3.12-alpine3.17
2-
ARG packages
3-
RUN apk --update add ${packages} \
4-
&& rm -rf /var/cache/apk/* \
5-
&& pip3 install --upgrade pip uv wheel virtualenv
1+
FROM python:3.14-alpine3.22
2+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
3+
4+
RUN rm -rf /var/cache/apk/*
65

76
COPY . /app
87
WORKDIR /app
98

109
# -- Install dependencies:
11-
RUN addgroup --system bracket && adduser --system bracket --ingroup bracket \
12-
&& chown -R bracket:bracket /app
10+
RUN addgroup --system bracket && \
11+
adduser --system bracket --ingroup bracket && \
12+
chown -R bracket:bracket /app
1313
USER bracket
1414

15-
RUN set -ex \
16-
&& pip3 install --upgrade pip uv wheel virtualenv \
17-
&& uv sync --no-dev
15+
RUN uv sync --no-dev --locked
1816

1917
EXPOSE 8400
2018

@@ -24,9 +22,15 @@ HEALTHCHECK --interval=3s --timeout=5s --retries=10 \
2422
CMD [ \
2523
"uv", \
2624
"run", \
25+
"--no-dev", \
26+
"--locked", \
27+
"--", \
2728
"gunicorn", \
28-
"-k", "uvicorn.workers.UvicornWorker", \
29+
"-k", \
30+
"uvicorn.workers.UvicornWorker", \
2931
"bracket.app:app", \
30-
"--bind", "0.0.0.0:8400", \
31-
"--workers", "1" \
32+
"--bind", \
33+
"0.0.0.0:8400", \
34+
"--workers", \
35+
"1" \
3236
]

backend/bracket/app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import glob
12
import time
23
from collections.abc import AsyncIterator
34
from contextlib import asynccontextmanager
5+
from pathlib import Path
46

57
from fastapi import FastAPI, Request
8+
from fastapi.responses import FileResponse
69
from starlette.exceptions import HTTPException
710
from starlette.middleware.base import RequestResponseEndpoint
811
from starlette.middleware.cors import CORSMiddleware
@@ -153,3 +156,23 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes
153156

154157
for tag, router in routers.items():
155158
app.include_router(router, tags=[tag])
159+
160+
if config.serve_frontend:
161+
frontend_root = Path("frontend-dist").resolve()
162+
allowed_paths = list(glob.iglob("frontend-dist/**/*", recursive=True))
163+
164+
@app.get("/{full_path:path}")
165+
async def frontend(full_path: str) -> FileResponse:
166+
path = (frontend_root / Path(full_path)).resolve()
167+
168+
# Checking `str(path) in allowed_paths` should be enough here but we check for more cases
169+
# to be sure and avoid AI tools raising false positives.
170+
if (
171+
path.exists()
172+
and path.is_file()
173+
and str(path) in allowed_paths
174+
and frontend_root in path.parents
175+
):
176+
return FileResponse(path)
177+
178+
return FileResponse(frontend_root / Path("index.html"))

backend/bracket/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Config(BaseSettings):
4040
auto_run_migrations: bool = True
4141
pg_dsn: PostgresDsn = PostgresDsn("postgresql://user:pass@localhost:5432/db")
4242
sentry_dsn: str | None = None
43+
serve_frontend: bool = False
4344

4445
def is_cors_enabled(self) -> bool:
4546
return self.cors_origins != "*"

frontend/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Build static files
2-
FROM node:22-alpine AS builder
2+
FROM node:24-alpine AS builder
33

44
WORKDIR /app
55

frontend/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
5-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
5+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Bracket</title>
88
<meta charSet="UTF-8" />

0 commit comments

Comments
 (0)