feat(docker): full self-hosted Compose support

Make the web image drop-in for a Honcho docker-compose stack:
- nginx reverse-proxies /v3 and /health to $HONCHO_UPSTREAM (variable +
  Docker resolver so it starts even before the upstream resolves), giving
  the SPA a same-origin path to Honcho with no browser CORS.
- Runtime config: an entrypoint writes config.js from
  OPENCONCHO_DEFAULT_HONCHO_URL, so one prebuilt image targets any backend
  ("same-origin" | absolute URL | empty). SPA seeds a first-run default
  instance from it (additive; no-op in dev/desktop).
- docker-compose.yml example service + GHCR multi-arch publish workflow on
  release.
- nginx.conf -> envsubst template; docs rewritten.

Closes #21. Closes #31.
This commit is contained in:
Offending Commit
2026-05-28 16:06:03 -05:00
parent dd2edbae99
commit 282ba1b76c
12 changed files with 278 additions and 106 deletions

46
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: Publish web image
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish:
name: Build & push web image to GHCR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/openconcho-web
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
type=sha,format=short
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -31,8 +31,16 @@ RUN pnpm --filter @openconcho/web build
FROM nginxinc/nginx-unprivileged:alpine FROM nginxinc/nginx-unprivileged:alpine
COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html
COPY --chown=101:101 docker/nginx.conf /etc/nginx/conf.d/default.conf # Rendered to /etc/nginx/conf.d/default.conf by the image's envsubst entrypoint.
COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.conf.template
# Writes /usr/share/nginx/html/config.js from OPENCONCHO_DEFAULT_HONCHO_URL.
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
# Defaults target the Honcho service in a typical Compose stack; override per deploy.
ENV HONCHO_UPSTREAM=http://api:8000 \
OPENCONCHO_DEFAULT_HONCHO_URL=same-origin
EXPOSE 8080 EXPOSE 8080
# Base image CMD runs nginx in the foreground as UID 101. # Base image entrypoint renders the template + runs config script, then nginx (UID 101).

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
# Example: add OpenConcho's web UI to a self-hosted Honcho Docker Compose stack.
#
# Drop this `openconcho` service into the Compose project that already runs your
# Honcho `api` service. nginx proxies /v3 and /health to the API, so the browser
# makes same-origin requests and never hits CORS.
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
# Or build from this repo instead of pulling the published image:
# build: .
environment:
# nginx reverse-proxies /v3 and /health to this upstream (the Honcho API).
HONCHO_UPSTREAM: http://api:8000
# The SPA defaults its Honcho base URL to its own origin, so requests flow
# through the proxy above — no browser CORS, token never leaves the origin.
# Set to an absolute URL instead to point the UI at a different backend.
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
ports:
- "127.0.0.1:8080:8080"
depends_on:
api:
condition: service_healthy
restart: unless-stopped

View File

@@ -0,0 +1,11 @@
#!/bin/sh
# Regenerate the SPA's runtime config from the environment at container start.
# Lets one prebuilt image target any Honcho backend without a rebuild.
# OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL, "same-origin", or empty.
# Runs from /docker-entrypoint.d before nginx starts. Requires the html dir to
# be writable (default); skip or bind-mount config.js when running --read-only.
set -eu
cat > /usr/share/nginx/html/config.js <<EOF
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
EOF

View File

@@ -1,54 +0,0 @@
# OpenConcho — nginx site config for the runtime container.
# Serves the React SPA from /usr/share/nginx/html with client-side routing.
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't leak the nginx version.
server_tokens off;
# Long-cache static assets — Vite hashes filenames so they're safely immutable.
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# Healthcheck (no logging spam).
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# --- Optional: same-origin Honcho reverse proxy (eliminates browser CORS) ---
# The SPA's fetches are subject to browser CORS only on the web build (the
# desktop app routes through Rust and bypasses CORS). To avoid configuring
# CORS on Honcho itself, proxy the API under this origin and point the UI's
# base URL at it. See docs/docker.md for the full tradeoff — note the UI
# currently requires an absolute base URL, so this block is opt-in.
#
# location /honcho/ {
# proxy_pass http://your-honcho-host:8000/;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# SPA fallback: any unknown path returns index.html so the router resolves it.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# gzip text responses (Vite pre-compresses CSS/JS, but HTML still benefits).
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}

View File

