diff --git a/.env.example b/.env.example
index ced130a..7cf7cca 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,11 @@ SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+# Serve the UI under a subpath instead of the root of the origin, e.g. /homelab/.
+# Read by docker-compose at *build* time (docker compose build frontend), since the
+# prefix is baked into the frontend bundle. See INSTALLATION.md.
+# VITE_BASE_PATH=/homelab/
+
# Auth — local mode is the backward-compatible default.
AUTH_MODE=local
# Local credentials: admin / admin by default.
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
index 745c359..5e8db73 100644
--- a/Dockerfile.frontend
+++ b/Dockerfile.frontend
@@ -7,6 +7,12 @@ FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
+# Subpath support: build with VITE_BASE_PATH=/homelab/ to serve Homelable under a
+# prefix instead of the root of an origin. The default '/' produces the same build
+# as before the option existed.
+ARG VITE_BASE_PATH=/
+ENV VITE_BASE_PATH=$VITE_BASE_PATH
+
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
@@ -18,15 +24,31 @@ RUN npm run build
# Stage 2: serve
FROM nginx:alpine
-COPY --from=builder /app/dist /usr/share/nginx/html
+COPY --from=builder /app/dist /tmp/dist
-# Nginx config: use standalone config (no backend proxy) or full config
+# Nginx config: standalone (no backend proxy) or full, served at the root or under the
+# VITE_BASE_PATH prefix. At the root the build lands in /usr/share/nginx/html and the
+# shipped .conf is copied verbatim, so an existing deployment gets exactly what it had
+# before this option existed. Under a prefix the build lands in the matching
+# subdirectory, which keeps the generated config on plain `root` semantics.
ARG VITE_STANDALONE=false
-COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
-COPY docker/nginx.standalone.conf /etc/nginx/conf.d/nginx.standalone.conf
-RUN if [ "$VITE_STANDALONE" = "true" ]; then \
- cp /etc/nginx/conf.d/nginx.standalone.conf /etc/nginx/conf.d/default.conf; \
- fi && \
- rm /etc/nginx/conf.d/nginx.standalone.conf
+ARG VITE_BASE_PATH=/
+COPY docker/nginx.conf docker/nginx.standalone.conf /tmp/nginx-conf/
+COPY docker/nginx.subpath.conf.template docker/nginx.standalone.subpath.conf.template /tmp/nginx-conf/
+# ash with pipefail: the base-path normalization below pipes printf into sed.
+SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
+RUN set -eu; \
+ if [ "$VITE_STANDALONE" = "true" ]; then variant=nginx.standalone; else variant=nginx; fi; \
+ base=$(printf '%s' "${VITE_BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g'); \
+ [ -n "$base" ] || base=/; \
+ mkdir -p "/usr/share/nginx/html${base}"; \
+ cp -a /tmp/dist/. "/usr/share/nginx/html${base}"; \
+ if [ "$base" = "/" ]; then \
+ cp "/tmp/nginx-conf/${variant}.conf" /etc/nginx/conf.d/default.conf; \
+ else \
+ sed -e "s#__BASE_NO_SLASH__#${base%/}#g" -e "s#__BASE__#${base}#g" \
+ "/tmp/nginx-conf/${variant}.subpath.conf.template" > /etc/nginx/conf.d/default.conf; \
+ fi; \
+ rm -rf /tmp/dist /tmp/nginx-conf
EXPOSE 80
diff --git a/INSTALLATION.md b/INSTALLATION.md
index 573c757..ea2c5ec 100644
--- a/INSTALLATION.md
+++ b/INSTALLATION.md
@@ -189,6 +189,7 @@ sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \
| `ADMIN_PASSWORD` | prompt, else `admin` | Initial password for user `admin` |
| `SCANNER_RANGES` | prompt, else guessed | JSON array of CIDRs |
| `SKIP_NGINX=1` | off | Do not install or touch nginx |
+| `BASE_PATH` | `/` | Serve under a subpath — see [Serving under a subpath](#serving-under-a-subpath) |
### Afterwards
@@ -283,6 +284,83 @@ the service.
---
+## Serving under a subpath
+
+By default Homelable owns the root of its origin (`https://homelable.example/`).
+To put it behind an existing reverse proxy on a shared hostname — one cert, one
+dynamic-DNS name, one open port, every service on its own prefix — build it with
+a base path.
+
+The base path is **baked into the build**: the browser has no way to guess it, so
+it cannot be a runtime setting. Changing it means rebuilding the frontend.
+
+### Docker
+
+```bash
+# in .env, next to the backend settings
+VITE_BASE_PATH=/homelab/
+
+docker compose build frontend && docker compose up -d
+```
+
+Homelable then answers on `http://:3000/homelab/`. The generated nginx
+config accepts both reverse-proxy styles, so either of these works in front of
+it:
+
+```nginx
+# prefix forwarded intact — no trailing slash on proxy_pass
+location /homelab/ {
+ proxy_pass http://127.0.0.1:3000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+}
+
+# or: prefix stripped — trailing slash on proxy_pass
+location /homelab/ {
+ proxy_pass http://127.0.0.1:3000/;
+ ...
+}
+```
+
+The pre-built images (`docker-compose.prebuilt.yml`) are built for the root, so a
+subpath needs a local build.
+
+### Bare metal / LXC
+
+```bash
+sudo BASE_PATH=/homelab/ bash scripts/install-baremetal.sh
+```
+
+The script bakes the prefix into the build and writes the matching nginx site.
+The build stays in `/opt/homelable/frontend/dist`; the site serves it through
+`/var/www/homelable/homelab`, a symlink refreshed on every run. Re-running the
+script with a different `BASE_PATH` (or none) rewrites both.
+
+### Development
+
+```bash
+cd frontend && VITE_BASE_PATH=/homelab/ npm run dev # http://localhost:5173/homelab/
+```
+
+The Vite dev proxy follows the same prefix and strips it before forwarding to
+uvicorn on `:8000`.
+
+### What to expect
+
+- WebSocket status updates, uploaded floor plans and the read-only live view
+ (`/homelab/view`) all follow the prefix.
+- TLS in front still means adding your hostname to `CORS_ORIGINS` in
+ `backend/.env`, exactly as at the root.
+- OIDC: `OIDC_REDIRECT_URI` must carry the prefix
+ (`https://home.example/homelab/api/v1/auth/oidc/callback`), and so must the
+ redirect URI registered with your provider.
+- Floor plans uploaded before the move keep working — stored URLs are resolved
+ against the base path at render time.
+
+---
+
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index 6f091c6..527088f 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from starlette.responses import RedirectResponse
from app.api.deps import AuthContext, get_auth_context
-from app.core.config import settings
+from app.core.config import app_base_path_of, settings
from app.core.security import (
clear_oidc_session_cookie,
create_access_token,
@@ -103,7 +103,10 @@ async def oidc_callback(request: Request) -> Response:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
request.session.clear()
- response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+ # Back to the SPA — which is not necessarily at the root of the origin.
+ response = RedirectResponse(
+ url=app_base_path_of(settings.oidc_redirect_uri), status_code=status.HTTP_303_SEE_OTHER
+ )
set_oidc_session_cookie(response, session_token)
return response
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index a66280c..ddb5f47 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -17,6 +17,26 @@ def origin_of(url: str) -> str:
return ""
return f"{parts.scheme}://{parts.netloc}"
+
+OIDC_CALLBACK_PATH = "/api/v1/auth/oidc/callback"
+
+
+def app_base_path_of(redirect_uri: str) -> str:
+ """
+ Path the SPA is served under, read back from OIDC_REDIRECT_URI.
+
+ The frontend can be mounted under a prefix (`VITE_BASE_PATH=/homelab/`), and the
+ backend only ever learns about it through the callback URL the operator
+ configured — `https://home.example/homelab/api/v1/auth/oidc/callback` means the
+ app lives at `/homelab/`. Always returns a path with a trailing slash; `/` when
+ the URI is unset, relative, or not a callback URL.
+ """
+ path = urlsplit(redirect_uri).path
+ if not path.endswith(OIDC_CALLBACK_PATH):
+ return "/"
+ base = path[: -len(OIDC_CALLBACK_PATH)]
+ return f"{base}/" if base else "/"
+
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index 6571112..2e86a04 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -272,6 +272,40 @@ async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_s
assert protected.status_code == 200
+async def test_oidc_callback_returns_to_the_app_base_path(client: AsyncClient, oidc_settings):
+ """The SPA can be mounted under a prefix — the post-login redirect follows it."""
+ oidc_settings.oidc_redirect_uri = "http://test/homelab/api/v1/auth/oidc/callback"
+ fake_client = FakeOIDCClient(token={
+ "userinfo": {
+ "iss": "https://idp.example/application/o/homelable/",
+ "sub": "user-123",
+ "preferred_username": "alice",
+ },
+ })
+ with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
+ callback = await client.get("/api/v1/auth/oidc/callback")
+
+ assert callback.status_code == 303
+ assert callback.headers["location"] == "/homelab/"
+
+
+@pytest.mark.parametrize(
+ ("redirect_uri", "expected"),
+ [
+ ("", "/"),
+ ("https://home.example/api/v1/auth/oidc/callback", "/"),
+ ("https://home.example/homelab/api/v1/auth/oidc/callback", "/homelab/"),
+ ("https://home.example/apps/lab/api/v1/auth/oidc/callback", "/apps/lab/"),
+ ("/api/v1/auth/oidc/callback", "/"),
+ ("https://home.example/somewhere/else", "/"),
+ ],
+)
+def test_app_base_path_of(redirect_uri: str, expected: str):
+ from app.core.config import app_base_path_of
+
+ assert app_base_path_of(redirect_uri) == expected
+
+
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
token = {} if userinfo is None else {"userinfo": userinfo}
diff --git a/docker-compose.yml b/docker-compose.yml
index ffa434a..11456c1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -49,6 +49,10 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
+ args:
+ # Serve Homelable under a subpath, e.g. VITE_BASE_PATH=/homelab/ in .env.
+ # Defaults to the root — see docs/INSTALLATION.md.
+ VITE_BASE_PATH: ${VITE_BASE_PATH:-/}
restart: unless-stopped
ports:
- "3000:80"
diff --git a/docker/nginx.standalone.subpath.conf.template b/docker/nginx.standalone.subpath.conf.template
new file mode 100644
index 0000000..dcce6da
--- /dev/null
+++ b/docker/nginx.standalone.subpath.conf.template
@@ -0,0 +1,26 @@
+# Standalone (no backend) Homelable served under a subpath (__BASE__).
+# Generated at image build time — see docker/nginx.subpath.conf.template.
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+ # Relative Location headers — the port and scheme belong to whatever proxy is
+ # in front, not to this server block.
+ absolute_redirect off;
+
+ location = __BASE_NO_SLASH__ {
+ return 301 __BASE__;
+ }
+
+ # SPA fallback under the prefix — no backend proxy
+ location __BASE__ {
+ try_files $uri $uri/ __BASE__index.html;
+ }
+
+ # Same, for a front proxy that strips the prefix before forwarding
+ location / {
+ root /usr/share/nginx/html__BASE_NO_SLASH__;
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/docker/nginx.subpath.conf.template b/docker/nginx.subpath.conf.template
new file mode 100644
index 0000000..c79548c
--- /dev/null
+++ b/docker/nginx.subpath.conf.template
@@ -0,0 +1,86 @@
+# Homelable served under a subpath (__BASE__) instead of the root of the origin.
+#
+# Generated at image build time by Dockerfile.frontend when VITE_BASE_PATH is set:
+# __BASE__ is the prefix with its trailing slash, __BASE_NO_SLASH__ without it. A
+# default build keeps docker/nginx.conf and this file is never read.
+#
+# The static build is copied to /usr/share/nginx/html__BASE__, so every block below
+# uses plain `root` semantics — `alias` plus `try_files` mis-resolves $uri.
+#
+# Both reverse-proxy styles work against this config:
+# - prefix forwarded intact (proxy_pass http://homelable;) -> the __BASE__ blocks
+# - prefix stripped (proxy_pass http://homelable/;) -> the trailing blocks
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+ # Relative Location headers — the port and scheme belong to whatever proxy is
+ # in front, not to this server block.
+ absolute_redirect off;
+
+ # --- prefix forwarded intact ------------------------------------------------
+ location = __BASE_NO_SLASH__ {
+ return 301 __BASE__;
+ }
+
+ # WebSocket (must come before the API block to take priority)
+ location __BASE__api/v1/status/ws/ {
+ proxy_pass http://backend:8000/api/v1/status/ws/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ location __BASE__api/ {
+ proxy_pass http://backend:8000/api/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ # Legacy /ws/ path
+ location __BASE__ws/ {
+ proxy_pass http://backend:8000/ws/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ }
+
+ # SPA fallback under the prefix
+ location __BASE__ {
+ try_files $uri $uri/ __BASE__index.html;
+ }
+
+ # --- prefix already stripped by the front proxy -----------------------------
+ # No redirect to __BASE__ here: the front proxy would strip it again and loop.
+ location /api/v1/status/ws/ {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ location /api/ {
+ proxy_pass http://backend:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+
+ location /ws/ {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ }
+
+ location / {
+ root /usr/share/nginx/html__BASE_NO_SLASH__;
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5992b73..20a4def 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard'
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
+import { withBase } from '@/utils/basePath'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
@@ -772,7 +773,9 @@ export default function App() {
if (STANDALONE) {
// Standalone reads canvas from localStorage; pass the active design id so
// the read-only tab renders the same canvas the user is viewing.
- const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
+ const url = activeDesignId
+ ? withBase(`view?design=${encodeURIComponent(activeDesignId)}`)
+ : withBase('view')
window.open(url, '_blank', 'noopener,noreferrer')
return
}
@@ -784,7 +787,7 @@ export default function App() {
}
const params = new URLSearchParams({ key: res.data.key })
if (activeDesignId) params.set('design', activeDesignId)
- window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
+ window.open(withBase(`view?${params.toString()}`), '_blank', 'noopener,noreferrer')
} catch {
toast.error('Failed to open live view')
}
diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts
index 98f462c..dce52e8 100644
--- a/frontend/src/api/__tests__/client.test.ts
+++ b/frontend/src/api/__tests__/client.test.ts
@@ -70,6 +70,19 @@ describe('api/client', () => {
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
+ it('mounts both instances under a configured base path', async () => {
+ const before = instances.length
+ vi.stubEnv('BASE_URL', '/homelab/')
+ vi.resetModules()
+ await import('../client')
+ const [subApi, subPublicApi] = instances.slice(before)
+ expect(subApi.defaults.baseURL).toBe('/homelab/api/v1')
+ expect(subApi.defaults.withCredentials).toBe(true)
+ expect(subPublicApi.defaults.baseURL).toBe('/homelab/api/v1')
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ })
+
it('exports `api` matching the first created instance', () => {
expect(mod.api).toBe(api)
})
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 4adb0d7..9da093f 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,15 +1,16 @@
import axios from 'axios'
+import { API_BASE_URL } from '@/utils/basePath'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
import type { InventoryEntry } from '@/types'
export const api = axios.create({
- baseURL: '/api/v1',
+ baseURL: API_BASE_URL,
withCredentials: true,
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
-const publicApi = axios.create({ baseURL: '/api/v1' })
+const publicApi = axios.create({ baseURL: API_BASE_URL })
api.interceptors.request.use((config) => {
const { token, csrfToken } = useAuthStore.getState()
diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx
index dddcb8e..aaeb8dd 100644
--- a/frontend/src/components/LoginPage.tsx
+++ b/frontend/src/components/LoginPage.tsx
@@ -7,6 +7,7 @@ import { Logo } from '@/components/ui/Logo'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/lib/utils'
+import { API_BASE_URL, resolveServerPath } from '@/utils/basePath'
export function LoginPage() {
const [username, setUsername] = useState('')
@@ -111,7 +112,7 @@ export function LoginPage() {
Continue through the configured OpenID Connect provider.
{ e.stopPropagation(); requestFloorMapEdit() }}
>
{
expect(container.querySelector('img')).toBeNull()
})
+ it('renders the stored image URL as-is at the root, base64 plans included', () => {
+ setFloorMap()
+ const { rerender } = render()
+ expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('/api/v1/media/abc.png')
+
+ act(() => setFloorMap({ imageData: 'data:image/png;base64,abc' }))
+ rerender()
+ expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('data:image/png;base64,abc')
+ })
+
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
setFloorMap()
render()
diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx
index 028db1c..71a8647 100644
--- a/frontend/src/components/modals/DesignModal.tsx
+++ b/frontend/src/components/modals/DesignModal.tsx
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, DesignType, FloorMapConfig } from '@/types'
+import { resolveServerPath } from '@/utils/basePath'
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
@@ -294,7 +295,7 @@ export function DesignModal({
) : (
<>
-

+
diff --git a/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts b/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts
new file mode 100644
index 0000000..86a6cc4
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useStatusPolling.basePath.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useStatusPolling } from '../useStatusPolling'
+
+// Served under /homelab/ — the WebSocket URL has to carry the prefix too, or it
+// lands on whatever else owns the root of the origin.
+vi.mock('@/utils/basePath', () => ({ API_BASE_URL: '/homelab/api/v1' }))
+
+vi.mock('@/stores/canvasStore', () => ({
+ useCanvasStore: () => ({
+ setNodeStatus: vi.fn(),
+ notifyScanDeviceFound: vi.fn(),
+ setServiceStatuses: vi.fn(),
+ }),
+}))
+
+vi.mock('@/stores/authStore', () => ({
+ useAuthStore: () => ({ isAuthenticated: true, authMethod: 'local', token: 'test-token' }),
+}))
+
+class MockWebSocket {
+ static instances: MockWebSocket[] = []
+ url: string
+ onopen: (() => void) | null = null
+ onmessage: ((e: { data: string }) => void) | null = null
+ onerror: ((e: unknown) => void) | null = null
+ send = vi.fn()
+ close = vi.fn()
+
+ constructor(url: string) {
+ this.url = url
+ MockWebSocket.instances.push(this)
+ }
+}
+
+describe('useStatusPolling under a base path', () => {
+ beforeEach(() => {
+ MockWebSocket.instances = []
+ vi.stubGlobal('WebSocket', MockWebSocket)
+ Object.defineProperty(window, 'location', {
+ value: { protocol: 'http:', host: 'home.example' },
+ writable: true,
+ })
+ })
+
+ afterEach(() => vi.restoreAllMocks())
+
+ it('prefixes the WebSocket URL with the base path', () => {
+ renderHook(() => useStatusPolling())
+ expect(MockWebSocket.instances).toHaveLength(1)
+ expect(MockWebSocket.instances[0].url).toBe('ws://home.example/homelab/api/v1/status/ws/status')
+ })
+})
diff --git a/frontend/src/hooks/useStatusPolling.ts b/frontend/src/hooks/useStatusPolling.ts
index f08c8dd..554c598 100644
--- a/frontend/src/hooks/useStatusPolling.ts
+++ b/frontend/src/hooks/useStatusPolling.ts
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { ServiceStatus } from '@/types'
+import { API_BASE_URL } from '@/utils/basePath'
interface ServiceStatusEntry {
port?: number
@@ -58,7 +59,7 @@ export function useStatusPolling() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host // includes port when non-standard
- const url = `${protocol}://${host}/api/v1/status/ws/status`
+ const url = `${protocol}://${host}${API_BASE_URL}/status/ws/status`
const ws = new WebSocket(url)
wsRef.current = ws
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index ad53488..5e6b74e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import LiveView from './components/LiveView.tsx'
+import { isLiveViewPath } from './utils/basePath.ts'
-const isLiveView = window.location.pathname === '/view'
+const isLiveView = isLiveViewPath(window.location.pathname)
createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/utils/__tests__/basePath.test.ts b/frontend/src/utils/__tests__/basePath.test.ts
new file mode 100644
index 0000000..3dfdc68
--- /dev/null
+++ b/frontend/src/utils/__tests__/basePath.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import {
+ BASE_PATH,
+ API_BASE_URL,
+ normalizeBasePath,
+ withBase,
+ resolveServerPath,
+ isLiveViewPath,
+} from '@/utils/basePath'
+
+const SUB = '/homelab/'
+
+describe('normalizeBasePath', () => {
+ it('falls back to the root for an empty or missing value', () => {
+ expect(normalizeBasePath(undefined)).toBe('/')
+ expect(normalizeBasePath(null)).toBe('/')
+ expect(normalizeBasePath('')).toBe('/')
+ expect(normalizeBasePath(' ')).toBe('/')
+ expect(normalizeBasePath('/')).toBe('/')
+ })
+
+ it('adds the leading and trailing slash', () => {
+ expect(normalizeBasePath('homelab')).toBe('/homelab/')
+ expect(normalizeBasePath('/homelab')).toBe('/homelab/')
+ expect(normalizeBasePath('homelab/')).toBe('/homelab/')
+ expect(normalizeBasePath('/homelab/')).toBe('/homelab/')
+ })
+
+ it('keeps nested prefixes and collapses duplicate slashes', () => {
+ expect(normalizeBasePath('/apps/homelab')).toBe('/apps/homelab/')
+ expect(normalizeBasePath('//apps//homelab//')).toBe('/apps/homelab/')
+ })
+})
+
+describe('default (root) base', () => {
+ it('is the root, so every helper returns the pre-base-path string', () => {
+ expect(BASE_PATH).toBe('/')
+ expect(API_BASE_URL).toBe('/api/v1')
+ expect(withBase('view')).toBe('/view')
+ expect(withBase('brand/homelable.svg')).toBe('/brand/homelable.svg')
+ expect(isLiveViewPath('/view')).toBe(true)
+ expect(isLiveViewPath('/')).toBe(false)
+ expect(resolveServerPath('/api/v1/media/abc.png')).toBe('/api/v1/media/abc.png')
+ })
+})
+
+describe('withBase', () => {
+ it('joins onto the base with exactly one slash', () => {
+ expect(withBase('view', SUB)).toBe('/homelab/view')
+ expect(withBase('/view', SUB)).toBe('/homelab/view')
+ expect(withBase('//view', SUB)).toBe('/homelab/view')
+ expect(withBase('', SUB)).toBe('/homelab/')
+ })
+
+ it('keeps the query string intact', () => {
+ expect(withBase('view?key=abc&design=1', SUB)).toBe('/homelab/view?key=abc&design=1')
+ })
+})
+
+describe('resolveServerPath', () => {
+ it('prefixes root-absolute paths the backend returns', () => {
+ expect(resolveServerPath('/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png')
+ expect(resolveServerPath('/api/v1/auth/oidc/login', SUB)).toBe('/homelab/api/v1/auth/oidc/login')
+ })
+
+ it('leaves an already-prefixed path alone', () => {
+ expect(resolveServerPath('/homelab/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png')
+ expect(resolveServerPath('/homelab', SUB)).toBe('/homelab')
+ })
+
+ it('leaves anything that is not a root-absolute path alone', () => {
+ expect(resolveServerPath('data:image/png;base64,AAAA', SUB)).toBe('data:image/png;base64,AAAA')
+ expect(resolveServerPath('blob:http://x/y', SUB)).toBe('blob:http://x/y')
+ expect(resolveServerPath('https://cdn.example/x.svg', SUB)).toBe('https://cdn.example/x.svg')
+ expect(resolveServerPath('//cdn.example/x.svg', SUB)).toBe('//cdn.example/x.svg')
+ expect(resolveServerPath('relative/x.svg', SUB)).toBe('relative/x.svg')
+ expect(resolveServerPath('', SUB)).toBe('')
+ })
+})
+
+describe('isLiveViewPath', () => {
+ it('matches the live view under the base only', () => {
+ expect(isLiveViewPath('/homelab/view', SUB)).toBe(true)
+ expect(isLiveViewPath('/view', SUB)).toBe(false)
+ expect(isLiveViewPath('/homelab/', SUB)).toBe(false)
+ expect(isLiveViewPath('/homelab/viewer', SUB)).toBe(false)
+ })
+})
+
+describe('module wiring', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ })
+
+ it('derives BASE_PATH and API_BASE_URL from import.meta.env.BASE_URL', async () => {
+ vi.stubEnv('BASE_URL', '/homelab/')
+ vi.resetModules()
+ const mod = await import('@/utils/basePath')
+ expect(mod.BASE_PATH).toBe('/homelab/')
+ expect(mod.API_BASE_URL).toBe('/homelab/api/v1')
+ expect(mod.withBase('view')).toBe('/homelab/view')
+ expect(mod.isLiveViewPath('/homelab/view')).toBe(true)
+ expect(mod.resolveServerPath('/api/v1/media/a.png')).toBe('/homelab/api/v1/media/a.png')
+ })
+})
diff --git a/frontend/src/utils/__tests__/brandIcons.test.ts b/frontend/src/utils/__tests__/brandIcons.test.ts
index c1a210d..20f8cc4 100644
--- a/frontend/src/utils/__tests__/brandIcons.test.ts
+++ b/frontend/src/utils/__tests__/brandIcons.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
import {
BRAND_ICON_PREFIX,
isBrandIconKey,
@@ -34,6 +34,16 @@ describe('brand icon helpers', () => {
expect(brandIconUrl('homelable')).toBe('/brand/homelable.svg')
})
+ it('serves the local icon from under a configured base path', async () => {
+ vi.stubEnv('BASE_URL', '/homelab/')
+ vi.resetModules()
+ const { brandIconUrl: scoped } = await import('@/utils/nodeIcons')
+ expect(scoped('homelable')).toBe('/homelab/brand/homelable.svg')
+ expect(scoped('plex')).toBe('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg')
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ })
+
it('isLocalBrandIcon only matches the locally-served slugs', () => {
expect(LOCAL_BRAND_ICONS).toContain('homelable')
expect(isLocalBrandIcon('homelable')).toBe(true)
diff --git a/frontend/src/utils/basePath.ts b/frontend/src/utils/basePath.ts
new file mode 100644
index 0000000..51084bf
--- /dev/null
+++ b/frontend/src/utils/basePath.ts
@@ -0,0 +1,52 @@
+/**
+ * Base path — Homelable can be served under a subpath (`https://home.example/homelab/`)
+ * instead of the root of an origin.
+ *
+ * The single knob is the build-time `VITE_BASE_PATH` env var, which becomes Vite's
+ * `base` (see `vite.config.ts`). Vite derives `import.meta.env.BASE_URL` from it and
+ * rewrites the asset URLs it emits itself; everything the app builds by hand — API
+ * calls, the WebSocket URL, the live-view route, `public/` assets — goes through the
+ * helpers below.
+ *
+ * The default is `/`, where every helper returns exactly the string it returned before
+ * the base path existed. Only path bases are supported (not a full CDN URL).
+ */
+
+/** Normalize a raw base into a `/`-delimited path that always ends in `/`. */
+export function normalizeBasePath(raw?: string | null): string {
+ const value = (raw ?? '').trim()
+ if (!value || value === '/') return '/'
+ return `/${value}/`.replace(/\/{2,}/g, '/')
+}
+
+/** The active base path. Always starts and ends with `/`; `/` when unset. */
+export const BASE_PATH = normalizeBasePath(import.meta.env.BASE_URL)
+
+/** Join a path onto the base: `withBase('view')` → `/homelab/view`. */
+export function withBase(path: string, base: string = BASE_PATH): string {
+ return `${base}${path.replace(/^\/+/, '')}`
+}
+
+/** Base URL of the REST API — what the axios instances are created with. */
+export const API_BASE_URL = withBase('api/v1')
+
+/**
+ * Resolve a root-absolute path the *backend* handed us (an uploaded media URL, the
+ * OIDC login URL) against the base path. The backend has no idea where the SPA is
+ * mounted, so it always answers `/api/v1/...`.
+ *
+ * Anything else — a `data:` URL, an absolute `http(s)://` URL, a protocol-relative
+ * `//host/...` one, a relative path, or a path already carrying the prefix — is
+ * returned untouched.
+ */
+export function resolveServerPath(url: string, base: string = BASE_PATH): string {
+ if (!url || base === '/') return url
+ if (!url.startsWith('/') || url.startsWith('//')) return url
+ if (url === base.slice(0, -1) || url.startsWith(base)) return url
+ return `${base}${url.slice(1)}`
+}
+
+/** Is this pathname the read-only live view (`/view`, `/homelab/view`)? */
+export function isLiveViewPath(pathname: string, base: string = BASE_PATH): boolean {
+ return pathname === withBase('view', base)
+}
diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts
index b0dda8a..629a77e 100644
--- a/frontend/src/utils/nodeIcons.ts
+++ b/frontend/src/utils/nodeIcons.ts
@@ -1,4 +1,5 @@
import type { NodeType } from '@/types'
+import { withBase } from '@/utils/basePath'
import {
// Infrastructure (node types)
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
@@ -236,7 +237,7 @@ export function isLocalBrandIcon(slug: string): boolean {
}
export function brandIconUrl(slug: string): string {
- if (isLocalBrandIcon(slug)) return `/brand/${slug}.svg`
+ if (isLocalBrandIcon(slug)) return withBase(`brand/${slug}.svg`)
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 7691592..36c321d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -6,7 +6,16 @@ import tailwindcss from '@tailwindcss/vite'
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
+// Serve Homelable under a subpath (`VITE_BASE_PATH=/homelab/`) instead of the root of
+// an origin. Defaults to '/', where the built output is byte-for-byte what it was
+// before this knob existed. Mirrors `normalizeBasePath` in src/utils/basePath.ts —
+// duplicated rather than imported because that module reads `import.meta.env`, which
+// does not exist while this config is being evaluated by Node.
+const rawBase = (process.env.VITE_BASE_PATH ?? '').trim()
+const base = !rawBase || rawBase === '/' ? '/' : `/${rawBase}/`.replace(/\/{2,}/g, '/')
+
export default defineConfig({
+ base,
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
@@ -27,15 +36,19 @@ export default defineConfig({
},
},
server: {
+ // Keyed off the base so `npm run dev` keeps working when one is set: the app then
+ // calls //api/v1, and the prefix is stripped before hitting uvicorn.
proxy: {
- '/api': {
+ [`${base}api`]: {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
+ rewrite: (p: string) => p.replace(base, '/'),
},
- '/ws': {
+ [`${base}ws`]: {
target: 'ws://localhost:8000',
ws: true,
+ rewrite: (p: string) => p.replace(base, '/'),
},
},
},
diff --git a/scripts/install-baremetal.sh b/scripts/install-baremetal.sh
index 5a85c18..cacf011 100755
--- a/scripts/install-baremetal.sh
+++ b/scripts/install-baremetal.sh
@@ -19,6 +19,9 @@
# BACKEND_PORT uvicorn listen port, loopback only (default: 8000)
# HTTP_PORT nginx listen port (default: 3000)
# SERVER_NAME nginx server_name (default: _)
+# BASE_PATH serve under a subpath instead of the root of the origin,
+# e.g. BASE_PATH=/homelab/ (default: /). Baked into the
+# frontend build and into the generated nginx site.
# ADMIN_PASSWORD initial admin password (default: prompt, "admin" on empty)
# SCANNER_RANGES JSON array of CIDRs to scan (default: prompt, guessed from
# the primary interface)
@@ -37,6 +40,11 @@ BACKEND_PORT="${BACKEND_PORT:-8000}"
HTTP_PORT="${HTTP_PORT:-3000}"
SERVER_NAME="${SERVER_NAME:-_}"
SKIP_NGINX="${SKIP_NGINX:-0}"
+# Normalized to a leading + trailing slash ('/' when unset), mirroring
+# normalizeBasePath() in frontend/src/utils/basePath.ts.
+BASE_PATH="$(printf '%s' "${BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g')"
+[[ -n "$BASE_PATH" ]] || BASE_PATH="/"
+BASE_PATH_NO_SLASH="${BASE_PATH%/}"
NODE_MAJOR=20
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
@@ -180,7 +188,7 @@ if [[ -f "$FRONTEND_DIR/package-lock.json" ]]; then
else
( cd "$FRONTEND_DIR" && npm install --silent )
fi
-( cd "$FRONTEND_DIR" && npm run build )
+( cd "$FRONTEND_DIR" && VITE_BASE_PATH="$BASE_PATH" npm run build )
[[ -d "$FRONTEND_DIR/dist" ]] || fail "Frontend build produced no $FRONTEND_DIR/dist."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
@@ -222,7 +230,8 @@ systemctl restart "$SERVICE_NAME"
if [[ "$SKIP_NGINX" != "1" ]]; then
SITE="/etc/nginx/sites-available/homelable"
log "Writing $SITE"
- cat >"$SITE" <"$SITE" <"$SITE" <}:${HTTP_PORT}
+ Web UI: http://${HOST_IP:-}:${HTTP_PORT}${BASE_PATH}
nginx site: /etc/nginx/sites-available/homelable
EOF
+ if [[ "$BASE_PATH" != "/" ]]; then
+ cat <