Homelable could only own the root of an origin. Behind an existing proxy at `https://home.example/homelab/` the built `index.html` still asked for `/assets/*`, which fell through to whatever owned the root — and when that answered `text/html` for a `<script>` under `nosniff`, the browser failed the load on an HTTP 200. The only workaround was patching the checkout and rebuilding on every upgrade. One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite rewrites the asset URLs it emits; everything the app builds by hand goes through the new `utils/basePath.ts` — the axios instances, the WebSocket URL, the live-view route, the local brand icons, and the OIDC login href. `resolveServerPath` covers what the *backend* hands back, which is always root-absolute because it cannot know where the SPA is mounted: uploaded floor-plan URLs already stored in a canvas are resolved at render time, so plans predating the move keep loading. The OIDC callback used to redirect to `/`, dropping subpath users at the origin root after login; it now reads the prefix back out of `OIDC_REDIRECT_URI`. The default is `/`, and stays a no-op there by construction: every helper returns the string it returned before, the root build output is unchanged and both nginx sites are byte-identical to what shipped — the Docker image copies `docker/nginx.conf` verbatim and the installer keeps its original heredoc. Only a non-default prefix takes the generated config. Those generated configs use `root`, never `alias`, since `alias` plus `try_files` mis-resolves `$uri` — the Docker build lands the bundle in the matching subdirectory, and the installer symlinks it under `/var/www/homelable`. Both accept either reverse-proxy style, prefix forwarded intact or already stripped, with no redirect loop between them, and `absolute_redirect off` stops the no-slash 301 from eating the port. Closes #334. ha-relevant: maybe
15 KiB
Homelable — Installation
Quick Start — Docker
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose up -d
Open http://localhost:3000 — login with admin / admin.
Change the password before exposing to a network: edit
.envand updateAUTH_USERNAME/AUTH_PASSWORD_HASH.
Generate a new hash:
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
⚠️ bcrypt hashes contain $ characters — how to handle them depends on where you set the value:
.envfile (recommended): wrap the hash in single quotes →AUTH_PASSWORD_HASH='$2b$12$...'docker-compose.ymlenvironment:block: escape every$as$$— use this command to generate a pre-escaped hash:docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
Quick Start — Frontend only
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
cd homelable && docker compose up -d
Update (Docker)
Re-run the install script — it detects an existing install and only updates docker-compose.yml:
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose pull && docker compose up -d
Pre-built Docker images
The quick starts above never build anything — install.sh writes docker-compose.prebuilt.yml (or docker-compose.standalone.yml with --standalone) as your docker-compose.yml, and both pull ready-made images. They are published to the GitHub Container Registry on every push to main and every v* tag, for linux/amd64 and linux/arm64:
| Image | Contents |
|---|---|
ghcr.io/pouzor/homelable-backend |
FastAPI API, scanner, status checker |
ghcr.io/pouzor/homelable-frontend |
React SPA behind nginx, proxying /api to the backend |
ghcr.io/pouzor/homelable-frontend-standalone |
Same SPA built with VITE_STANDALONE=true — no backend, canvases in localStorage |
ghcr.io/pouzor/homelable-mcp |
MCP server exposing the canvas to AI clients |
Tags: latest (tip of main), plus X.Y.Z and X.Y for releases.
To wire it up by hand instead of using install.sh:
curl -fsSLO https://raw.githubusercontent.com/Pouzor/homelable/main/docker-compose.prebuilt.yml
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/.env.example -o .env
docker compose -f docker-compose.prebuilt.yml up -d
Update the same way: docker compose -f docker-compose.prebuilt.yml pull && … up -d.
Scanning from Docker — MAC addresses
Out of the box the backend runs on a Docker bridge network, and the scan will
never report a MAC address from it. MACs come from ARP, which is layer 2: the
container's ARP cache holds only the Docker gateway, and nmap can read a target's
hardware address only when that target sits in the same broadcast domain. From a
bridge every LAN host is one hop away behind the gateway, so the field stays
empty. cap_add: NET_RAW does not change this — the capability grants raw
sockets, not a place on the LAN.
This also affects device identity. When a device is rescanned it is matched on MAC first and IP second, so without MACs a DHCP lease change makes the device come back as a new entry in the inventory instead of updating the old one.
To collect MACs, put the backend on the LAN itself. In docker-compose.yml (or
docker-compose.prebuilt.yml), comment out the backend's networks: key and
uncomment:
network_mode: host
then docker compose up -d. Caveats:
- Linux only. On Docker Desktop for macOS and Windows the containers run inside a VM, so host networking still does not reach your physical LAN. There is no MAC-capable Docker setup on those platforms — run the bare-metal install instead.
- The backend binds
8000directly on the host, with no port mapping and no network isolation from other host services. frontendandmcpreach the backend athttp://backend:8000over thehomelablebridge; once the backend leaves that network they needhttp://127.0.0.1:8000instead. SetBACKEND_URLonmcp, and for the front end either give itnetwork_mode: hosttoo or point its nginx proxy at the host address.
The alternative, if you would rather keep the backend isolated, is a macvlan network, which gives the container its own MAC and IP on your physical LAN. It needs a parent interface and a spare address range from your subnet, and on most setups the Docker host itself cannot talk to a macvlan container without an extra shim interface.
Build from source
docker-compose.yml at the repo root builds the images locally instead of pulling them — use it for development, or to run a patched tree.
git clone https://github.com/Pouzor/homelable.git
cd homelable
cp .env.example .env
docker compose up -d
Proxmox LXC Install
You can now install Homelable with community-scripts (proxmox-VE) :
https://community-scripts.org/scripts/homelable
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
Bare metal — no Docker
scripts/install-baremetal.sh installs Homelable natively on a Debian 12+ /
Ubuntu 22.04+ host (physical, VM or LXC): a Python venv and a homelable
systemd unit for the backend on 127.0.0.1:8000, the built frontend served by
nginx on port 3000.
git clone https://github.com/Pouzor/homelable.git /opt/homelable
sudo bash /opt/homelable/scripts/install-baremetal.sh
It prompts for the admin password and the CIDR range to scan, then writes
backend/.env with a generated SECRET_KEY and bcrypt hash. Open
http://<host-ip>:3000.
The script can also clone for you — run it from anywhere and it fetches the repo
into INSTALL_DIR when that directory is empty. Piped into bash there is no
terminal to prompt on, so pass the two answers as environment variables:
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-baremetal.sh \
| sudo ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["192.168.1.0/24"]' bash
Without them the prompts are skipped and their defaults apply — the password
becomes admin and the range is guessed from the primary interface. The script
warns when that happens; change the password before exposing the host.
Re-running is safe and is how you upgrade — an existing backend/.env is kept
untouched, everything else is rebuilt:
cd /opt/homelable && git pull
sudo bash scripts/install-baremetal.sh
Options
Every setting is an environment variable. Setting them all skips every prompt, which is what makes an unattended install possible:
sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \
bash scripts/install-baremetal.sh
| Variable | Default | What |
|---|---|---|
INSTALL_DIR |
/opt/homelable |
Repo root |
REPO_URL / REPO_REF |
upstream / main |
Used only when INSTALL_DIR is empty |
SERVICE_USER |
homelable |
systemd User= |
BACKEND_PORT |
8000 |
uvicorn port, bound to loopback |
HTTP_PORT |
3000 |
nginx port |
SERVER_NAME |
_ |
nginx server_name |
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 |
Afterwards
systemctl status homelable
journalctl -u homelable -f
- Config:
/opt/homelable/backend/.env— every other option (OIDC, MCP, Proxmox, Zigbee, Z-Wave, live view) is documented in.env.example.systemctl restart homelableafter an edit. - Data:
/opt/homelable/data— SQLite DB and uploads. Back up this folder. - nginx site:
/etc/nginx/sites-available/homelable.
Change the password later:
/opt/homelable/backend/.venv/bin/python -c \
'import bcrypt; print(bcrypt.hashpw(b"newpassword", bcrypt.gensalt()).decode())'
# put it in backend/.env as AUTH_PASSWORD_HASH='$2b$12$...' (keep the single quotes)
systemctl restart homelable
⚠️ Keep JSON values in backend/.env single-quoted — CORS_ORIGINS='["http://…"]'.
systemd's EnvironmentFile parser strips bare double quotes, which breaks the
JSON before the backend parses it. This does not apply to the Docker install.
Scanning as a non-root service
The unit runs as homelable, not root, so nmap has no raw sockets and silently
falls back to a TCP connect scan: hosts and open ports are still found, OS
detection (-O) and SYN scan (-sS) are not. To grant them, uncomment in
/etc/systemd/system/homelable.service:
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
then systemctl daemon-reload && systemctl restart homelable.
Your own reverse proxy
With SKIP_NGINX=1 the script leaves the front end to you: serve
/opt/homelable/frontend/dist as a static SPA and proxy the API to the backend.
The nginx translation of docker/nginx.conf — what the script writes, with
backend:8000 replaced by 127.0.0.1:8000:
server {
listen 3000;
server_name _;
root /opt/homelable/frontend/dist;
index index.html;
client_max_body_size 20M;
# WebSocket — must come before /api/ to take priority
location /api/v1/status/ws/ {
proxy_pass http://127.0.0.1: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://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Legacy /ws/ path
location /ws/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Terminating TLS in front of it means adding your own hostname to
CORS_ORIGINS in backend/.env (https://homelable.example) and restarting
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
# in .env, next to the backend settings
VITE_BASE_PATH=/homelab/
docker compose build frontend && docker compose up -d
Homelable then answers on http://<host>:3000/homelab/. The generated nginx
config accepts both reverse-proxy styles, so either of these works in front of
it:
# 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
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
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_ORIGINSinbackend/.env, exactly as at the root. - OIDC:
OIDC_REDIRECT_URImust 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):
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
# CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# How often to check node status (seconds)
STATUS_CHECKER_INTERVAL=60
OpenID Connect (optional)
OIDC is an exclusive alternative to the local password. Existing installs stay
in AUTH_MODE=local unless explicitly changed.
AUTH_MODE=oidc
CORS_ORIGINS=["https://homelable.example"]
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
Register OIDC_REDIRECT_URI exactly at the identity provider. Production OIDC
requires HTTPS, a non-wildcard CORS_ORIGINS, and a confidential client. The
backend uses Authorization Code with PKCE and keeps provider tokens out of the
browser. SECRET_KEY must contain at least 32 bytes in OIDC mode. Do not expose
OIDC_CLIENT_SECRET in Compose YAML or commit it to Git.
All settings are also editable in-app via the Scan Network button.
Development Mode
Backend (Python 3.13):
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # edit SECRET_KEY and review defaults
uvicorn app.main:app --reload --port 8000
Frontend:
cd frontend
npm install
npm run dev # http://localhost:5173