@@ -0,0 +1,64 @@
# OpenConcho — nginx site config (envsubst template).
# The nginx image renders ${HONCHO_UPSTREAM} from the environment at start.
# Serves the React SPA and reverse-proxies the Honcho API under the same origin.
server {
listen 8080;
listen [::]:8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't leak the nginx version.
server_tokens off;
# Same-origin reverse proxy to Honcho so the browser never sees a
# cross-origin request (no CORS). The variable + Docker resolver let nginx
# start even when the upstream isn't resolvable yet, re-resolving per request.
resolver 127.0.0.11 ipv6=off valid=10s;
set $honcho_upstream "${HONCHO_UPSTREAM}";
# `^~` so these win over the static-asset regex below.
location ^~ /v3/ {
proxy_pass $honcho_upstream$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass $honcho_upstream/health;
proxy_set_header Host $host;
}
# Runtime config — regenerated per container start, must never be cached.
location = /config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# Long-cache static assets — Vite hashes filenames so they're immutable.
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# Container healthcheck (local; distinct from Honcho's /health).
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# SPA fallback: unknown paths return index.html so the router resolves them.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}

View File

@@ -1,65 +1,67 @@
# Running OpenConcho in Docker # Running OpenConcho in Docker
The `@openconcho/web` SPA can be served from a container. The image is a The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
two-stage build: Node + pnpm builds the static bundle, then builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
`nginx-unprivileged` serves it on port `8080` as a non-root user. a non-root user) that also **reverse-proxies the Honcho API under its own
origin**, so the browser never makes a cross-origin request.
## Build and run ## Add it to a Honcho Compose stack (recommended)
Honcho's self-hosting path is Docker Compose. Drop the `openconcho` service from
[`docker-compose.yml`](../docker-compose.yml) into the project that runs your
Honcho `api`:
```yaml
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
environment:
HONCHO_UPSTREAM: http://api:8000 # nginx proxies /v3 + /health here
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
ports:
- "127.0.0.1:8080:8080"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
```
`OPENCONCHO_DEFAULT_HONCHO_URL: same-origin` makes the UI default its Honcho
base URL to its own origin, so API calls go through the proxy → **no browser
CORS, and the API token never leaves the origin.** The published image is
multi-arch (amd64 + arm64); the first publish creates a private GHCR package —
make it public if you want unauthenticated pulls.
## Standalone
```bash ```bash
docker build -t openconcho-web . docker build -t openconcho-web .
docker run --rm -p 8080:8080 openconcho-web docker run --rm -p 8080:8080 -e HONCHO_UPSTREAM=http://host.docker.internal:8000 openconcho-web
# → http://localhost:8080 # → http://localhost:8080 · GET /healthz returns "ok"
``` ```
Hardened run (read-only filesystem, no added capabilities): Runtime knobs (no rebuild needed):
```bash | Env | Default | Meaning |
docker run --rm -p 8080:8080 \ |-----|---------|---------|
--read-only \ | `HONCHO_UPSTREAM` | `http://api:8000` | Where nginx proxies `/v3` and `/health` |
--tmpfs /tmp \ | `OPENCONCHO_DEFAULT_HONCHO_URL` | `same-origin` | SPA's default base URL — `same-origin`, an absolute URL, or empty (configure in Settings) |
--tmpfs /var/cache/nginx \
--cap-drop ALL \
--security-opt no-new-privileges \
openconcho-web
```
`GET /healthz` returns `200 ok` for container health checks. Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges`
with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the runtime config writes
`config.js` into the web root at start, which a read-only root blocks — under
`--read-only` either bind-mount `config.js` or leave
`OPENCONCHO_DEFAULT_HONCHO_URL` empty and set the URL in Settings.
## CORS ## CORS, the short version
The desktop app routes HTTP through Rust (`reqwest`), so it is **not** subject The desktop app routes HTTP through Rust and bypasses browser CORS; the web
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every build doesn't. The Compose setup above **solves CORS via the same-origin proxy**
request to your Honcho API is cross-origin. Honcho calls are `POST` + — nothing to configure on Honcho. If instead you point the UI at a *different*
`application/json` + `Authorization: Bearer`, which the browser always origin (absolute `OPENCONCHO_DEFAULT_HONCHO_URL` or a URL typed in Settings),
**preflights** (`OPTIONS`). You must handle this one of two ways. allow that origin in Honcho's FastAPI `CORSMiddleware`:
### Option 1 — configure Honcho's CORS (recommended)
Honcho is a FastAPI service. Allow the UI's origin via its
`CORSMiddleware` so preflight and actual requests succeed:
```python ```python
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["https://your-ui-origin"],
CORSMiddleware, allow_methods=["*"], allow_headers=["*"])
allow_origins=["http://localhost:8080"], # the OpenConcho origin
allow_methods=["*"],
allow_headers=["*"],
)
``` ```
This fits OpenConcho's model directly — the UI keeps using the absolute Honcho
URL you enter in Settings (stored in `localStorage`). Since you self-host
Honcho, you control this.
### Option 2 — same-origin reverse proxy (advanced)
Proxy the Honcho API under the same origin that serves the SPA, so the browser
sees same-origin requests and CORS never applies (the token also never crosses
origins). Uncomment the `location /honcho/` block in
[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho
host.
Caveat: the Settings form currently validates the base URL as an **absolute**
URL (`z.string().url()`), so pointing the UI at a relative same-origin path
(`/honcho`) isn't wired yet. Until that lands, Option 1 is the supported path.

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenConcho</title> <title>OpenConcho</title>
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" /> <meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
<!-- Runtime config (regenerated by the Docker image at start; no-op otherwise) -->
<script src="/config.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1,4 @@
// Runtime configuration placeholder. In the Docker image this file is
// regenerated at container start from the OPENCONCHO_DEFAULT_HONCHO_URL env.
// In dev and the desktop build it stays a no-op.
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "";

View File

@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { httpFetch } from "@/lib/http"; import { httpFetch } from "@/lib/http";
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
const LEGACY_KEY = "openconcho:config"; const LEGACY_KEY = "openconcho:config";
const STORE_KEY = "openconcho:instances"; const STORE_KEY = "openconcho:instances";
@@ -73,6 +74,18 @@ export function loadStore(): InstanceStore {
} }
const migrated = migrateLegacy(); const migrated = migrateLegacy();
if (migrated) return migrated; if (migrated) return migrated;
// First-run default from a container's runtime config (no-op outside Docker).
const runtimeUrl = runtimeDefaultBaseUrl();
if (runtimeUrl) {
const inst: Instance = {
id: "runtime-default",
name: "Honcho",
baseUrl: runtimeUrl,
token: "",
};
return { instances: [inst], activeId: inst.id };
}
return { instances: [], activeId: null }; return { instances: [], activeId: null };
} }

