feat(docker): add containerized web build with CORS guidance
Multi-stage Dockerfile (node:22-alpine + pnpm build -> nginx-unprivileged serve) producing a non-root image that runs under read-only fs + cap_drop ALL. nginx.conf does SPA-fallback routing, /healthz, immutable asset cache, and gzip, plus an opt-in same-origin reverse-proxy block. docs/docker.md documents build/run and the two ways to handle browser CORS on the web build (configure Honcho's CORSMiddleware, or the nginx proxy). The Dockerfile and nginx.conf are adapted from @zmarakjanbangash's fork. Co-authored-by: zmarakjanbangash <zmarakjanbangash@users.noreply.github.com>
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/target
|
||||||
|
**/.turbo
|
||||||
|
**/.vite
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
packages/desktop/src-tauri/target
|
||||||
|
e2e
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
*.log
|
||||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -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
docker/nginx.conf
Normal file
54
docker/nginx.conf
Normal file
@@ -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
docs/docker.md
Normal file
65
docs/docker.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user