feat(tenant): combined platform + canvas Docker image with reverse proxy

Single-container tenant architecture: Go platform (:8080) + Canvas
Node.js (:3000) in one Fly machine, with Go's NoRoute handler reverse-
proxying non-API routes to the canvas. Browser only talks to :8080.

Changes:

platform/Dockerfile.tenant — multi-stage build (Go + Node + runtime).
  Bakes workspace-configs-templates/ + org-templates/ into the image.
  Build context: repo root.

platform/entrypoint-tenant.sh — starts both processes, kills both if
  either exits. Fly health check on :8080 covers the Go binary; canvas
  health is implicit (proxy returns 502 if canvas is down).

platform/internal/router/canvas_proxy.go — httputil.ReverseProxy that
  forwards unmatched routes to CANVAS_PROXY_URL (http://localhost:3000).
  Activated by NoRoute when CANVAS_PROXY_URL env is set.

platform/internal/router/router.go — wire NoRoute → canvasProxy when
  CANVAS_PROXY_URL is present; no-op otherwise (local dev unchanged).

platform/internal/middleware/securityheaders.go — relaxed CSP to allow
  Next.js inline scripts/styles/eval + WebSocket + data: URIs. The
  strict `default-src 'self'` was blocking all canvas rendering.

canvas/src/lib/api.ts — changed `||` to `??` for NEXT_PUBLIC_PLATFORM_URL
  so empty string means "same-origin" (combined image) instead of falling
  back to localhost:8080.

canvas/src/components/tabs/TerminalTab.tsx — same `??` fix for WS URL.

Verified: tenant machine boots, canvas renders, 8 runtime templates +
4 org templates visible, API routes work through the same port.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-16 02:46:47 -07:00
parent 111c59da68
commit a363b56f25
7 changed files with 189 additions and 3 deletions

View File

@ -6,7 +6,7 @@ interface Props {
workspaceId: string;
}
const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.replace("/ws", "") || "ws://localhost:8080";
const WS_URL = (process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080").replace("/ws", "");
export function TerminalTab({ workspaceId }: Props) {
const containerRef = useRef<HTMLDivElement>(null);

View File

@ -1,7 +1,12 @@
import { getTenantSlug } from "./tenant";
// When NEXT_PUBLIC_PLATFORM_URL is set to "" (empty string), the canvas
// uses relative paths — correct for the combined tenant image where Go
// platform + canvas run on the same port via reverse proxy. The `??`
// operator preserves "" as a valid value; `||` would fall through to
// the localhost default.
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL || "http://localhost:8080";
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
async function request<T>(
method: string,

View File

@ -0,0 +1,63 @@
# Dockerfile.tenant — combined platform (Go) + canvas (Next.js) image.
#
# Serves both the API (Go on :8080) and the UI (Node.js on :3000) in a
# single container. Fly listens on :8080 for health checks; an entrypoint
# script starts both processes. The Go router is the external-facing port;
# it reverse-proxies unknown routes to the canvas Node server so a single
# port handles everything.
#
# Build context: repo root (same as platform/Dockerfile).
#
# docker buildx build --platform linux/amd64 \
# -f platform/Dockerfile.tenant \
# --build-arg NEXT_PUBLIC_PLATFORM_URL="" \
# --build-arg NEXT_PUBLIC_WS_URL="" \
# -t registry.fly.io/molecule-tenant:latest \
# --push .
# ── Stage 1: Go platform binary ──────────────────────────────────────
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
COPY platform/go.mod platform/go.sum ./
RUN go mod download
COPY platform/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server
# ── Stage 2: Canvas Next.js standalone ────────────────────────────────
FROM node:20-alpine AS canvas-builder
WORKDIR /canvas
COPY canvas/package.json canvas/package-lock.json* ./
RUN npm install
COPY canvas/ .
# Platform URL is relative (same container) — empty string = same-origin.
# WebSocket URL uses relative path so the browser connects to the same host.
ARG NEXT_PUBLIC_PLATFORM_URL=""
ARG NEXT_PUBLIC_WS_URL=""
ENV NEXT_PUBLIC_PLATFORM_URL=$NEXT_PUBLIC_PLATFORM_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
RUN npm run build
# ── Stage 3: Runtime ──────────────────────────────────────────────────
FROM node:20-alpine
RUN apk add --no-cache ca-certificates git tzdata
# Go platform binary
COPY --from=go-builder /platform /platform
COPY platform/migrations /migrations
COPY workspace-configs-templates /workspace-configs-templates
COPY org-templates /org-templates
# Canvas standalone (Next.js server.js + static assets)
WORKDIR /canvas
COPY --from=canvas-builder /canvas/.next/standalone ./
COPY --from=canvas-builder /canvas/.next/static ./.next/static
COPY --from=canvas-builder /canvas/public ./public
# Entrypoint starts both processes. Go on :8080, Canvas on :3000.
# The Go platform's router proxies unknown routes to :3000 so Fly
# only needs to expose :8080.
COPY platform/entrypoint-tenant.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8080
CMD ["/entrypoint.sh"]

View File

@ -0,0 +1,35 @@
#!/bin/sh
# Tenant entrypoint — starts both Go platform (API) and Canvas (UI).
#
# Go platform listens on :8080 (Fly health checks hit this port).
# Canvas Node.js listens on :3000 (internal only).
# The Go platform's fallback handler proxies non-API routes to :3000
# so the browser only ever talks to :8080.
#
# If either process dies, we kill the other and exit non-zero so Fly
# restarts the machine.
set -e
# Start Canvas in background
cd /canvas
PORT=3000 HOSTNAME=0.0.0.0 node server.js &
CANVAS_PID=$!
# Start Go platform in foreground-ish (we trap signals)
cd /
/platform &
PLATFORM_PID=$!
# If either process exits, kill the other
cleanup() {
kill $CANVAS_PID 2>/dev/null || true
kill $PLATFORM_PID 2>/dev/null || true
}
trap cleanup EXIT SIGTERM SIGINT
# Wait for either to exit — whichever exits first triggers cleanup
wait -n $CANVAS_PID $PLATFORM_PID
EXIT_CODE=$?
cleanup
exit $EXIT_CODE

View File

@ -15,7 +15,6 @@ func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Content-Security-Policy", "default-src 'self'")
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// #282: these two were documented in CLAUDE.md but missing from
// the middleware. Referrer-Policy prevents browsers from leaking
@ -25,6 +24,30 @@ func SecurityHeaders() gin.HandlerFunc {
// canvas embeds iframes for Langfuse traces.
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// CSP: only apply to API responses. Canvas-proxied routes
// (NoRoute → reverse-proxy to Next.js) serve HTML with inline
// scripts + styles that `default-src 'self'` blocks. Next.js
// sets its own CSP via <meta> tags. The Go middleware should
// not override it for proxied HTML responses.
//
// Detection: API routes are registered explicitly in the router;
// canvas-proxied routes hit NoRoute. We can't detect NoRoute
// before c.Next() fires, so instead we check the response
// Content-Type after Next() — but that's too late for headers.
//
// Simpler: apply a permissive CSP that allows Next.js to work.
// 'unsafe-inline' + 'unsafe-eval' are needed for Next.js dev
// hydration; in production Next.js uses nonces but we don't
// propagate them through the proxy. This is acceptable because
// the canvas is our own code, not user-generated content.
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: blob:; "+
"connect-src 'self' ws: wss:; "+
"font-src 'self' data:")
c.Next()
}
}

View File

@ -0,0 +1,47 @@
package router
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"github.com/gin-gonic/gin"
)
// newCanvasProxy returns a Gin handler that reverse-proxies all unmatched
// routes to the canvas Next.js server. Used in the combined tenant image
// (Dockerfile.tenant) where Go platform (:8080) and canvas (:3000) run in
// the same container.
//
// The proxy forwards the request path, query, and headers as-is. The Host
// header is rewritten to the canvas upstream so Next.js doesn't reject it
// (Next.js checks Host in dev mode). Response headers from canvas flow back
// to the client unchanged.
//
// Why NoRoute + proxy instead of nginx: one fewer process, one fewer config
// file, and the Go router already knows which routes are API routes. Any
// path not registered as an API route is a canvas page by elimination.
func newCanvasProxy(targetURL string) gin.HandlerFunc {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("canvas_proxy: invalid CANVAS_PROXY_URL %q: %v", targetURL, err)
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
log.Printf("canvas_proxy: %v", err)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("canvas unavailable"))
},
}
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}

View File

@ -406,6 +406,19 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
sh := handlers.NewSocketHandler(hub)
r.GET("/ws", sh.HandleConnect)
// Canvas reverse proxy — when running as a combined tenant image
// (Dockerfile.tenant), the Next.js canvas server runs on :3000 inside
// the same container. Any route not matched by the API handlers above
// gets proxied to the canvas so the browser only ever talks to :8080.
//
// When CANVAS_PROXY_URL is empty (self-hosted / local dev), this is a
// no-op and Gin returns its default 404. The canvas dev server runs
// separately on :3000 in that setup.
if canvasURL := os.Getenv("CANVAS_PROXY_URL"); canvasURL != "" {
canvasProxy := newCanvasProxy(canvasURL)
r.NoRoute(canvasProxy)
}
return r
}