View File

@@ -0,0 +1,24 @@
const GLOBAL_KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
const SAME_ORIGIN = "same-origin";
/**
* Runtime-injected default Honcho base URL for container deployments.
*
* The Docker image writes `/config.js` from the `OPENCONCHO_DEFAULT_HONCHO_URL`
* env at container start, so one prebuilt image can target any backend without
* a rebuild (Vite envs are baked at build time and can't do this).
*
* - `"same-origin"` → the page's own origin (pairs with the nginx `/v3` reverse
* proxy, so the browser makes same-origin requests and CORS never applies)
* - an absolute URL → that URL
* - empty / unset → `null` (no default; the user configures in Settings)
*/
export function runtimeDefaultBaseUrl(): string | null {
const raw = (globalThis as Record<string, unknown>)[GLOBAL_KEY];
if (typeof raw !== "string" || raw.trim() === "") return null;
const value = raw.trim();
if (value === SAME_ORIGIN) {
return typeof location !== "undefined" ? location.origin : null;
}
return value;
}

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
const KEY = "__OPENCONCHO_DEFAULT_HONCHO_URL__";
afterEach(() => {
delete (globalThis as Record<string, unknown>)[KEY];
});
describe("runtimeDefaultBaseUrl", () => {
it("returns null when the global is unset", () => {
expect(runtimeDefaultBaseUrl()).toBeNull();
});
it("returns null when the global is blank", () => {
(globalThis as Record<string, unknown>)[KEY] = " ";
expect(runtimeDefaultBaseUrl()).toBeNull();
});
it("returns an absolute URL verbatim", () => {
(globalThis as Record<string, unknown>)[KEY] = "https://honcho.example.net";
expect(runtimeDefaultBaseUrl()).toBe("https://honcho.example.net");
});
it("resolves 'same-origin' to the page origin", () => {
(globalThis as Record<string, unknown>)[KEY] = "same-origin";
expect(runtimeDefaultBaseUrl()).toBe(location.origin);
});
});