Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**/node_modules
**/dist
**/target
**/.turbo
**/.vite
.git
.github
packages/desktop/src-tauri/target
e2e
playwright-report
test-results
*.log
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# OpenConcho web SPA — self-hosted Honcho dashboard.
#
# Multi-stage build:
# 1. node:22-alpine + pnpm builds the @openconcho/web SPA to packages/web/dist
# 2. nginx-unprivileged serves the static bundle as non-root (UID 101) on
# port 8080 — runs cleanly under read-only filesystem + cap_drop ALL.

# ---------- Builder stage ----------
FROM node:22-alpine AS builder

RUN corepack enable \
&& corepack prepare pnpm@10.33.2 --activate

WORKDIR /app

# Copy workspace/lockfile/manifests first for layer-cache efficiency.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc .pnpmfile.cjs ./
COPY packages/web/package.json packages/web/
COPY packages/desktop/package.json packages/desktop/

# Install only the web filter's transitive deps (skips the Tauri Rust toolchain).
RUN pnpm install --frozen-lockfile --filter @openconcho/web...

# Copy remaining sources + build.
COPY . .
RUN pnpm --filter @openconcho/web build

# ---------- Runtime stage ----------
# Unprivileged variant runs as UID 101 with no root setup steps, so it works
# under a read-only filesystem with cap_drop ALL.
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

EXPOSE 8080

# Base image CMD runs nginx in the foreground as UID 101.
54 changes: 54 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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;
}
65 changes: 65 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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.

## Build and run

```bash
docker build -t openconcho-web .
docker run --rm -p 8080:8080 openconcho-web
# → http://localhost:8080
```

Hardened run (read-only filesystem, no added capabilities):

```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
```

`GET /healthz` returns `200 ok` for container health checks.

## CORS

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:

```python
app.add_middleware(
CORSMiddleware,
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.
Loading