diff --git a/backend/app.py b/backend/app.py
index fb6dc89c..f652d149 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -77,6 +77,7 @@ def create_app() -> FastAPI:
_configure_cors(application)
register_routers(application)
_register_lifecycle(application)
+ _mount_frontend(application)
return application
@@ -104,6 +105,43 @@ def _configure_cors(application: FastAPI) -> None:
)
+def _mount_frontend(application: FastAPI) -> None:
+ """Serve the built web frontend when present (Docker / web deployment).
+
+ The Dockerfile copies the Vite build output to ``/app/frontend/``. When
+ that directory exists we mount static assets and add a catch-all route so
+ the React SPA handles client-side routing. In dev or API-only mode the
+ directory is absent and this function is a no-op.
+ """
+ frontend_dir = Path(__file__).resolve().parent.parent / "frontend"
+ if not frontend_dir.is_dir():
+ return
+
+ from fastapi.staticfiles import StaticFiles
+ from fastapi.responses import FileResponse
+
+ # Mount hashed assets (JS, CSS, images) that Vite places under /assets
+ assets_dir = frontend_dir / "assets"
+ if assets_dir.is_dir():
+ application.mount(
+ "/assets",
+ StaticFiles(directory=str(assets_dir)),
+ name="frontend-assets",
+ )
+
+ # SPA catch-all: serve files if they exist, otherwise index.html for
+ # client-side routes like /voices, /stories, /models, etc.
+ @application.get("/{full_path:path}")
+ async def serve_spa(full_path: str):
+ file_path = (frontend_dir / full_path).resolve()
+ # Guard against path traversal — only serve files inside frontend_dir
+ if full_path and file_path.is_file() and str(file_path).startswith(str(frontend_dir)):
+ return FileResponse(file_path)
+ return FileResponse(frontend_dir / "index.html", media_type="text/html")
+
+ logger.info("Frontend: serving SPA from %s", frontend_dir)
+
+
def _get_gpu_status() -> str:
"""Return a human-readable string describing GPU availability."""
backend_type = get_backend_type()
diff --git a/backend/routes/health.py b/backend/routes/health.py
index e48d5689..0053f423 100644
--- a/backend/routes/health.py
+++ b/backend/routes/health.py
@@ -3,9 +3,11 @@
import asyncio
import os
import signal
+from pathlib import Path
import torch
from fastapi import APIRouter, Depends
+from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from .. import config, models
@@ -15,12 +17,18 @@ from ..utils.platform_detect import get_backend_type
router = APIRouter()
+# Frontend build directory — present in Docker, absent in dev/API-only mode
+_frontend_dir = Path(__file__).resolve().parent.parent.parent / "frontend"
+
@router.get("/")
async def root():
- """Root endpoint."""
+ """Root endpoint — serves SPA index.html in Docker, JSON otherwise."""
from .. import __version__
+ index = _frontend_dir / "index.html"
+ if index.is_file():
+ return FileResponse(index, media_type="text/html")
return {"message": "voicebox API", "version": __version__}
diff --git a/docs/README.md b/docs/README.md
index 68109407..54c796b3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,11 +6,7 @@ This is a Next.js application generated with
Run development server:
```bash
-npm run dev
-# or
-pnpm dev
-# or
-yarn dev
+bun run dev
```
Open http://localhost:3000 with your browser to see the result.
diff --git a/docs/app/(home)/layout.tsx b/docs/app/(home)/layout.tsx
deleted file mode 100644
index 77379fac..00000000
--- a/docs/app/(home)/layout.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { HomeLayout } from 'fumadocs-ui/layouts/home';
-import { baseOptions } from '@/lib/layout.shared';
-
-export default function Layout({ children }: LayoutProps<'/'>) {
- return {children};
-}
diff --git a/docs/app/(home)/page.tsx b/docs/app/(home)/page.tsx
deleted file mode 100644
index 4393163a..00000000
--- a/docs/app/(home)/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation';
-
-export default function HomePage() {
- redirect('/docs');
-}
diff --git a/docs/app/docs/layout.tsx b/docs/app/[[...slug]]/layout.tsx
similarity index 77%
rename from docs/app/docs/layout.tsx
rename to docs/app/[[...slug]]/layout.tsx
index 299d2e28..cfae6acd 100644
--- a/docs/app/docs/layout.tsx
+++ b/docs/app/[[...slug]]/layout.tsx
@@ -1,8 +1,8 @@
-import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
+import { source } from '@/lib/source';
-export default function Layout({ children }: LayoutProps<'/docs'>) {
+export default function Layout({ children }: LayoutProps<'/[[...slug]]'>) {
return (
{children}
diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx
similarity index 92%
rename from docs/app/docs/[[...slug]]/page.tsx
rename to docs/app/[[...slug]]/page.tsx
index 35aca33e..908c0aeb 100644
--- a/docs/app/docs/[[...slug]]/page.tsx
+++ b/docs/app/[[...slug]]/page.tsx
@@ -7,7 +7,7 @@ import { APIPage } from '@/components/api-page';
import { getPageImage, source } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
-export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
+export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -59,7 +59,7 @@ export async function generateStaticParams() {
return source.generateParams();
}
-export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise {
+export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
diff --git a/docs/content/docs/overview/docker.mdx b/docs/content/docs/overview/docker.mdx
new file mode 100644
index 00000000..8090dbe0
--- /dev/null
+++ b/docs/content/docs/overview/docker.mdx
@@ -0,0 +1,240 @@
+---
+title: "Docker Deployment"
+description: "Run Voicebox as a headless server with a web UI using Docker"
+---
+
+## Overview
+
+Voicebox can run as a Docker container with a full web UI -- no desktop app required. This is ideal for headless servers, shared GPU machines, or self-hosted deployments.
+
+## Quick Start
+
+```bash
+git clone https://github.com/jamiepine/voicebox.git
+cd voicebox
+docker compose up
+```
+
+Open [http://localhost:17493](http://localhost:17493) in your browser. The full Voicebox UI is served directly from the backend.
+
+
+ The first build takes a few minutes (compiling the frontend, installing Python dependencies). Subsequent starts are fast thanks to Docker layer caching.
+
+
+## How It Works
+
+The Docker image uses a 3-stage build:
+
+1. **Frontend** -- builds the React SPA with Bun and Vite
+2. **Backend** -- installs Python dependencies and TTS model packages
+3. **Runtime** -- combines both into a minimal image running the FastAPI server
+
+The backend serves the web UI automatically when the built frontend is present. All API routes work exactly as they do in the desktop app.
+
+## Configuration
+
+### docker-compose.yml
+
+The default `docker-compose.yml` binds to localhost only, mounts persistent volumes for data and model cache, and sets sensible resource limits:
+
+```yaml
+services:
+ voicebox:
+ build: .
+ container_name: voicebox
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:17493:17493"
+ volumes:
+ - ./output:/app/data/generations
+ - voicebox-data:/app/data
+ - huggingface-cache:/home/voicebox/.cache/huggingface
+ environment:
+ - LOG_LEVEL=info
+ deploy:
+ resources:
+ limits:
+ cpus: '4'
+ memory: 8G
+```
+
+### Exposing to Your Network
+
+By default the container only listens on `127.0.0.1`. To allow other machines on your network to connect, change the port binding:
+
+```yaml
+ports:
+ - "0.0.0.0:17493:17493"
+```
+
+
+ The API has no built-in authentication. Only expose to trusted networks, or put a reverse proxy with auth in front of it.
+
+
+### Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `LOG_LEVEL` | `info` | Logging verbosity (`debug`, `info`, `warning`, `error`) |
+| `VOICEBOX_MODELS_DIR` | (HuggingFace cache) | Custom path for model storage |
+| `VOICEBOX_CORS_ORIGINS` | (local origins) | Additional CORS origins, comma-separated |
+
+### Resource Limits
+
+The default compose file limits the container to 4 CPUs and 8GB RAM. Adjust these based on your hardware:
+
+```yaml
+deploy:
+ resources:
+ limits:
+ cpus: '8'
+ memory: 16G
+```
+
+
+ TTS model inference is memory-intensive. 8GB is the minimum for running a single engine. 16GB+ is recommended if you want multiple engines loaded simultaneously.
+
+
+## Volumes
+
+| Volume | Container Path | Purpose |
+|--------|---------------|---------|
+| `./output` | `/app/data/generations` | Generated audio files (bind-mount, easy access from host) |
+| `voicebox-data` | `/app/data` | Profiles, database, cache |
+| `huggingface-cache` | `/home/voicebox/.cache/huggingface` | Downloaded models (persists across rebuilds) |
+
+The `huggingface-cache` volume is important -- without it, models would be re-downloaded every time the container is rebuilt.
+
+## GPU Acceleration
+
+### NVIDIA GPU (CUDA)
+
+To use your NVIDIA GPU inside the container, install the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) and add GPU access to your compose file:
+
+```yaml
+services:
+ voicebox:
+ build: .
+ # ... existing config ...
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+```
+
+### AMD GPU (ROCm)
+
+For AMD GPUs, use the ROCm runtime:
+
+```yaml
+services:
+ voicebox:
+ build: .
+ # ... existing config ...
+ devices:
+ - /dev/kfd
+ - /dev/dri
+ group_add:
+ - video
+```
+
+### CPU Only
+
+The default configuration runs on CPU. This works fine but generation will be slower. LuxTTS is the fastest engine on CPU (150x realtime).
+
+## Security
+
+The Docker image follows security best practices:
+
+- **Non-root user** -- the server runs as `voicebox`, not `root`
+- **Localhost binding** -- only accessible from the host machine by default
+- **Health checks** -- automatic restart if the server hangs (`/health` endpoint polled every 30s)
+- **CORS restricted** -- only local origins allowed by default
+
+### Running Behind a Reverse Proxy
+
+For production deployments, put Voicebox behind nginx or Caddy with TLS and authentication:
+
+```nginx
+server {
+ listen 443 ssl;
+ server_name voicebox.example.com;
+
+ ssl_certificate /etc/ssl/certs/voicebox.pem;
+ ssl_certificate_key /etc/ssl/private/voicebox.key;
+
+ auth_basic "Voicebox";
+ auth_basic_user_file /etc/nginx/.htpasswd;
+
+ location / {
+ proxy_pass http://127.0.0.1:17493;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+```
+
+## Troubleshooting
+
+### Container starts but UI shows JSON
+
+If you see `{"message": "voicebox API", ...}` instead of the web UI, the frontend build may have failed during the Docker build. Check the build logs:
+
+```bash
+docker compose build --no-cache
+```
+
+Look for errors in the "Build frontend" stage.
+
+### Models downloading on every restart
+
+Make sure the `huggingface-cache` volume is configured. Without it, the model cache is lost when the container stops:
+
+```yaml
+volumes:
+ - huggingface-cache:/home/voicebox/.cache/huggingface
+```
+
+### Out of memory
+
+TTS models are large. If the container is killed by the OOM killer, increase the memory limit:
+
+```yaml
+deploy:
+ resources:
+ limits:
+ memory: 16G
+```
+
+### Port already in use
+
+```bash
+# Check what's using port 17493
+lsof -i :17493
+
+# Or use a different port
+ports:
+ - "127.0.0.1:8080:17493"
+```
+
+## Prebuilt Images (Coming Soon)
+
+We plan to publish prebuilt Docker images to GitHub Container Registry so you won't need to build locally:
+
+```bash
+# Not available yet — coming in a future release
+docker run -p 17493:17493 ghcr.io/jamiepine/voicebox:latest
+```
+
+The CPU image will be ~3-4 GB (Python + PyTorch + TTS packages). A separate CUDA tag (~6-8 GB) will be available for NVIDIA GPU users. This is normal for ML containers.
+
+For now, use `docker compose up` to build from source as described above.
+
+## Connecting the Desktop App
+
+You can also use the desktop app as a frontend for a Docker-hosted backend. In the desktop app, go to **Settings -> Server**, enable **Remote Mode**, and enter `http://:17493`.
+
+See the [Remote Mode guide](/overview/remote-mode) for details.
diff --git a/docs/content/docs/overview/meta.json b/docs/content/docs/overview/meta.json
index 3ac2fc15..17dab7bf 100644
--- a/docs/content/docs/overview/meta.json
+++ b/docs/content/docs/overview/meta.json
@@ -4,6 +4,7 @@
"pages": [
"introduction",
"installation",
+ "docker",
"quick-start",
"voice-cloning",
"stories-editor",
diff --git a/docs/lib/source.ts b/docs/lib/source.ts
index c829e387..15b9ccbe 100644
--- a/docs/lib/source.ts
+++ b/docs/lib/source.ts
@@ -1,10 +1,10 @@
-import { docs } from '@/.source';
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
+import { docs } from '@/.source';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
- baseUrl: '/docs',
+ baseUrl: '/',
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
});
diff --git a/docs/MIGRATION.md b/docs/notes/MIGRATION.md
similarity index 100%
rename from docs/MIGRATION.md
rename to docs/notes/MIGRATION.md
diff --git a/docs/RELEASE_v0.2.0.md b/docs/notes/RELEASE_v0.2.0.md
similarity index 100%
rename from docs/RELEASE_v0.2.0.md
rename to docs/notes/RELEASE_v0.2.0.md
diff --git a/docs/issue-pain-points.md b/docs/notes/issue-pain-points.md
similarity index 100%
rename from docs/issue-pain-points.md
rename to docs/notes/issue-pain-points.md