Add docker + Space deployment
Browse files- Dockerfile +70 -0
- README.md +70 -0
- backend/Makefile +4 -1
- backend/src/app.py +16 -0
- backend/src/constants.py +20 -0
- frontend/package.json +1 -0
- frontend/pnpm-lock.yaml +0 -0
- frontend/src/App.tsx +2 -2
- frontend/{public → src/assets}/vite.svg +0 -0
- frontend/src/components/BackendHealthCheck.tsx +74 -3
Dockerfile
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# =======================
|
2 |
+
# 1️⃣ Frontend build stage
|
3 |
+
# =======================
|
4 |
+
FROM node:22-slim AS frontend-builder
|
5 |
+
|
6 |
+
# Install pnpm globally
|
7 |
+
RUN corepack enable && corepack prepare pnpm@latest --activate
|
8 |
+
|
9 |
+
# Set working directory
|
10 |
+
WORKDIR /app/frontend
|
11 |
+
|
12 |
+
# Copy package files first for caching
|
13 |
+
COPY frontend/pnpm-lock.yaml frontend/package.json ./
|
14 |
+
|
15 |
+
# Install dependencies (prod only for frontend)
|
16 |
+
RUN pnpm install --frozen-lockfile
|
17 |
+
|
18 |
+
# Copy the rest of the frontend source
|
19 |
+
COPY frontend/ ./
|
20 |
+
|
21 |
+
# Build frontend
|
22 |
+
ENV VITE_APP_ENV=production
|
23 |
+
RUN pnpm build
|
24 |
+
|
25 |
+
|
26 |
+
# =======================
|
27 |
+
# 2️⃣ Backend build stage
|
28 |
+
# =======================
|
29 |
+
FROM python:3.12-slim AS backend-builder
|
30 |
+
|
31 |
+
# Install uv (fast Python package installer)
|
32 |
+
RUN pip install --no-cache-dir uv
|
33 |
+
|
34 |
+
# Set working directory
|
35 |
+
WORKDIR /app
|
36 |
+
|
37 |
+
# Copy backend requirements and install (no dev deps)
|
38 |
+
COPY backend/pyproject.toml backend/uv.lock ./backend/
|
39 |
+
RUN cd backend && uv pip install --no-cache-dir --system .
|
40 |
+
|
41 |
+
# Copy backend source
|
42 |
+
COPY backend/ ./backend/
|
43 |
+
|
44 |
+
# Copy built frontend from stage 1
|
45 |
+
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
46 |
+
|
47 |
+
# =======================
|
48 |
+
# 3️⃣ Production runtime
|
49 |
+
# =======================
|
50 |
+
FROM python:3.12-slim
|
51 |
+
|
52 |
+
# Create non-root user
|
53 |
+
RUN useradd -m appuser
|
54 |
+
WORKDIR /app
|
55 |
+
|
56 |
+
# Copy installed packages and app
|
57 |
+
COPY --from=backend-builder /usr/local /usr/local
|
58 |
+
COPY --from=backend-builder /app /app
|
59 |
+
|
60 |
+
# Set frontend path for FastAPI
|
61 |
+
ENV FRONTEND_PATH=/app/frontend/dist
|
62 |
+
|
63 |
+
# Switch to non-root user
|
64 |
+
USER appuser
|
65 |
+
|
66 |
+
# Expose port (adjust if needed)
|
67 |
+
EXPOSE 8000
|
68 |
+
|
69 |
+
# Run FastAPI via uvicorn
|
70 |
+
CMD ["uvicorn", "backend.src.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: FastAPI + React Template
|
3 |
+
emoji: 💻
|
4 |
+
colorFrom: blue
|
5 |
+
colorTo: green
|
6 |
+
sdk: docker
|
7 |
+
app_port: 8000
|
8 |
+
---
|
9 |
+
|
10 |
+
# docker-space-fastapi-react
|
11 |
+
|
12 |
+
This repo is a template to run a FastAPI server hosting a React+Tailwind app in a Hugging Face Space.
|
13 |
+
|
14 |
+
Spaces are very well suited to vibe-code demos using Cursor, Lovable, Claude Code, etc. The hardest past is often to correctly configure the tooltip to make it work in the first place. This repo is intended to solve that.
|
15 |
+
|
16 |
+
## Getting started
|
17 |
+
|
18 |
+
The backend (Python + FastAPI) and the frontend (TS + React + Tailwind) are located respectively in the `backend/` and `frontend/` folder.
|
19 |
+
|
20 |
+
### Backend
|
21 |
+
|
22 |
+
To run the backend, you'll need `uv` and Python3.12 installed.
|
23 |
+
Install dependencies with:
|
24 |
+
|
25 |
+
```bash
|
26 |
+
uv venv
|
27 |
+
make install
|
28 |
+
```
|
29 |
+
|
30 |
+
And then run the backend server with:
|
31 |
+
|
32 |
+
```bash
|
33 |
+
make dev
|
34 |
+
```
|
35 |
+
|
36 |
+
For more details about backend development, check out [this README](./backend/README.md).
|
37 |
+
|
38 |
+
### Frontend
|
39 |
+
|
40 |
+
To run the frontend, you'll need nodeJS + pnpm installed.
|
41 |
+
|
42 |
+
Install dependencies with:
|
43 |
+
|
44 |
+
```bash
|
45 |
+
pnpm install
|
46 |
+
```
|
47 |
+
|
48 |
+
And then run the frontend app with:
|
49 |
+
|
50 |
+
```bash
|
51 |
+
pnpm dev
|
52 |
+
```
|
53 |
+
|
54 |
+
Once both parts are running, go to the frontend URL. You should see a green "Backend Online" message in the top-right corner. Note that this message is only visible in development mode. When running in a Space, it won't be shown to your end users.
|
55 |
+
|
56 |
+
For more details about frontend development, check out [this README](./frontend/README.md).
|
57 |
+
|
58 |
+
## Deploy
|
59 |
+
|
60 |
+
This repo is meant to be run in a [Docker Space](https://huggingface.co/docs/hub/spaces-sdks-docker).
|
61 |
+
Configuration should be automatically done if you clone the on the Hugging Face Hub.
|
62 |
+
|
63 |
+
If you want to run it on your own, you can use Docker:
|
64 |
+
|
65 |
+
```bash
|
66 |
+
docker build -t fastapi-react-space .
|
67 |
+
docker run -p 8000:8000 fastapi-react-space
|
68 |
+
```
|
69 |
+
|
70 |
+
Note that when running in Docker, the app runs in production mode without hot-reloading.
|
backend/Makefile
CHANGED
@@ -10,4 +10,7 @@ quality:
|
|
10 |
|
11 |
style:
|
12 |
ruff format src
|
13 |
-
ruff check --fix src
|
|
|
|
|
|
|
|
10 |
|
11 |
style:
|
12 |
ruff format src
|
13 |
+
ruff check --fix src
|
14 |
+
|
15 |
+
preview:
|
16 |
+
FRONTEND_PATH=../frontend/dist/ uv run uvicorn src.app:app
|
backend/src/app.py
CHANGED
@@ -1,5 +1,9 @@
|
|
1 |
from fastapi import FastAPI
|
2 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
|
|
|
3 |
|
4 |
app = FastAPI()
|
5 |
|
@@ -12,6 +16,18 @@ app.add_middleware(
|
|
12 |
allow_headers=["*"],
|
13 |
)
|
14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
@app.get("/api/health")
|
17 |
async def health():
|
|
|
1 |
from fastapi import FastAPI
|
2 |
from fastapi.middleware.cors import CORSMiddleware
|
3 |
+
from fastapi.responses import FileResponse
|
4 |
+
from fastapi.staticfiles import StaticFiles
|
5 |
+
|
6 |
+
from . import constants
|
7 |
|
8 |
app = FastAPI()
|
9 |
|
|
|
16 |
allow_headers=["*"],
|
17 |
)
|
18 |
|
19 |
+
# Mount static files from dist directory (if configured)
|
20 |
+
if constants.SERVE_FRONTEND:
|
21 |
+
app.mount(
|
22 |
+
"/assets",
|
23 |
+
StaticFiles(directory=constants.FRONTEND_ASSETS_PATH),
|
24 |
+
name="assets",
|
25 |
+
)
|
26 |
+
|
27 |
+
@app.get("/")
|
28 |
+
async def serve_frontend():
|
29 |
+
return FileResponse(constants.FRONTEND_INDEX_PATH)
|
30 |
+
|
31 |
|
32 |
@app.get("/api/health")
|
33 |
async def health():
|
backend/src/constants.py
ADDED
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
|
3 |
+
FRONTEND_PATH = os.getenv("FRONTEND_PATH")
|
4 |
+
SERVE_FRONTEND = os.getenv("FRONTEND_PATH") is not None
|
5 |
+
|
6 |
+
FRONTEND_ASSETS_PATH = os.path.join(FRONTEND_PATH, "assets") if SERVE_FRONTEND else None
|
7 |
+
FRONTEND_INDEX_PATH = (
|
8 |
+
os.path.join(FRONTEND_PATH, "index.html") if SERVE_FRONTEND else None
|
9 |
+
)
|
10 |
+
|
11 |
+
|
12 |
+
if SERVE_FRONTEND and (
|
13 |
+
not os.path.exists(FRONTEND_PATH)
|
14 |
+
or not os.path.exists(FRONTEND_ASSETS_PATH)
|
15 |
+
or not os.path.exists(FRONTEND_INDEX_PATH)
|
16 |
+
):
|
17 |
+
raise FileNotFoundError(
|
18 |
+
f"FRONTEND_PATH {FRONTEND_PATH} has not been built correctly. Please build the frontend first by running `pnpm build` from the 'frontend/' directory."
|
19 |
+
" If you want to run the server in development mode, run `make dev` from the 'backend/' directory and `pnpm dev` from the 'frontend/' directory."
|
20 |
+
)
|
frontend/package.json
CHANGED
@@ -3,6 +3,7 @@
|
|
3 |
"private": true,
|
4 |
"version": "0.0.0",
|
5 |
"type": "module",
|
|
|
6 |
"scripts": {
|
7 |
"dev": "vite",
|
8 |
"build": "tsc -b && vite build",
|
|
|
3 |
"private": true,
|
4 |
"version": "0.0.0",
|
5 |
"type": "module",
|
6 |
+
"packageManager": "[email protected]",
|
7 |
"scripts": {
|
8 |
"dev": "vite",
|
9 |
"build": "tsc -b && vite build",
|
frontend/pnpm-lock.yaml
CHANGED
The diff for this file is too large to render.
See raw diff
|
|
frontend/src/App.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
import { useState } from "react";
|
2 |
import reactLogo from "./assets/react.svg";
|
3 |
-
import viteLogo from "/vite.svg";
|
4 |
import { BackendHealthCheck } from "./components/BackendHealthCheck";
|
5 |
|
6 |
function App() {
|
@@ -9,7 +9,7 @@ function App() {
|
|
9 |
return (
|
10 |
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
11 |
<div className="max-w-4xl mx-auto p-8 text-center">
|
12 |
-
{/* Backend Status Indicator */}
|
13 |
<BackendHealthCheck />
|
14 |
|
15 |
<div className="flex justify-center space-x-8 mb-8">
|
|
|
1 |
import { useState } from "react";
|
2 |
import reactLogo from "./assets/react.svg";
|
3 |
+
import viteLogo from "./assets/vite.svg";
|
4 |
import { BackendHealthCheck } from "./components/BackendHealthCheck";
|
5 |
|
6 |
function App() {
|
|
|
9 |
return (
|
10 |
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
11 |
<div className="max-w-4xl mx-auto p-8 text-center">
|
12 |
+
{/* Backend Status Indicator - Only shown in development mode */}
|
13 |
<BackendHealthCheck />
|
14 |
|
15 |
<div className="flex justify-center space-x-8 mb-8">
|
frontend/{public → src/assets}/vite.svg
RENAMED
File without changes
|
frontend/src/components/BackendHealthCheck.tsx
CHANGED
@@ -9,7 +9,14 @@ interface BackendHealthCheckProps {
|
|
9 |
export function BackendHealthCheck({
|
10 |
checkInterval = APP_CONFIG.HEALTH_CHECK_INTERVAL
|
11 |
}: BackendHealthCheckProps) {
|
|
|
|
|
|
|
|
|
|
|
12 |
const [backendStatus, setBackendStatus] = useState<'loading' | 'online' | 'offline'>('loading');
|
|
|
|
|
13 |
const healthCheckUrl = `${BACKEND_URL}/api/health`;
|
14 |
|
15 |
useEffect(() => {
|
@@ -39,6 +46,17 @@ export function BackendHealthCheck({
|
|
39 |
return () => clearInterval(interval);
|
40 |
}, [healthCheckUrl, checkInterval]);
|
41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
42 |
const getStatusColor = () => {
|
43 |
switch (backendStatus) {
|
44 |
case 'online':
|
@@ -66,9 +84,62 @@ export function BackendHealthCheck({
|
|
66 |
};
|
67 |
|
68 |
return (
|
69 |
-
<div className="absolute top-4 right-4
|
70 |
-
<div
|
71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
</div>
|
73 |
);
|
74 |
}
|
|
|
9 |
export function BackendHealthCheck({
|
10 |
checkInterval = APP_CONFIG.HEALTH_CHECK_INTERVAL
|
11 |
}: BackendHealthCheckProps) {
|
12 |
+
// Only render in development mode
|
13 |
+
if (import.meta.env.PROD) {
|
14 |
+
return null;
|
15 |
+
}
|
16 |
+
|
17 |
const [backendStatus, setBackendStatus] = useState<'loading' | 'online' | 'offline'>('loading');
|
18 |
+
const [showTooltip, setShowTooltip] = useState(false);
|
19 |
+
const [copied, setCopied] = useState(false);
|
20 |
const healthCheckUrl = `${BACKEND_URL}/api/health`;
|
21 |
|
22 |
useEffect(() => {
|
|
|
46 |
return () => clearInterval(interval);
|
47 |
}, [healthCheckUrl, checkInterval]);
|
48 |
|
49 |
+
const handleCopyCode = async () => {
|
50 |
+
const codeText = `cd backend/\nmake install\nmake backend`;
|
51 |
+
try {
|
52 |
+
await navigator.clipboard.writeText(codeText);
|
53 |
+
setCopied(true);
|
54 |
+
setTimeout(() => setCopied(false), 2000);
|
55 |
+
} catch (err) {
|
56 |
+
console.error('Failed to copy text: ', err);
|
57 |
+
}
|
58 |
+
};
|
59 |
+
|
60 |
const getStatusColor = () => {
|
61 |
switch (backendStatus) {
|
62 |
case 'online':
|
|
|
84 |
};
|
85 |
|
86 |
return (
|
87 |
+
<div className="absolute top-4 right-4">
|
88 |
+
<div
|
89 |
+
className="relative flex items-center space-x-2 bg-gray-800 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-700 transition-colors"
|
90 |
+
onMouseEnter={() => backendStatus === 'offline' && setShowTooltip(true)}
|
91 |
+
onMouseLeave={() => setShowTooltip(false)}
|
92 |
+
>
|
93 |
+
<div className={`w-3 h-3 rounded-full ${getStatusColor()} animate-pulse`}></div>
|
94 |
+
<span className="text-sm font-medium">{getStatusText()}</span>
|
95 |
+
|
96 |
+
{/* Info icon when backend is offline */}
|
97 |
+
{backendStatus === 'offline' && (
|
98 |
+
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
99 |
+
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
100 |
+
</svg>
|
101 |
+
)}
|
102 |
+
|
103 |
+
{/* Tooltip for offline backend */}
|
104 |
+
{showTooltip && backendStatus === 'offline' && (
|
105 |
+
<div
|
106 |
+
className="absolute top-full right-0 mt-2 w-fit bg-gray-900 text-white rounded-lg p-4 shadow-xl border border-gray-700 z-50"
|
107 |
+
onMouseEnter={() => setShowTooltip(true)}
|
108 |
+
onMouseLeave={() => setShowTooltip(false)}
|
109 |
+
>
|
110 |
+
<div className="flex items-start space-x-3">
|
111 |
+
<div className="flex-1 min-w-0">
|
112 |
+
<h3 className="text-sm font-semibold text-400 mb-3 text-left">Start the server with:</h3>
|
113 |
+
<div className="relative">
|
114 |
+
<code className="text-xs bg-gray-800 text-green-400 px-3 py-2 rounded font-mono block w-fit overflow-x-auto text-left pr-12">
|
115 |
+
cd backend/<br/>
|
116 |
+
make install<br/>
|
117 |
+
make backend
|
118 |
+
</code>
|
119 |
+
<button
|
120 |
+
onClick={handleCopyCode}
|
121 |
+
className="absolute top-1 right-1 p-1 bg-gray-700 hover:bg-gray-600 rounded text-gray-300 hover:text-white transition-colors"
|
122 |
+
title="Copy to clipboard"
|
123 |
+
>
|
124 |
+
{copied ? (
|
125 |
+
<svg className="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
126 |
+
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
127 |
+
</svg>
|
128 |
+
) : (
|
129 |
+
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
130 |
+
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
131 |
+
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
132 |
+
</svg>
|
133 |
+
)}
|
134 |
+
</button>
|
135 |
+
</div>
|
136 |
+
</div>
|
137 |
+
</div>
|
138 |
+
{/* Arrow pointing up */}
|
139 |
+
<div className="absolute bottom-full right-4 w-0 h-0 border-l-4 border-r-4 border-b-4 border-transparent border-b-gray-900"></div>
|
140 |
+
</div>
|
141 |
+
)}
|
142 |
+
</div>
|
143 |
</div>
|
144 |
);
|
145 |
}
|