Merge pull request #43 from offendingcommit/feat/docker-compose-support
feat(docker): full self-hosted Compose support
This commit is contained in:
46
.github/workflows/docker-publish.yml
vendored
Normal file
46
.github/workflows/docker-publish.yml
vendored
Normal 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
|
||||
12
Dockerfile
12
Dockerfile
@@ -31,8 +31,16 @@ RUN pnpm --filter @openconcho/web build
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
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
|
||||
|
||||
# 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
23
docker-compose.yml
Normal 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
|
||||
11
docker/40-openconcho-config.sh
Normal file
11
docker/40-openconcho-config.sh
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
64
docker/nginx.conf.template
Normal file
64
docker/nginx.conf.template
Normal 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;
|
||||
}
|
||||
102
docs/docker.md
102
docs/docker.md
@@ -1,65 +1,67 @@
|
||||
# Running OpenConcho in Docker
|
||||
|
||||
The `@openconcho/web` SPA can be served from a container. The image is a
|
||||
two-stage build: Node + pnpm builds the static bundle, then
|
||||
`nginx-unprivileged` serves it on port `8080` as a non-root user.
|
||||
The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
|
||||
builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
|
||||
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
|
||||
docker build -t openconcho-web .
|
||||
docker run --rm -p 8080:8080 openconcho-web
|
||||
# → http://localhost:8080
|
||||
docker run --rm -p 8080:8080 -e HONCHO_UPSTREAM=http://host.docker.internal:8000 openconcho-web
|
||||
# → http://localhost:8080 · GET /healthz returns "ok"
|
||||
```
|
||||
|
||||
Hardened run (read-only filesystem, no added capabilities):
|
||||
Runtime knobs (no rebuild needed):
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 \
|
||||
--read-only \
|
||||
--tmpfs /tmp \
|
||||
--tmpfs /var/cache/nginx \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
openconcho-web
|
||||
```
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `HONCHO_UPSTREAM` | `http://api:8000` | Where nginx proxies `/v3` and `/health` |
|
||||
| `OPENCONCHO_DEFAULT_HONCHO_URL` | `same-origin` | SPA's default base URL — `same-origin`, an absolute URL, or empty (configure in Settings) |
|
||||
|
||||
`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
|
||||
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every
|
||||
request to your Honcho API is cross-origin. Honcho calls are `POST` +
|
||||
`application/json` + `Authorization: Bearer`, which the browser always
|
||||
**preflights** (`OPTIONS`). You must handle this one of two ways.
|
||||
|
||||
### 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:
|
||||
The desktop app routes HTTP through Rust and bypasses browser CORS; the web
|
||||
build doesn't. The Compose setup above **solves CORS via the same-origin proxy**
|
||||
— nothing to configure on Honcho. If instead you point the UI at a *different*
|
||||
origin (absolute `OPENCONCHO_DEFAULT_HONCHO_URL` or a URL typed in Settings),
|
||||
allow that origin in Honcho's FastAPI `CORSMiddleware`:
|
||||
|
||||
```python
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8080"], # the OpenConcho origin
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["https://your-ui-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.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenConcho</title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
4
packages/web/public/config.js
Normal file
4
packages/web/public/config.js
Normal 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__ = "";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { httpFetch } from "@/lib/http";
|
||||
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
|
||||
|
||||
const LEGACY_KEY = "openconcho:config";
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
@@ -73,6 +74,18 @@ export function loadStore(): InstanceStore {
|
||||
}
|
||||
const migrated = migrateLegacy();
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
24
packages/web/src/lib/runtimeConfig.ts
Normal file
24
packages/web/src/lib/runtimeConfig.ts
Normal 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;
|
||||
}
|
||||
29
packages/web/src/test/runtime-config.test.ts
Normal file
29
packages/web/src/test/runtime-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user