feat: single-port architecture and browser warning fixes (#390)

This commit is contained in:
Carlos Florêncio
2026-03-08 13:10:39 +00:00
committed by GitHub
parent e62498ba05
commit b48471e063
23 changed files with 144 additions and 234 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
# docker build -t kandev:latest .
#
# Run:
# docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data kandev:latest
# docker run -p 8080:8080 -v kandev-data:/data kandev:latest
# ---------------------------------------------------------------------------
# Stage 1: Go builder — compile kandev + agentctl binaries
@@ -110,7 +110,7 @@ RUN chown -R kandev:kandev /app /data
WORKDIR /app
EXPOSE 8080 3000
EXPOSE 8080
# tini as PID 1 for signal handling; entrypoint handles privilege drop
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
+31
View File
@@ -3,6 +3,9 @@ package main
import (
"context"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -383,6 +386,7 @@ type routeParams struct {
msgCreator *messageCreatorAdapter
secretsSvc *secrets.Service
secretStore secrets.SecretStore
webInternalURL string
log *logger.Logger
}
@@ -401,6 +405,33 @@ func registerRoutes(p routeParams) {
p.router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "kandev", "mode": "websocket+http"})
})
if p.webInternalURL != "" {
target, err := url.Parse(p.webInternalURL)
if err != nil {
p.log.Error("Invalid web internal URL, skipping reverse proxy", zap.String("url", p.webInternalURL), zap.Error(err))
} else {
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.FlushInterval = -1
// Custom error handler to prevent the default behavior of panicking
// with http.ErrAbortHandler when the upstream connection fails or the
// client disconnects. Without this, Gin's recovery middleware catches
// the panic and logs a full (harmless but noisy) stack trace.
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
p.log.Debug("Web proxy error", zap.Error(err))
w.WriteHeader(http.StatusBadGateway)
}
p.router.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/api/") || path == "/ws" || path == "/health" {
c.AbortWithStatus(http.StatusNotFound)
return
}
proxy.ServeHTTP(c.Writer, c.Request)
})
p.log.Info("Web reverse proxy enabled", zap.String("target", p.webInternalURL))
}
}
}
// registerTaskRoutes registers all task-related HTTP and WebSocket routes.
+1
View File
@@ -432,6 +432,7 @@ func buildHTTPServer(
msgCreator: msgCreator,
secretsSvc: secrets.NewService(repos.Secrets, log),
secretStore: repos.Secrets,
webInternalURL: cfg.Server.WebInternalURL,
log: log,
})
@@ -54,6 +54,7 @@ type ServerConfig struct {
Port int `mapstructure:"port"`
ReadTimeout int `mapstructure:"readTimeout"` // in seconds
WriteTimeout int `mapstructure:"writeTimeout"` // in seconds
WebInternalURL string `mapstructure:"webInternalUrl"`
}
// DatabaseConfig holds database connection configuration.
@@ -197,6 +198,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.port", 8080)
v.SetDefault("server.readTimeout", 30)
v.SetDefault("server.writeTimeout", 30)
v.SetDefault("server.webInternalUrl", "")
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev/data
v.SetDefault("dataDir", "")
@@ -312,6 +314,7 @@ func LoadWithPath(configPath string) (*Config, error) {
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
_ = v.BindEnv("server.webInternalUrl", "KANDEV_WEB_INTERNAL_URL")
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
+1 -2
View File
@@ -28,7 +28,7 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
KANDEV_MOCK_AGENT: process.env.KANDEV_MOCK_AGENT || "true",
};
const backendEnv = buildBackendEnv({ ports, extra });
const webEnv = buildWebEnv({ ports, includeMcp: true, debug: true });
const webEnv = buildWebEnv({ ports, debug: true });
const logLevel =
process.env.KANDEV_LOGGING_LEVEL?.trim() || process.env.KANDEV_LOG_LEVEL?.trim() || "info";
@@ -36,7 +36,6 @@ export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Pr
header: "dev mode: using local repo",
ports,
dbPath,
includeMcp: true,
logLevel,
});
+3 -3
View File
@@ -174,6 +174,7 @@ async function prepareReleaseBundle({
const backendEnv = {
...process.env,
KANDEV_SERVER_PORT: String(actualBackendPort),
KANDEV_WEB_INTERNAL_URL: `http://localhost:${actualWebPort}`,
KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort),
KANDEV_DATABASE_PATH: dbPath,
KANDEV_LOG_LEVEL: logLevel,
@@ -182,7 +183,6 @@ async function prepareReleaseBundle({
const webEnv: NodeJS.ProcessEnv = {
...process.env,
KANDEV_API_BASE_URL: backendUrl,
NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl,
PORT: String(actualWebPort),
};
(webEnv as Record<string, string>).NODE_ENV = "production";
@@ -264,6 +264,6 @@ export async function runRelease({ version, backendPort, webPort }: RunOptions):
quiet: true,
});
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
console.log(`[kandev] web ready at ${webUrl}`);
openBrowser(webUrl);
console.log("[kandev] ready at " + prepared.backendUrl);
openBrowser(prepared.backendUrl);
}
+7 -20
View File
@@ -67,6 +67,7 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
return {
...process.env,
KANDEV_SERVER_PORT: String(ports.backendPort),
KANDEV_WEB_INTERNAL_URL: `http://localhost:${ports.webPort}`,
KANDEV_AGENT_STANDALONE_PORT: String(ports.agentctlPort),
KANDEV_AGENT_MCP_SERVER_PORT: String(ports.mcpPort),
...(logLevel ? { KANDEV_LOG_LEVEL: logLevel } : {}),
@@ -76,8 +77,6 @@ export function buildBackendEnv(options: BackendEnvOptions): NodeJS.ProcessEnv {
export type WebEnvOptions = {
ports: PortConfig;
/** Include MCP URL environment variables (used in dev/start modes) */
includeMcp?: boolean;
/** Set NODE_ENV to production */
production?: boolean;
/** Enable debug mode (NEXT_PUBLIC_KANDEV_DEBUG) */
@@ -91,25 +90,18 @@ export type WebEnvOptions = {
* @returns Environment object for the web process
*/
export function buildWebEnv(options: WebEnvOptions): NodeJS.ProcessEnv {
const { ports, includeMcp = false, production = false, debug = false } = options;
const { ports, production = false, debug = false } = options;
// Server-side env vars use localhost (SSR runs on same machine as backend)
// Client-side uses NEXT_PUBLIC_*_PORT to build URLs dynamically from window.location.hostname
// This allows accessing the app from any device (iPhone, Tailscale, etc.)
const env: NodeJS.ProcessEnv = {
...process.env,
// Server-side: full localhost URL for SSR
// Server-side: full localhost URL for SSR fetches (Next.js SSR → Go backend)
KANDEV_API_BASE_URL: ports.backendUrl,
// Client-side: only pass ports, client builds URL from current hostname
// Client-side: port injection for dev mode (browser on :3000, API on :8080).
// In production, the backend reverse-proxies Next.js so the client uses same-origin.
NEXT_PUBLIC_KANDEV_API_PORT: String(ports.backendPort),
PORT: String(ports.webPort),
};
if (includeMcp) {
env.KANDEV_MCP_SERVER_URL = ports.mcpUrl;
env.NEXT_PUBLIC_KANDEV_MCP_PORT = String(ports.mcpPort);
}
if (production) {
(env as Record<string, string>).NODE_ENV = "production";
}
@@ -127,8 +119,6 @@ export type StartupInfoOptions = {
ports: PortConfig;
/** Database file path */
dbPath?: string;
/** Whether to log MCP-related ports */
includeMcp?: boolean;
/** Log level being used */
logLevel?: string;
};
@@ -137,15 +127,12 @@ export type StartupInfoOptions = {
* Logs a unified startup info block to the console.
*/
export function logStartupInfo(options: StartupInfoOptions): void {
const { header, ports, dbPath, includeMcp = false, logLevel } = options;
const { header, ports, dbPath, logLevel } = options;
console.log(`[kandev] ${header}`);
console.log("[kandev] backend port:", ports.backendPort);
console.log("[kandev] web port:", ports.webPort);
console.log("[kandev] url: http://localhost:" + ports.backendPort);
console.log("[kandev] agentctl port:", ports.agentctlPort);
if (includeMcp) {
console.log("[kandev] mcp port:", ports.mcpPort);
console.log("[kandev] mcp url:", ports.mcpUrl);
}
if (dbPath) {
console.log("[kandev] db path:", dbPath);
}
+3 -4
View File
@@ -121,13 +121,12 @@ export async function runStart({
...(debug ? { KANDEV_DEBUG_AGENT_MESSAGES: "true" } : {}),
},
});
const webEnv = buildWebEnv({ ports, includeMcp: true, production: true, debug });
const webEnv = buildWebEnv({ ports, production: true, debug });
logStartupInfo({
header: "start mode: using local build",
ports,
dbPath,
includeMcp: true,
logLevel,
});
@@ -168,6 +167,6 @@ export async function runStart({
});
await waitForUrlReady(webUrl, webProc, healthTimeoutMs);
console.log(`[kandev] web ready at ${webUrl}`);
openBrowser(webUrl);
console.log("[kandev] ready at " + ports.backendUrl);
openBrowser(ports.backendUrl);
}
+5 -26
View File
@@ -24,31 +24,15 @@ export const viewport: Viewport = {
userScalable: false,
};
/**
* Extract port from URL string (e.g., "http://localhost:8080" -> "8080")
*/
function extractPort(url: string): string | null {
try {
const parsed = new URL(url);
return parsed.port || null;
} catch {
return null;
}
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Runtime URL configuration for the browser client.
// KANDEV_PUBLIC_URL: full external URL when behind a reverse proxy / ingress
// (e.g., "https://kandev.example.com") — injected as window.__KANDEV_API_BASE_URL.
// Otherwise: extract ports so client builds URLs from window.location.hostname,
// allowing access from any device (iPhone, Tailscale, etc.)
const publicUrl = process.env.KANDEV_PUBLIC_URL?.replace(/\/$/, "") || null;
const apiPort = publicUrl ? null : extractPort(process.env.KANDEV_API_BASE_URL ?? "");
const mcpPort = extractPort(process.env.KANDEV_MCP_SERVER_URL ?? "");
// API port injection for dev mode (browser opens at web port, API on backend port).
// In production single-port mode, this is not needed — the client uses
// window.location.origin (same-origin, works for any domain / reverse proxy).
const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null;
const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true";
return (
@@ -57,16 +41,11 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-title" content="KanDev" />
</head>
<body className="antialiased font-sans">
{publicUrl || apiPort || mcpPort || debugMode ? (
// Inject runtime config for production bundles.
// publicUrl: full base URL for reverse-proxy deployments (same-origin API)
// ports: client builds URLs using window.location.hostname + port
{apiPort || debugMode ? (
<script
dangerouslySetInnerHTML={{
__html: [
publicUrl ? `window.__KANDEV_API_BASE_URL = ${JSON.stringify(publicUrl)};` : "",
apiPort ? `window.__KANDEV_API_PORT = ${JSON.stringify(apiPort)};` : "",
mcpPort ? `window.__KANDEV_MCP_PORT = ${JSON.stringify(mcpPort)};` : "",
debugMode ? `window.__KANDEV_DEBUG = true;` : "",
]
.filter(Boolean)
@@ -69,6 +69,14 @@ function initTerminalInstance(
} catch (e) {
log("Initial fit failed:", e);
}
refs.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon;
// Defer WebGL addon loading to the next animation frame so the initial
// synchronous work (Terminal + FitAddon + open) stays within the browser's
// frame budget and avoids "Violation: 'setTimeout' handler took Xms" warnings.
requestAnimationFrame(() => {
const term = refs.xtermRef.current;
if (!term || refs.webglAddonRef.current) return;
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
@@ -76,14 +84,13 @@ function initTerminalInstance(
webglAddon.dispose();
refs.webglAddonRef.current = null;
});
terminal.loadAddon(webglAddon);
term.loadAddon(webglAddon);
refs.webglAddonRef.current = webglAddon;
log("WebGL addon loaded");
} catch (e) {
log("WebGL failed, using canvas:", e);
}
refs.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon;
});
// Expose buffer reader on the container for e2e tests (xterm renders to
// canvas so text isn't accessible in the DOM).
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
@@ -278,7 +285,12 @@ function connectWebSocket({
attachAddonRef.current = null;
}
if (wsRef.current) {
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CLOSING
) {
wsRef.current.close();
}
wsRef.current = null;
}
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
@@ -402,7 +414,15 @@ export function useWebSocketConnection({
attachAddonRef.current = null;
}
if (wsRef.current) {
// Only close if the connection is actually open or has completed
// opening. Closing a CONNECTING WebSocket triggers a browser warning
// ("WebSocket is closed before the connection is established").
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CLOSING
) {
wsRef.current.close();
}
wsRef.current = null;
}
};
+1
View File
@@ -227,6 +227,7 @@ export const backendFixture = base.extend<object, { backend: BackendContext }>({
env: {
...(process.env as unknown as Record<string, string>),
KANDEV_API_BASE_URL: baseUrl,
NEXT_PUBLIC_KANDEV_API_PORT: String(backendPort),
PORT: String(frontendPort),
HOSTNAME: "localhost",
NODE_ENV: "production",
+5 -4
View File
@@ -106,11 +106,11 @@ export { expect } from "@playwright/test";
async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise<void> {
await page.addInitScript(
({
backendUrl,
backendPort,
repositoryId,
agentProfileId,
}: {
backendUrl: string;
backendPort: string;
repositoryId: string;
agentProfileId: string;
}) => {
@@ -121,10 +121,11 @@ async function setupPage(page: Page, backend: BackendContext, seedData: SeedData
localStorage.setItem("kandev.dialog.lastAgentProfileId", JSON.stringify(agentProfileId));
localStorage.setItem("kandev.dialog.lastBranch", JSON.stringify("main"));
// Set the window global that getBackendConfig() reads for API/WS connections
window.__KANDEV_API_BASE_URL = backendUrl;
// (e2e tests run frontend and backend on separate ports, like dev mode)
window.__KANDEV_API_PORT = backendPort;
},
{
backendUrl: backend.baseUrl,
backendPort: String(backend.port),
repositoryId: seedData.repositoryId,
agentProfileId: seedData.agentProfileId,
},
+1 -5
View File
@@ -2,12 +2,8 @@ export {};
declare global {
interface Window {
// Full URL overrides (for custom domains/proxies)
__KANDEV_API_BASE_URL?: string;
__KANDEV_MCP_SERVER_URL?: string;
// Port-only injection (client builds URL from window.location.hostname + port)
// Port injection for dev mode (browser on web port, API on backend port)
__KANDEV_API_PORT?: string;
__KANDEV_MCP_PORT?: string;
// Debug mode flag (injected at runtime by layout.tsx)
__KANDEV_DEBUG?: boolean;
}
+13 -48
View File
@@ -1,68 +1,33 @@
export type AppConfig = {
apiBaseUrl: string;
mcpServerUrl: string;
};
// Default ports
const DEFAULT_API_PORT = 8080;
const DEFAULT_MCP_PORT = 9090;
// Server-side defaults (used during SSR - always localhost since SSR runs on same machine)
const DEFAULT_API_BASE_URL = `http://localhost:${DEFAULT_API_PORT}`;
const DEFAULT_MCP_SERVER_URL = `http://localhost:${DEFAULT_MCP_PORT}/sse`;
// Server-side default (used during SSR - always localhost since SSR runs on same machine)
const DEFAULT_API_BASE_URL = "http://localhost:8080";
export const DEBUG_UI =
process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true" ||
(typeof window !== "undefined" && window.__KANDEV_DEBUG === true);
/**
* Build URL from current page hostname and port.
* This allows accessing the app from any device (iPhone, Tailscale, etc.)
* without manual configuration.
*/
function buildClientUrl(port: number, path = ""): string {
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
return `${protocol}//${window.location.hostname}:${port}${path}`;
}
export function getBackendConfig(): AppConfig {
// Server-side: use env vars or localhost defaults (SSR runs on same machine as backend)
if (typeof window === "undefined") {
return {
apiBaseUrl: process.env.KANDEV_API_BASE_URL ?? DEFAULT_API_BASE_URL,
mcpServerUrl: process.env.KANDEV_MCP_SERVER_URL ?? DEFAULT_MCP_SERVER_URL,
};
}
// Client-side: Build URLs from current hostname + port
// Priority:
// 1. Explicit full URL override via window.__KANDEV_API_BASE_URL (for custom domains/proxies)
// 2. Port from window.__KANDEV_API_PORT (injected by layout.tsx from server env)
// 3. Port from NEXT_PUBLIC env var (build-time)
// 4. Default port
// All port-based options build URL from window.location.hostname
if (window.__KANDEV_API_BASE_URL) {
// Full URL override - use as-is (for custom domains/proxies)
return {
apiBaseUrl: window.__KANDEV_API_BASE_URL,
mcpServerUrl: window.__KANDEV_MCP_SERVER_URL || buildClientUrl(DEFAULT_MCP_PORT, "/sse"),
};
// Client-side URL resolution:
// 1. Port-based URL via __KANDEV_API_PORT (dev mode: browser on :3000, API on :8080)
// 2. Same-origin (production: Go reverse-proxies Next.js on single port)
// Works for any hosting scenario: localhost, custom domain, Tailscale, etc.
if (window.__KANDEV_API_PORT) {
const port = parseInt(window.__KANDEV_API_PORT, 10);
if (Number.isInteger(port) && port > 0 && port <= 65535) {
const protocol = window.location.protocol;
return { apiBaseUrl: `${protocol}//${window.location.hostname}:${port}` };
}
}
// Build URLs dynamically from current hostname + port
const apiPort =
parseInt(window.__KANDEV_API_PORT || "", 10) ||
parseInt(process.env.NEXT_PUBLIC_KANDEV_API_PORT || "", 10) ||
DEFAULT_API_PORT;
const mcpPort =
parseInt(window.__KANDEV_MCP_PORT || "", 10) ||
parseInt(process.env.NEXT_PUBLIC_KANDEV_MCP_PORT || "", 10) ||
DEFAULT_MCP_PORT;
return {
apiBaseUrl: buildClientUrl(apiPort),
mcpServerUrl: buildClientUrl(mcpPort, "/sse"),
};
return { apiBaseUrl: window.location.origin };
}
@@ -8,7 +8,6 @@ import {
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({
apiBaseUrl: "http://localhost:8080",
mcpServerUrl: "http://localhost:9090/sse",
}),
}));
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Convert HTTP URL to WebSocket URL
* Convert HTTP URL to WebSocket URL.
* http://localhost:8080 → ws://localhost:8080/ws
* https://api.example.com → wss://api.example.com/ws
*/
+13 -41
View File
@@ -5,10 +5,10 @@ Run Kandev in a Docker container. For Kubernetes deployment, see [k8s.md](k8s.md
## Quick Start
```bash
docker run -p 8080:8080 -p 3000:3000 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
docker run -p 8080:8080 -v kandev-data:/data ghcr.io/kdlbs/kandev:latest
```
Open `http://localhost:3000` in your browser.
Open `http://localhost:8080` in your browser.
## Using the Pre-built Image
@@ -53,7 +53,7 @@ Without a volume, data is lost when the container is removed.
Configuration is done via `KANDEV_`-prefixed environment variables:
```bash
docker run -p 8080:8080 -p 3000:3000 \
docker run -p 8080:8080 \
-v kandev-data:/data \
-e KANDEV_LOG_LEVEL=debug \
ghcr.io/kdlbs/kandev:latest
@@ -63,7 +63,6 @@ docker run -p 8080:8080 -p 3000:3000 \
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
@@ -76,7 +75,7 @@ docker run -p 8080:8080 -p 3000:3000 \
To use PostgreSQL instead of SQLite:
```bash
docker run -p 8080:8080 -p 3000:3000 \
docker run -p 8080:8080 \
-e KANDEV_DATABASE_DRIVER=postgres \
-e KANDEV_DATABASE_HOST=host.docker.internal \
-e KANDEV_DATABASE_PORT=5432 \
@@ -86,20 +85,21 @@ docker run -p 8080:8080 -p 3000:3000 \
ghcr.io/kdlbs/kandev:latest
```
## Ports
## Port
Kandev exposes a single port. The Go backend serves the API, WebSocket, and reverse-proxies the Next.js frontend — all on one port:
| Port | Service |
|------|---------|
| `8080` | Backend API + WebSocket |
| `3000` | Web UI |
| `8080` | API + WebSocket + Web UI |
Override ports by changing the published ports and passing CLI flags:
Override the port:
```bash
docker run -p 9080:9080 -p 4000:4000 \
docker run -p 9080:9080 \
-v kandev-data:/data \
ghcr.io/kdlbs/kandev:latest \
kandev start --backend-port 9080 --web-port 4000
kandev start --backend-port 9080
```
## Docker Compose
@@ -112,7 +112,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest
ports:
- "8080:8080"
- "3000:3000"
volumes:
- kandev-data:/data
restart: unless-stopped
@@ -133,7 +132,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest
ports:
- "8080:8080"
- "3000:3000"
volumes:
- kandev-data:/data
environment:
@@ -170,20 +168,7 @@ volumes:
## Reverse Proxy
When running Kandev behind a reverse proxy (nginx, Traefik, Caddy) that serves both the API and web UI on the same domain, set `KANDEV_PUBLIC_URL` so the frontend routes API calls through the proxy instead of trying to reach port 8080 directly:
```bash
docker run -p 8080:8080 -p 3000:3000 \
-v kandev-data:/data \
-e KANDEV_PUBLIC_URL=https://kandev.example.com \
ghcr.io/kdlbs/kandev:latest
```
Your reverse proxy should route:
- `/api/*` and `/ws` → `http://kandev:8080` (backend)
- `/*` → `http://kandev:3000` (web UI)
With WebSocket support enabled (for `/ws`).
Since Kandev serves everything on a single port, a reverse proxy only needs to forward all traffic to port 8080. No extra environment variables are needed — the frontend automatically uses `window.location.origin` to reach the API.
### Docker Compose with Caddy
@@ -193,8 +178,6 @@ services:
image: ghcr.io/kdlbs/kandev:latest
volumes:
- kandev-data:/data
environment:
KANDEV_PUBLIC_URL: "https://kandev.example.com"
restart: unless-stopped
caddy:
@@ -216,19 +199,8 @@ Example `Caddyfile`:
```
kandev.example.com {
handle /api/* {
reverse_proxy kandev:8080
}
handle /ws {
reverse_proxy kandev:8080
}
handle /health {
reverse_proxy kandev:8080
}
handle {
reverse_proxy kandev:3000
}
}
```
## Docker-in-Docker (Agent Containers)
@@ -236,7 +208,7 @@ kandev.example.com {
By default, `KANDEV_DOCKER_ENABLED=false` inside the container. To enable Docker-based agent execution, mount the Docker socket:
```bash
docker run -p 8080:8080 -p 3000:3000 \
docker run -p 8080:8080 \
-v kandev-data:/data \
-v /var/run/docker.sock:/var/run/docker.sock \
-e KANDEV_DOCKER_ENABLED=true \
+7 -15
View File
@@ -56,7 +56,7 @@ kubectl logs -l app=kandev -f
| Resource | File | Purpose |
|----------|------|---------|
| Deployment | `deployment.yaml` | Single-replica pod running backend + web |
| Service | `service.yaml` | ClusterIP exposing ports 8080 (API) and 3000 (UI) |
| Service | `service.yaml` | ClusterIP exposing port 8080 |
| ConfigMap | `configmap.yaml` | Non-sensitive environment configuration |
| PVC | `pvc.yaml` | 10Gi persistent volume for SQLite + worktrees |
| Ingress | `ingress.yaml` | Example ingress with WebSocket support |
@@ -66,22 +66,15 @@ kubectl logs -l app=kandev -f
**Port-forward** (quickest for testing):
```bash
kubectl port-forward svc/kandev 3000:3000 8080:8080
# Open http://localhost:3000
kubectl port-forward svc/kandev 8080:8080
# Open http://localhost:8080
```
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes `/api` and `/ws` to the backend (port 8080) and everything else to the web UI (port 3000).
**Ingress**: Edit `k8s/ingress.yaml` to set your domain, then apply. The ingress routes all traffic to the backend on port 8080 (the Go backend reverse-proxies the Next.js frontend internally).
### Custom Domain / Reverse Proxy
When serving Kandev behind an ingress with a custom domain (e.g., `https://kandev.example.com`), set `KANDEV_PUBLIC_URL` so the frontend knows to use the ingress URL instead of trying to reach the backend on port 8080 directly:
```yaml
# In k8s/configmap.yaml
KANDEV_PUBLIC_URL: "https://kandev.example.com"
```
Without this, the browser tries to connect to `https://kandev.example.com:8080/api/...` which doesn't exist — only the ingress port (443) is exposed externally.
No extra configuration is needed. The frontend automatically uses `window.location.origin` to reach the API, which works with any domain, reverse proxy, or ingress setup.
## Configuration
@@ -91,8 +84,7 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
| Env Var | Default | Description |
|---------|---------|-------------|
| `KANDEV_PUBLIC_URL` | (unset) | External URL when behind a reverse proxy (e.g., `https://kandev.example.com`) |
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
| `KANDEV_SERVER_PORT` | `8080` | Server port (API + WebSocket + Web UI) |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
@@ -155,7 +147,7 @@ spec:
## Health Checks
The deployment includes both probes on the backend `/health` endpoint:
The deployment includes both probes on the `/health` endpoint:
- **Liveness probe**: Restarts the pod if the backend becomes unresponsive (30s interval, 3 failures)
- **Readiness probe**: Removes the pod from service during startup or issues (10s interval, 3 failures)
+3 -3
View File
@@ -122,9 +122,9 @@ Extends the backend fixture with:
- **`apiClient`** (worker-scoped) — HTTP client for seeding data via the backend API
- **`seedData`** (worker-scoped) — creates a workspace, discovers the default workflow and its steps
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, plus `localStorage` pre-configured:
- `kandev.onboarding.completed = "true"` — skips onboarding
- `window.__KANDEV_API_BASE_URL` — points to the worker's backend (client-side override)
- **`testPage`** (per-test) — Playwright page with a browser context whose `baseURL` points to the worker's frontend, pre-configured with:
- `kandev.onboarding.completed = "true"` in localStorage — skips onboarding
- `window.__KANDEV_API_PORT` injected via `addInitScript` — points to the worker's backend port
### API client (`helpers/api-client.ts`)
-5
View File
@@ -16,8 +16,3 @@ data:
# Logging — auto-detects K8s and uses JSON format
KANDEV_LOG_LEVEL: info
# Public URL — set when behind a reverse proxy / ingress that serves both
# web and API on the same domain. Without this, the UI tries to reach the
# backend API on port 8080 directly, which fails behind a proxy.
# KANDEV_PUBLIC_URL: "https://kandev.example.com"
-3
View File
@@ -24,9 +24,6 @@ spec:
- name: backend
containerPort: 8080
protocol: TCP
- name: web
containerPort: 3000
protocol: TCP
envFrom:
- configMapRef:
name: kandev-config
+1 -24
View File
@@ -18,33 +18,10 @@ spec:
- host: kandev.example.com
http:
paths:
# Backend API + WebSocket
- path: /api
pathType: Prefix
backend:
service:
name: kandev
port:
name: backend
- path: /ws
pathType: Prefix
backend:
service:
name: kandev
port:
name: backend
- path: /health
pathType: Exact
backend:
service:
name: kandev
port:
name: backend
# Web UI (catch-all)
- path: /
pathType: Prefix
backend:
service:
name: kandev
port:
name: web
name: http
+1 -5
View File
@@ -9,11 +9,7 @@ spec:
selector:
app: kandev
ports:
- name: backend
- name: http
port: 8080
targetPort: backend
protocol: TCP
- name: web
port: 3000
targetPort: web
protocol: TCP