feat(install): native bare-metal install path, no Docker

Adds scripts/install-baremetal.sh: installs Homelable natively on a
Debian/Ubuntu host — Python venv plus a homelable systemd unit for the
backend on 127.0.0.1:8000, the built frontend served by nginx on :3000.
Modeled on scripts/lxc-mcp-install.sh, with the install steps taken from
the community-scripts/ProxmoxVE recipe so the two stay recognisably the
same install.

The script clones into INSTALL_DIR when empty, creates the service user,
builds the venv and the frontend, generates backend/.env with a random
SECRET_KEY and a bcrypt hash, writes the systemd unit and the nginx site,
then waits on /api/v1/health. Re-running is safe and is the upgrade path:
an existing .env is kept, everything else is rebuilt. Every prompt has an
environment-variable override, so a non-interactive install is one line.

Two details worth calling out:

- JSON values in the generated .env are single-quoted. systemd's
  EnvironmentFile parser strips bare double quotes, which would hand
  pydantic [http://...] instead of ["http://..."] and fail startup.
- The admin password reaches Python through the environment rather than
  argv, which is world-readable in ps.

The unit runs unprivileged, so nmap falls back to a TCP connect scan;
AmbientCapabilities=CAP_NET_RAW is shipped commented out with the
trade-off spelled out in the unit and in the docs.

INSTALLATION.md gains a "Bare metal — no Docker" section covering the
quick start, the upgrade, the option table, the non-root scan trade-off
and the host-nginx blocks for anyone bringing their own reverse proxy.
README links it from the install line.

No test suite: shell installers have none in this repo, and CI's
lint-scripts job shellchecks scripts/. Shellcheck is clean.

Closes #333

ha-relevant: no
This commit is contained in:
Pouzor
2026-09-02 02:15:10 +02:00
committed by Pouzor - Rémy Jardient
parent 39cad9efc7
commit 9998b3784f
3 changed files with 447 additions and 1 deletions
+146
View File
@@ -89,6 +89,152 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
---
## 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.
```bash
git clone https://github.com/Pouzor/homelable.git /opt/homelable
sudo bash /opt/homelable/scripts/install-baremetal.sh
```
The script can also clone for you — run it from anywhere and it fetches the repo
into `INSTALL_DIR` if that directory is empty:
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-baremetal.sh \
| sudo bash
```
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**.
Re-running is safe and is how you upgrade — an existing `backend/.env` is kept
untouched, everything else is rebuilt:
```bash
cd /opt/homelable && git pull
sudo bash scripts/install-baremetal.sh
```
### Options
Every setting is an environment variable, so a non-interactive install is one line:
```bash
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 (`admin`) | Initial password for user `admin` |
| `SCANNER_RANGES` | prompt (guessed) | JSON array of CIDRs |
| `SKIP_NGINX=1` | off | Do not install or touch nginx |
### Afterwards
```bash
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 homelable` after 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:
```bash
/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`:
```ini
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`:
```nginx
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.
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
+1 -1
View File
@@ -65,7 +65,7 @@ Every feature, with how to turn it on and use it, is described in **[FEATURES.md
## Installation
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, **[bare metal without Docker](./INSTALLATION.md#bare-metal--no-docker)** (`sudo bash scripts/install-baremetal.sh` — systemd unit plus nginx on a Debian/Ubuntu host), build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
---
+300
View File
@@ -0,0 +1,300 @@
#!/bin/bash
# Install Homelable (backend + frontend) natively on a Debian/Ubuntu host — no Docker.
#
# Run interactively as root on any Debian 12+/Ubuntu 22.04+ host, VM or LXC.
# Installs:
# - a Python venv + systemd unit for the FastAPI backend on 127.0.0.1:8000
# - the built Vite frontend, served by nginx on $HTTP_PORT with /api and /ws
# proxied to the backend
#
# Idempotent: re-running is safe. An existing backend/.env is kept untouched;
# only the venv, the frontend build, the systemd unit and the nginx site are
# refreshed. Use it to upgrade: pull (or re-clone) then re-run.
#
# Optional env vars (override defaults / skip the matching prompt):
# INSTALL_DIR repo root (default: /opt/homelable)
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
# REPO_REF branch/tag/commit when cloning (default: main)
# SERVICE_USER systemd User= (default: homelable)
# BACKEND_PORT uvicorn listen port, loopback only (default: 8000)
# HTTP_PORT nginx listen port (default: 3000)
# SERVER_NAME nginx server_name (default: _)
# 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)
# SKIP_NGINX=1 do not install or touch nginx (bring your own reverse proxy)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
REPO_REF="${REPO_REF:-main}"
SERVICE_USER="${SERVICE_USER:-homelable}"
SERVICE_NAME="homelable"
BACKEND_PORT="${BACKEND_PORT:-8000}"
HTTP_PORT="${HTTP_PORT:-3000}"
SERVER_NAME="${SERVER_NAME:-_}"
SKIP_NGINX="${SKIP_NGINX:-0}"
NODE_MAJOR=20
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
command -v apt-get >/dev/null || fail "This script targets Debian/Ubuntu (apt-get not found)."
log "Installing OS dependencies"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
git curl ca-certificates python3 python3-venv python3-pip \
build-essential nmap iputils-ping iproute2 openssl >/dev/null
if [[ "$SKIP_NGINX" != "1" ]]; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx >/dev/null
fi
# Node 20+ — Debian 12 ships 18, too old for Vite 7 / React 19.
node_major="$(node --version 2>/dev/null | sed 's/^v\([0-9]*\).*/\1/')"
if [[ -z "$node_major" || "$node_major" -lt "$NODE_MAJOR" ]]; then
log "Installing Node.js $NODE_MAJOR (found: ${node_major:-none})"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs >/dev/null
fi
BACKEND_DIR="$INSTALL_DIR/backend"
FRONTEND_DIR="$INSTALL_DIR/frontend"
if [[ ! -d "$BACKEND_DIR" ]]; then
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
fi
[[ -f "$BACKEND_DIR/requirements.txt" ]] || fail "Missing $BACKEND_DIR/requirements.txt — repo layout unexpected."
[[ -f "$FRONTEND_DIR/package.json" ]] || fail "Missing $FRONTEND_DIR/package.json — repo layout unexpected."
for port in "$BACKEND_PORT" "$HTTP_PORT"; do
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}$"; then
warn "Port $port already in use. Fine if it's a previous Homelable instance; otherwise abort and free it."
fi
done
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating service user '$SERVICE_USER'"
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
DATA_DIR="$INSTALL_DIR/data"
mkdir -p "$DATA_DIR"
VENV="$BACKEND_DIR/.venv"
if [[ ! -d "$VENV" ]]; then
log "Creating venv at $VENV"
python3 -m venv "$VENV"
fi
log "Installing Python deps (this takes a few minutes)"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -r "$BACKEND_DIR/requirements.txt"
ENV_FILE="$BACKEND_DIR/.env"
if [[ -f "$ENV_FILE" ]]; then
log ".env already present at $ENV_FILE — keeping existing values"
else
[[ -f "$INSTALL_DIR/.env.example" ]] || fail "Missing $INSTALL_DIR/.env.example"
log "No .env found — generating one (press Enter to accept defaults)"
admin_password="${ADMIN_PASSWORD:-}"
if [[ -z "$admin_password" ]]; then
read -rsp "Initial admin password [default: admin]: " admin_password; echo
admin_password="${admin_password:-admin}"
fi
scanner_ranges="${SCANNER_RANGES:-}"
if [[ -z "$scanner_ranges" ]]; then
guess="$(ip -o -f inet addr show scope global 2>/dev/null \
| awk '{print $4}' | head -n1 \
| awk -F/ '{split($1,o,"."); print o[1]"."o[2]"."o[3]".0/"$2}')"
guess="${guess:-192.168.1.0/24}"
read -rp "CIDR range to scan [$guess]: " scanner_ranges
scanner_ranges="[\"${scanner_ranges:-$guess}\"]"
fi
secret_key="$(openssl rand -hex 32)"
# Passed through the environment, not argv — argv is world-readable in ps.
password_hash="$(HL_PW="$admin_password" "$VENV/bin/python" -c \
'import os, bcrypt; print(bcrypt.hashpw(os.environ["HL_PW"].encode(), bcrypt.gensalt()).decode())')"
host_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
origins="[\"http://localhost:${HTTP_PORT}\""
[[ -n "$host_ip" ]] && origins="${origins},\"http://${host_ip}:${HTTP_PORT}\""
origins="${origins}]"
umask 077
cat >"$ENV_FILE" <<EOF
# Generated by scripts/install-baremetal.sh
# JSON values are single-quoted: systemd's EnvironmentFile parser strips
# bare double quotes, which would break the JSON before pydantic sees it.
SECRET_KEY=$secret_key
SQLITE_PATH=$DATA_DIR/homelab.db
# Set this to the URL(s) you use to reach Homelable in your browser.
# Behind a TLS reverse proxy, replace these with https://homelable.example.
CORS_ORIGINS='$origins'
AUTH_MODE=local
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$password_hash'
SCANNER_RANGES='$scanner_ranges'
SCANNER_HTTP_RANGES='[]'
SCANNER_HTTP_PROBE_ENABLED=false
SCANNER_HTTP_VERIFY_TLS=false
STATUS_CHECKER_INTERVAL=60
EOF
umask 022
log "Wrote $ENV_FILE (mode 600)"
warn "Every other setting (OIDC, MCP, Proxmox, Zigbee, Z-Wave, live view) is documented in .env.example."
fi
log "Building the frontend"
if [[ -f "$FRONTEND_DIR/package-lock.json" ]]; then
( cd "$FRONTEND_DIR" && npm ci --silent )
else
( cd "$FRONTEND_DIR" && npm install --silent )
fi
( cd "$FRONTEND_DIR" && npm run build )
[[ -d "$FRONTEND_DIR/dist" ]] || fail "Frontend build produced no $FRONTEND_DIR/dist."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
chmod 600 "$ENV_FILE"
# nginx (www-data) needs to traverse the tree down to dist/.
chmod o+x "$INSTALL_DIR" "$FRONTEND_DIR"
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
log "Writing $UNIT"
cat >"$UNIT" <<EOF
[Unit]
Description=Homelable backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$BACKEND_DIR
EnvironmentFile=$ENV_FILE
ExecStart=$VENV/bin/uvicorn app.main:app --host 127.0.0.1 --port $BACKEND_PORT
Restart=on-failure
RestartSec=5
# The scanner shells out to nmap. Without raw sockets nmap silently falls back
# to a TCP connect scan: hosts and open ports are still found, but OS detection
# (-O) and SYN scan (-sS) do not work. Uncomment to grant them.
#AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
#CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
if [[ "$SKIP_NGINX" != "1" ]]; then
SITE="/etc/nginx/sites-available/homelable"
log "Writing $SITE"
cat >"$SITE" <<EOF
server {
listen $HTTP_PORT;
server_name $SERVER_NAME;
root $FRONTEND_DIR/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:$BACKEND_PORT;
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:$BACKEND_PORT;
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:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
}
# SPA fallback
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
ln -sf "$SITE" /etc/nginx/sites-enabled/homelable
if [[ "$HTTP_PORT" == "80" ]]; then rm -f /etc/nginx/sites-enabled/default; fi
nginx -t
systemctl reload nginx || systemctl restart nginx
fi
log "Waiting for the backend on :$BACKEND_PORT"
ok=0
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if curl -fsS "http://127.0.0.1:${BACKEND_PORT}/api/v1/health" >/dev/null 2>&1; then
ok=1; break
fi
sleep 1
done
if [[ "$ok" -ne 1 ]]; then
warn "Backend did not answer /api/v1/health within 15s. Check: journalctl -u $SERVICE_NAME -n 50"
else
log "Backend is up."
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
cat <<EOF
----------------------------------------------------------------
Homelable installed.
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
Backend: http://127.0.0.1:$BACKEND_PORT (loopback only)
Env file: $ENV_FILE
Data: $DATA_DIR (SQLite DB + uploads)
Logs: journalctl -u $SERVICE_NAME -f
EOF
if [[ "$SKIP_NGINX" != "1" ]]; then
cat <<EOF
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}
nginx site: /etc/nginx/sites-available/homelable
EOF
else
cat <<EOF
nginx: skipped — proxy your own front end to 127.0.0.1:$BACKEND_PORT
and serve $FRONTEND_DIR/dist. See INSTALLATION.md.
EOF
fi
cat <<EOF
Log in as 'admin' with the password you set. Change it later by regenerating
AUTH_PASSWORD_HASH in $ENV_FILE:
$VENV/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode())"
systemctl restart $SERVICE_NAME
Upgrade: git -C $INSTALL_DIR pull && bash $INSTALL_DIR/scripts/install-baremetal.sh
----------------------------------------------------------------
EOF