Skip to content

Commit 04104d1

Browse files
Merge pull request #43 from offendingcommit/feat/docker-compose-support
feat(docker): full self-hosted Compose support
2 parents ab69b00 + 282ba1b commit 04104d1

12 files changed

Lines changed: 276 additions & 104 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Publish web image
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
packages: write
11+
12+
jobs:
13+
publish:
14+
name: Build & push web image to GHCR
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: docker/setup-qemu-action@v3
20+
- uses: docker/setup-buildx-action@v3
21+
22+
- uses: docker/login-action@v3
23+
with:
24+
registry: ghcr.io
25+
username: ${{ github.actor }}
26+
password: ${{ secrets.GITHUB_TOKEN }}
27+
28+
- id: meta
29+
uses: docker/metadata-action@v5
30+
with:
31+
images: ghcr.io/${{ github.repository_owner }}/openconcho-web
32+
tags: |
33+
type=semver,pattern={{version}}
34+
type=semver,pattern={{major}}.{{minor}}
35+
type=raw,value=latest
36+
type=sha,format=short
37+
38+
- uses: docker/build-push-action@v6
39+
with:
40+
context: .
41+
platforms: linux/amd64,linux/arm64
42+
push: true
43+
tags: ${{ steps.meta.outputs.tags }}
44+
labels: ${{ steps.meta.outputs.labels }}
45+
cache-from: type=gha
46+
cache-to: type=gha,mode=max

Dockerfile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@ RUN pnpm --filter @openconcho/web build
3131
FROM nginxinc/nginx-unprivileged:alpine
3232

3333
COPY --chown=101:101 --from=builder /app/packages/web/dist /usr/share/nginx/html
34-
COPY --chown=101:101 docker/nginx.conf /etc/nginx/conf.d/default.conf
34+
# Rendered to /etc/nginx/conf.d/default.conf by the image's envsubst entrypoint.
35+
COPY --chown=101:101 docker/nginx.conf.template /etc/nginx/templates/default.conf.template
36+
# Writes /usr/share/nginx/html/config.js from OPENCONCHO_DEFAULT_HONCHO_URL.
37+
# --chmod=0755 so nginx's docker-entrypoint.d actually executes it.
38+
COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypoint.d/40-openconcho-config.sh
39+
40+
# Defaults target the Honcho service in a typical Compose stack; override per deploy.
41+
ENV HONCHO_UPSTREAM=http://api:8000 \
42+
OPENCONCHO_DEFAULT_HONCHO_URL=same-origin
3543

3644
EXPOSE 8080
3745

38-
# Base image CMD runs nginx in the foreground as UID 101.
46+
# Base image entrypoint renders the template + runs config script, then nginx (UID 101).

docker-compose.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Example: add OpenConcho's web UI to a self-hosted Honcho Docker Compose stack.
2+
#
3+
# Drop this `openconcho` service into the Compose project that already runs your
4+
# Honcho `api` service. nginx proxies /v3 and /health to the API, so the browser
5+
# makes same-origin requests and never hits CORS.
6+
services:
7+
openconcho:
8+
image: ghcr.io/offendingcommit/openconcho-web:latest
9+
# Or build from this repo instead of pulling the published image:
10+
# build: .
11+
environment:
12+
# nginx reverse-proxies /v3 and /health to this upstream (the Honcho API).
13+
HONCHO_UPSTREAM: http://api:8000
14+
# The SPA defaults its Honcho base URL to its own origin, so requests flow
15+
# through the proxy above — no browser CORS, token never leaves the origin.
16+
# Set to an absolute URL instead to point the UI at a different backend.
17+
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
18+
ports:
19+
- "127.0.0.1:8080:8080"
20+
depends_on:
21+
api:
22+
condition: service_healthy
23+
restart: unless-stopped

docker/40-openconcho-config.sh

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

docker/nginx.conf

Lines changed: 0 additions & 54 deletions
This file was deleted.

