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.
The browser issues every Honcho call same-origin to /api/* and names the real
upstream per request in an X-Honcho-Upstream header (sourced from the active
instance's base URL). nginx strips /api, forwards to that upstream server-side,
and returns the response. Because the browser→nginx hop is same-origin, no CORS
applies; the nginx→Honcho hop is server-side, where CORS is irrelevant. The
frontend stays the source of truth for which instance to talk to, so the
multi-instance switcher and the Fleet view keep working.
One docker-compose.yml with two profiles; the shared
config (env, ports, extra_hosts) is defined once via a YAML anchor:
devprofile —build: ., runs your local source (make up).prodprofile — pulls the published image (ghcr.io/offendingcommit/openconcho-web:latest,pull_policy: always) (make prod).
make up # build from source + run → http://localhost:8080
make prod # pull ghcr…:latest instead of building
make down # stop + remove (either profile)
make clean # down + drop the locally built imagemake up expands to docker compose --profile dev up -d --build and make prod
to docker compose --profile prod up -d. A bare docker compose up (no profile)
starts nothing — use the make targets or pass --profile. Set env inline or via a
.env file:
OPENCONCHO_DEFAULT_HONCHO_URL=https://honcho.example.net make prodThe published image is multi-arch (amd64 + arm64); the first publish creates a private GHCR package — make it public for unauthenticated pulls.
Drop the openconcho service into the project that runs your Honcho api,
pointing the seed at the api service (nginx resolves it on the compose network):
services:
openconcho:
image: ghcr.io/offendingcommit/openconcho-web:latest
environment:
OPENCONCHO_DEFAULT_HONCHO_URL: http://api:8000
ports:
- "127.0.0.1:8080:8080"
depends_on:
api:
condition: service_healthy
restart: unless-stoppedOPENCONCHO_DEFAULT_HONCHO_URL seeds the UI's first instance with an absolute
URL. The browser sends that URL in the X-Honcho-Upstream header; nginx (on the
compose network) forwards to it — no browser CORS, and the API token never
leaves the origin.
docker run --rm -p 8080:8080 -e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 \
ghcr.io/offendingcommit/openconcho-web:latest
# → http://localhost:8080 · GET /healthz returns "ok"Runtime knobs (no rebuild needed):
| Env | Default | Meaning |
|---|---|---|
OPENCONCHO_DEFAULT_HONCHO_URL |
(empty) | Absolute URL seeding the first instance; empty = configure in Settings |
OPENCONCHO_UPSTREAM_ALLOWLIST |
(empty) | Optional SSRF guard: comma-separated host globs (e.g. honcho.example.net,*.honcho.dev). Empty = forward anywhere |
OPENCONCHO_PORT |
8080 |
nginx listen port inside the container (IPv4 and IPv6). Unset or empty keeps 8080. Decimal 1-65535, no leading zeros. |
OPENCONCHO_PORT is the process listen port, not the published host port. On a
bridge network docker run -p 9090:8080 already remaps localhost without a
rebuild. Set the env when the process itself must listen elsewhere (host
networking, or a required container port). The image runs as UID 101, so ports
below 1024 fail to bind unless the host allows unprivileged low ports. Compose
publishes 127.0.0.1:8080:${OPENCONCHO_PORT:-8080} so the target matches the
listener; change the left-hand host port separately if you only need a different
localhost port.
Hardened run adds --read-only --cap-drop ALL --security-opt no-new-privileges
with tmpfs on /tmp, /var/cache/nginx, /var/run, and /etc/nginx/conf.d.
Those tmpfs mounts must be writable by UID 101 (uid=101,gid=101 on docker run --tmpfs, or fsGroup: 101 in the chart). The entrypoint writes config.js, the
allowlist map, and the listen port at start, which a read-only root blocks
without those mounts.
To host the UI under a path such as https://example.net/honcho/, build with the
BASE_PATH build arg (leading and trailing slash required) and let your reverse
proxy strip the prefix:
docker build --build-arg BASE_PATH=/honcho/ -t openconcho-web:honcho .
docker run -d -p 127.0.0.1:8080:8080 \
-e OPENCONCHO_DEFAULT_HONCHO_URL=http://host.docker.internal:8000 openconcho-web:honcholocation = /honcho { return 308 /honcho/; }
location /honcho/ {
proxy_pass http://127.0.0.1:8080/; # trailing slash strips /honcho
}Assets, routes, config.js, and the /api proxy all resolve under the prefix
(/honcho/api/*). Outside Docker, pass the same flag to the web build:
pnpm --filter @openconcho/web build --base /honcho/.
The header-driven proxy forwards to whatever upstream the client names. With the
default 127.0.0.1:8080 binding only your own machine can reach nginx, so leaving
the allowlist open is fine. Before exposing the proxy (e.g. behind a tunnel),
set OPENCONCHO_UPSTREAM_ALLOWLIST to the host globs you trust — non-matching
upstreams are rejected with 403 and an X-Honcho-Proxy-Reject: allowlist header.
The desktop app routes HTTP through Rust (reqwest) and bypasses browser CORS; the
web build solves it with the same-origin /api proxy above — nothing to
configure on Honcho. The proxy makes a Honcho-side CORSMiddleware unnecessary
regardless of which instance you point at.