♻️ Serve frontend from FastAPI (#2393)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sebastián Ramírez
2026-07-30 21:41:08 +02:00
committed by GitHub
parent 588dfd46e5
commit e80d92452e
30 changed files with 132 additions and 176 deletions
-26
View File
@@ -1,26 +0,0 @@
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
FROM oven/bun:1 AS build-stage
WORKDIR /app
COPY package.json bun.lock /app/
COPY frontend/package.json /app/frontend/
WORKDIR /app/frontend
RUN bun install
COPY ./frontend /app/frontend
ARG VITE_API_URL
RUN bun run build
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
FROM nginx:1
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY ./frontend/nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
-2
View File
@@ -17,5 +17,3 @@ WORKDIR /app/frontend
RUN bun install
COPY ./frontend /app/frontend
ARG VITE_API_URL
+8 -7
View File
@@ -15,7 +15,7 @@ bun run dev
* Then open your browser at http://localhost:5173/.
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload.
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the backend Docker image and start it, to test the production-like setup where FastAPI serves the built frontend at `http://localhost:8000`.
Check the file `package.json` to see other available options.
@@ -25,18 +25,19 @@ If you are developing an API-only app and want to remove the frontend, you can d
* Remove the `./frontend` directory.
* In the `compose.yml` file, remove the whole service / section `frontend`.
* In the `backend/app/main.py` file, remove the `app.frontend()` call.
* In the `compose.override.yml` file, remove the whole service / section `frontend` and `playwright`.
* In the `backend/Dockerfile` file, remove the frontend build stage and the `COPY --from=frontend-build` instruction.
* In the `compose.override.yml` file, remove the `playwright` service.
Done, you have a frontend-less (api-only) app. 🤓
---
If you want, you can also remove the `FRONTEND` environment variables from:
If you want, you can also remove the `FRONTEND_HOST` environment variable from:
* `.env`
* `./scripts/*.sh`
But it would be only to clean them up, leaving them won't really have any effect either way.
@@ -71,10 +72,10 @@ Notice that everytime the backend changes (changing the OpenAPI schema), you sho
## Using a Remote API
If you want to use a remote API, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
By default, the built frontend uses the same origin as the FastAPI app. If you want to use a remote API while running the Vite development server, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
```env
VITE_API_URL=https://api.my-domain.example.com
VITE_API_URL=https://my-domain.example.com
```
Then, when you run the frontend, it will use that URL as the base URL for the API.
-9
View File
@@ -1,9 +0,0 @@
location /api {
return 404;
}
location /docs {
return 404;
}
location /redoc {
return 404;
}
-11
View File
@@ -1,11 +0,0 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri /index.html =404;
}
include /etc/nginx/extra-conf.d/*.conf;
}
+10 -6
View File
@@ -1,6 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config'
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -24,7 +26,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@@ -83,9 +85,11 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'bun run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: 'bun run dev',
url: baseURL,
reuseExistingServer: !process.env.CI,
},
});
+1 -1
View File
@@ -13,7 +13,7 @@ import { Toaster } from "./components/ui/sonner"
import "./index.css"
import { routeTree } from "./routeTree.gen"
OpenAPI.BASE = import.meta.env.VITE_API_URL
OpenAPI.BASE = import.meta.env.VITE_API_URL ?? ""
OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || ""
}
+1 -1
View File
@@ -23,7 +23,7 @@ import { PasswordInput } from "@/components/ui/password-input"
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
const formSchema = z.object({
username: z.email(),
username: z.email({ message: "Invalid email address" }),
password: z
.string()
.min(1, { message: "Password is required" })
+1 -1
View File
@@ -25,7 +25,7 @@ import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
email: z.email(),
email: z.email({ message: "Invalid email address" }),
})
type FormData = z.infer<typeof formSchema>
+1 -1
View File
@@ -22,7 +22,7 @@ import useAuth, { isLoggedIn } from "@/hooks/useAuth"
const formSchema = z
.object({
email: z.email(),
email: z.email({ message: "Invalid email address" }),
full_name: z.string().min(1, { message: "Full Name is required" }),
password: z
.string()
+1 -1
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_API_URL?: string
}
interface ImportMeta {
+6 -8
View File
@@ -56,13 +56,11 @@ test("User can reset password successfully using the link", async ({
const selector = 'a[href*="/reset-password?token="]'
let url = await page.getAttribute(selector, "href")
// TODO: update var instead of doing a replace
url = url!.replace("http://localhost/", "http://localhost:5173/")
const url = await page.getAttribute(selector, "href")
const resetUrl = new URL(url!)
// Set the new password and confirm it
await page.goto(url)
await page.goto(`${resetUrl.pathname}${resetUrl.search}`)
await page.getByTestId("new-password-input").fill(newPassword)
await page.getByTestId("confirm-password-input").fill(newPassword)
@@ -110,11 +108,11 @@ test("Weak new password validation", async ({ page, request }) => {
)
const selector = 'a[href*="/reset-password?token="]'
let url = await page.getAttribute(selector, "href")
url = url!.replace("http://localhost/", "http://localhost:5173/")
const url = await page.getAttribute(selector, "href")
const resetUrl = new URL(url!)
// Set a weak new password
await page.goto(url)
await page.goto(`${resetUrl.pathname}${resetUrl.search}`)
await page.getByTestId("new-password-input").fill(weakPassword)
await page.getByTestId("confirm-password-input").fill(weakPassword)
await page.getByRole("button", { name: "Reset Password" }).click()
+4
View File
@@ -6,6 +6,10 @@ import { defineConfig } from "vite"
// https://vitejs.dev/config/
export default defineConfig({
build: {
outDir: "../backend/app/frontend",
emptyOutDir: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),