docker/nginx.conf.template

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# OpenConcho — nginx site config (envsubst template).
2+
# The nginx image renders ${HONCHO_UPSTREAM} from the environment at start.
3+
# Serves the React SPA and reverse-proxies the Honcho API under the same origin.
4+
5+
server {
6+
listen 8080;
7+
listen [::]:8080;
8+
server_name _;
9+
root /usr/share/nginx/html;
10+
index index.html;
11+
12+
# Don't leak the nginx version.
13+
server_tokens off;
14+
15+
# Same-origin reverse proxy to Honcho so the browser never sees a
16+
# cross-origin request (no CORS). The variable + Docker resolver let nginx
17+
# start even when the upstream isn't resolvable yet, re-resolving per request.
18+
resolver 127.0.0.11 ipv6=off valid=10s;
19+
set $honcho_upstream "${HONCHO_UPSTREAM}";
20+
21+
# `^~` so these win over the static-asset regex below.
22+
location ^~ /v3/ {
23+
proxy_pass $honcho_upstream$request_uri;
24+
proxy_set_header Host $host;
25+
proxy_set_header X-Real-IP $remote_addr;
26+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
27+
proxy_set_header X-Forwarded-Proto $scheme;
28+
}
29+
location = /health {
30+
proxy_pass $honcho_upstream/health;
31+
proxy_set_header Host $host;
32+
}
33+
34+
# Runtime config — regenerated per container start, must never be cached.
35+
location = /config.js {
36+
add_header Cache-Control "no-cache, no-store, must-revalidate";
37+
try_files $uri =404;
38+
}
39+
40+
# Long-cache static assets — Vite hashes filenames so they're immutable.
41+
location ~* \.(?:js|mjs|css|woff2?|ttf|otf|eot|svg|png|jpg|jpeg|gif|ico|webp|avif|wasm)$ {
42+
expires 1y;
43+
add_header Cache-Control "public, immutable";
44+
access_log off;
45+
try_files $uri =404;
46+
}
47+
48+
# Container healthcheck (local; distinct from Honcho's /health).
49+
location = /healthz {
50+
access_log off;
51+
default_type text/plain;
52+
return 200 "ok\n";
53+
}
54+
55+
# SPA fallback: unknown paths return index.html so the router resolves them.
56+
location / {
57+
try_files $uri $uri/ /index.html;
58+
add_header Cache-Control "no-cache, no-store, must-revalidate";
59+
}
60+
61+
gzip on;
62+
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
63+
gzip_min_length 1024;
64+
}

docs/docker.md

Lines changed: 50 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,67 @@
11
# Running OpenConcho in Docker
22

3-
The `@openconcho/web` SPA can be served from a container. The image is a
4-
two-stage build: Node + pnpm builds the static bundle, then
5-
`nginx-unprivileged` serves it on port `8080` as a non-root user.
3+
The `@openconcho/web` SPA ships as a container: a two-stage build (Node + pnpm
4+
builds the static bundle, then `nginx-unprivileged` serves it on port `8080` as
5+
a non-root user) that also **reverse-proxies the Honcho API under its own
6+
origin**, so the browser never makes a cross-origin request.
67

7-
## Build and run
8+
## Add it to a Honcho Compose stack (recommended)
89

9-
```bash
10-
docker build -t openconcho-web .
11-
docker run --rm -p 8080:8080 openconcho-web
12-
# → http://localhost:8080
10+
Honcho's self-hosting path is Docker Compose. Drop the `openconcho` service from
11+
[`docker-compose.yml`](../docker-compose.yml) into the project that runs your
12+
Honcho `api`:
13+
14+
```yaml
15+
services:
16+
openconcho:
17+
image: ghcr.io/offendingcommit/openconcho-web:latest
18+
environment:
19+
HONCHO_UPSTREAM: http://api:8000 # nginx proxies /v3 + /health here
20+
OPENCONCHO_DEFAULT_HONCHO_URL: same-origin
21+
ports:
22+
- "127.0.0.1:8080:8080"
23+
depends_on:
24+
api:
25+
condition: service_healthy
26+
restart: unless-stopped
1327
```
1428
15-
Hardened run (read-only filesystem, no added capabilities):
29+
`OPENCONCHO_DEFAULT_HONCHO_URL: same-origin` makes the UI default its Honcho
30+
base URL to its own origin, so API calls go through the proxy → **no browser
31+
CORS, and the API token never leaves the origin.** The published image is
32+
multi-arch (amd64 + arm64); the first publish creates a private GHCR package —
33+
make it public if you want unauthenticated pulls.
34+
35+
## Standalone
1636

1737
```bash
18-
docker run --rm -p 8080:8080 \
19-
--read-only \
20-
--tmpfs /tmp \
21-
--tmpfs /var/cache/nginx \
22-
--cap-drop ALL \
23-
--security-opt no-new-privileges \
24-
openconcho-web
38+
docker build -t openconcho-web .
39+
docker run --rm -p 8080:8080 -e HONCHO_UPSTREAM=http://host.docker.internal:8000 openconcho-web
40+
# → http://localhost:8080 · GET /healthz returns "ok"
2541
```
2642

27-
`GET /healthz` returns `200 ok` for container health checks.
43+
Runtime knobs (no rebuild needed):
2844

29-
## CORS
45+
| Env | Default | Meaning |
46+
|-----|---------|---------|
47+
| `HONCHO_UPSTREAM` | `http://api:8000` | Where nginx proxies `/v3` and `/health` |
48+
| `OPENCONCHO_DEFAULT_HONCHO_URL` | `same-origin` | SPA's default base URL — `same-origin`, an absolute URL, or empty (configure in Settings) |
3049

