feat: deploy LifeOS on hf-pdocker-02 (:8095)
Self-hosted LifeOS (github.com/lifeos-app/lifeos, pinned 3009bf6) in local mode: no Supabase. Multi-stage build - Node builds the Vite bundle, gunicorn serves it plus the bundled Flask/SQLite API from one process. LAN-only on purpose: the backend is single-user with no auth, so anyone who reaches the port has full read/write. Needs Traefik basicAuth before it can ever be exposed publicly. Two upstream bugs patched via sed (build fails loudly if they are renamed): the chat model was hardcoded to the author's private 'sentient-teddy' bridge, and a hardcoded 30s timeout failed every first message because Ollama needs ~46s to cold-load the model (0.7s warm). Both are env vars now. SQLite stays on local disk - NFSv3 breaks WAL locking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
# LifeOS — https://github.com/lifeos-app/lifeos
|
||||||
|
#
|
||||||
|
# Two stages: Node builds the Vite/React bundle, Python serves that bundle plus
|
||||||
|
# the Flask API from a single process. Build context is the upstream source tree
|
||||||
|
# (cloned to /opt/lifeos on the node); this file is copied in alongside it.
|
||||||
|
|
||||||
|
# ─── Stage 1: build the frontend ──────────────────────────────────────
|
||||||
|
FROM node:22-bookworm-slim AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# --ignore-scripts skips the electron-builder postinstall, which pulls a desktop
|
||||||
|
# toolchain we never use and fails without an X stack.
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Routes the data layer (src/lib/data-access.ts) to the local Flask API instead
|
||||||
|
# of Supabase Cloud. VITE_API_BASE_URL is deliberately left unset: local-api.ts
|
||||||
|
# then falls back to a relative /api, so the bundle works on any host and port.
|
||||||
|
ENV VITE_USE_LOCAL_API=true
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=3072
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ─── Stage 2: Flask API + built frontend ──────────────────────────────
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
COPY backend/ /app/
|
||||||
|
COPY serve_container.py /app/
|
||||||
|
COPY --from=frontend /build/dist /app/dist
|
||||||
|
|
||||||
|
# Upstream hardcodes the author's own model name in the chat proxy, and a 30s
|
||||||
|
# timeout that a cold Ollama model load (~46s for a 9.6GB model) blows through.
|
||||||
|
# Make both env vars. Fails the build if upstream renames these out from under us.
|
||||||
|
RUN sed -i 's/"model": "sentient-teddy"/"model": os.environ.get("AI_MODEL", "sentient-teddy")/' app.py \
|
||||||
|
&& sed -i 's/timeout=30$/timeout=int(os.environ.get("AI_TIMEOUT", "120"))/' app.py \
|
||||||
|
&& grep -q 'os.environ.get("AI_MODEL"' app.py \
|
||||||
|
&& grep -q 'os.environ.get("AI_TIMEOUT"' app.py
|
||||||
|
|
||||||
|
# app.py hardcodes its SQLite path to ~/.lifeos/data.db, so HOME is what pins
|
||||||
|
# the database into the mounted volume.
|
||||||
|
ENV HOME=/data
|
||||||
|
RUN mkdir -p /data/.lifeos
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", \
|
||||||
|
"--timeout", "60", "--access-logfile", "-", "serve_container:app"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
lifeos:
|
||||||
|
build:
|
||||||
|
context: /opt/lifeos
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: lifeos:local
|
||||||
|
container_name: lifeos
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# 8080 on this node is taken by unifi-os-server.
|
||||||
|
- "8095:8080"
|
||||||
|
environment:
|
||||||
|
- TZ=America/Chicago
|
||||||
|
# AI companion -> Ollama on the GPU machine (OpenAI-compatible endpoint).
|
||||||
|
- AI_BRIDGE_URL=http://192.168.86.150:11434
|
||||||
|
- AI_MODEL=gemma4:latest
|
||||||
|
# Ollama needs ~46s to cold-load gemma4 (0.7s once warm); upstream's
|
||||||
|
# hardcoded 30s timeout would fail every first message.
|
||||||
|
- AI_TIMEOUT=120
|
||||||
|
volumes:
|
||||||
|
# SQLite (WAL) stays on local disk: NFS v3 locking breaks it, same reason
|
||||||
|
# guacamole-db lives on /var/lib.
|
||||||
|
- /var/lib/lifeos:/data/.lifeos
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
mem_limit: 1g
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health-check').read()"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Container entrypoint: serve the Flask API and the built SPA from one process.
|
||||||
|
|
||||||
|
Upstream's backend/serve.py does this too, but it also shells out to `npm run
|
||||||
|
build` and tries to launch a browser, neither of which works in a container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import send_file, send_from_directory
|
||||||
|
|
||||||
|
from app import app # importing app runs init_db(), creating the SQLite schema
|
||||||
|
|
||||||
|
DIST = Path(__file__).parent / "dist"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def _index():
|
||||||
|
return send_file(DIST / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/<path:path>")
|
||||||
|
def _static(path):
|
||||||
|
asset = DIST / path
|
||||||
|
if asset.is_file():
|
||||||
|
return send_from_directory(DIST, path)
|
||||||
|
if path.startswith("api/"):
|
||||||
|
return {"error": "Not found"}, 404
|
||||||
|
return send_file(DIST / "index.html") # SPA client-side routing
|
||||||
Reference in New Issue
Block a user