31-
The desktop app routes HTTP through Rust (`reqwest`), so it is **not** subject
32-
to browser CORS. The **web build is**: it uses the browser's `fetch`, so every
33-
request to your Honcho API is cross-origin. Honcho calls are `POST` +
34-
`application/json` + `Authorization: Bearer`, which the browser always
35-
**preflights** (`OPTIONS`). You must handle this one of two ways.
50+
Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges`
51+
with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the runtime config writes
52+
`config.js` into the web root at start, which a read-only root blocks — under
53+
`--read-only` either bind-mount `config.js` or leave
54+
`OPENCONCHO_DEFAULT_HONCHO_URL` empty and set the URL in Settings.
3655

37-
### Option 1 — configure Honcho's CORS (recommended)
56+
## CORS, the short version
3857

39-
Honcho is a FastAPI service. Allow the UI's origin via its
40-
`CORSMiddleware` so preflight and actual requests succeed:
58+
The desktop app routes HTTP through Rust and bypasses browser CORS; the web
59+
build doesn't. The Compose setup above **solves CORS via the same-origin proxy**
60+
— nothing to configure on Honcho. If instead you point the UI at a *different*
61+
origin (absolute `OPENCONCHO_DEFAULT_HONCHO_URL` or a URL typed in Settings),
62+
allow that origin in Honcho's FastAPI `CORSMiddleware`:
4163

4264
```python
43-
app.add_middleware(
44-
CORSMiddleware,
45-
allow_origins=["http://localhost:8080"], # the OpenConcho origin
46-
allow_methods=["*"],
47-
allow_headers=["*"],
48-
)
65+
app.add_middleware(CORSMiddleware, allow_origins=["https://your-ui-origin"],
66+
allow_methods=["*"], allow_headers=["*"])
4967
```
50-
51-
This fits OpenConcho's model directly — the UI keeps using the absolute Honcho
52-
URL you enter in Settings (stored in `localStorage`). Since you self-host
53-
Honcho, you control this.
54-
55-
### Option 2 — same-origin reverse proxy (advanced)
56-
57-
Proxy the Honcho API under the same origin that serves the SPA, so the browser
58-
sees same-origin requests and CORS never applies (the token also never crosses
59-
origins). Uncomment the `location /honcho/` block in
60-
[`docker/nginx.conf`](../docker/nginx.conf) and set `proxy_pass` to your Honcho
61-
host.
62-
63-
Caveat: the Settings form currently validates the base URL as an **absolute**
64-
URL (`z.string().url()`), so pointing the UI at a relative same-origin path
65-
(`/honcho`) isn't wired yet. Until that lands, Option 1 is the supported path.

packages/web/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>OpenConcho</title>
88
<meta name="description" content="Frontend for self-hosted Honcho instances — browse memories, chat with memory context" />
9+
<!-- Runtime config (regenerated by the Docker image at start; no-op otherwise) -->
10+
<script src="/config.js"></script>
911
</head>
1012
<body>
1113
<div id="root"></div>

packages/web/public/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Runtime configuration placeholder. In the Docker image this file is
2+
// regenerated at container start from the OPENCONCHO_DEFAULT_HONCHO_URL env.
3+
// In dev and the desktop build it stays a no-op.
4+
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "";

packages/web/src/lib/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod";
22
import { httpFetch } from "@/lib/http";
3+
import { runtimeDefaultBaseUrl } from "@/lib/runtimeConfig";
34

45
const LEGACY_KEY = "openconcho:config";
56
const STORE_KEY = "openconcho:instances";
@@ -73,6 +74,18 @@ export function loadStore(): InstanceStore {
7374
}
7475
const migrated = migrateLegacy();
7576
if (migrated) return migrated;
77+
78+
// First-run default from a container's runtime config (no-op outside Docker).
79+
const runtimeUrl = runtimeDefaultBaseUrl();
80+
if (runtimeUrl) {
81+
const inst: Instance = {
82+
id: "runtime-default",
83+
name: "Honcho",
84+
baseUrl: runtimeUrl,
85+
token: "",
86+
};
87+
return { instances: [inst], activeId: inst.id };
88+
}
7689
return { instances: [], activeId: null };
7790
}
7891

0 commit comments

Comments
 (0)