diff --git a/README.md b/README.md
index edcd8404..bbe6f9dc 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,72 @@ OPENCHAMBER_OPENCODE_HOSTNAME=0.0.0.0 openchamber --port 3000
+
+systemd service (VPN / LAN access)
+
+Run OpenChamber and OpenCode as separate persistent services — useful when you want to access your
+dev machine over a VPN (e.g. Tailscale) or LAN without a Cloudflare tunnel.
+
+**How it works:**
+- OpenCode runs as its own service, binding only to `localhost`.
+- OpenChamber connects to it via `OPENCODE_HOST` and `--host 0.0.0.0` makes it reachable on your VPN IP.
+- `--foreground` keeps the CLI process alive so systemd can track and restart it.
+
+**`~/.config/systemd/user/opencode.service`**
+```ini
+[Unit]
+Description=OpenCode Server
+
+[Service]
+Type=simple
+ExecStart=opencode serve --port 4095
+Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/YOU/.local/bin:/home/YOU/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"
+Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+> **Why set `PATH` and `SSH_AUTH_SOCK`?**
+> systemd user services start with a minimal environment — no shell profile is sourced.
+> Without an explicit `PATH`, OpenCode won't find tools installed via Homebrew, npm, or `~/.local/bin`.
+> Without `SSH_AUTH_SOCK`, git operations over SSH (push, pull, clone) will fail because the agent socket isn't inherited.
+> Adjust the `PATH` to match your own tool installation paths.
+> `%t` expands to `$XDG_RUNTIME_DIR` (e.g. `/run/user/1000`), where most SSH agents write their socket.
+
+**`~/.config/systemd/user/openchamber.service`**
+```ini
+[Unit]
+Description=OpenChamber Web Server
+After=opencode.service
+
+[Service]
+Type=simple
+ExecStart=openchamber serve --port 3000 --host 0.0.0.0 --ui-password your-password --foreground
+Environment="OPENCODE_HOST=http://localhost:4095"
+Environment="OPENCODE_SKIP_START=true"
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+```bash
+systemctl --user daemon-reload
+systemctl --user enable --now opencode openchamber
+```
+
+OpenChamber will be reachable at `http://:3000` from any device on your VPN.
+
+> **Note:** `--host 0.0.0.0` is required to listen on all interfaces. The default
+> bind address is `127.0.0.1` (localhost only). Use `--host ` or
+> `OPENCHAMBER_HOST=` to bind to a specific interface instead.
+
+
+
Docker
diff --git a/packages/web/README.md b/packages/web/README.md
index 50259dc3..f9b2219d 100644
--- a/packages/web/README.md
+++ b/packages/web/README.md
@@ -119,6 +119,61 @@ openchamber stop # Stop background server
+
+systemd service (VPN / LAN access)
+
+Use `--foreground` to keep the CLI process alive so systemd (or any other process manager) can track and restart it. Combine with `OPENCODE_HOST` to connect to an OpenCode instance running as a separate service.
+
+**`~/.config/systemd/user/opencode.service`**
+```ini
+[Unit]
+Description=OpenCode Server
+
+[Service]
+Type=simple
+ExecStart=opencode serve --port 4095
+Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/YOU/.local/bin:/home/YOU/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"
+Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+> **Why set `PATH` and `SSH_AUTH_SOCK`?**
+> systemd user services start with a minimal environment — no shell profile is sourced.
+> Without an explicit `PATH`, OpenCode won't find tools installed via Homebrew, npm, or `~/.local/bin`.
+> Without `SSH_AUTH_SOCK`, git operations over SSH (push, pull, clone) will fail.
+> `%t` expands to `$XDG_RUNTIME_DIR` (e.g. `/run/user/1000`), where most SSH agents write their socket.
+
+**`~/.config/systemd/user/openchamber.service`**
+```ini
+[Unit]
+Description=OpenChamber Web Server
+After=opencode.service
+
+[Service]
+Type=simple
+ExecStart=openchamber serve --port 3000 --host 0.0.0.0 --ui-password your-password --foreground
+Environment="OPENCODE_HOST=http://localhost:4095"
+Environment="OPENCODE_SKIP_START=true"
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
+```
+
+```bash
+systemctl --user daemon-reload
+systemctl --user enable --now opencode openchamber
+```
+
+`--host 0.0.0.0` is required to listen on all interfaces (the default is `127.0.0.1`). Use `--host ` or `OPENCHAMBER_HOST=` to bind to a specific interface instead.
+
+
+
## What makes the web version special
- **Remote access** - Cloudflare tunnel with QR onboarding. Scan from your phone, start coding.
diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js
old mode 100644
new mode 100755
index 72a9c015..06ad47ff
--- a/packages/web/bin/cli.js
+++ b/packages/web/bin/cli.js
@@ -61,6 +61,8 @@ const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [cloudflareTunnelProviderCapabiliti
let onCancelCleanup = null;
let activeCommandOptions = null;
+let foregroundServerActive = false;
+let foregroundShutdown = null;
function setCancelCleanup(handler) {
onCancelCleanup = typeof handler === 'function' ? handler : null;
@@ -596,6 +598,7 @@ function parseArgs(argv = process.argv.slice(2)) {
quiet: false,
explicitPort: false,
explicitUiPassword: false,
+ foreground: false,
};
const removedFlagErrors = [];
@@ -788,6 +791,10 @@ function parseArgs(argv = process.argv.slice(2)) {
case 'v':
versionRequested = true;
break;
+ case 'foreground':
+ case 'no-daemon':
+ options.foreground = true;
+ break;
case 'daemon':
case 'd':
removedFlagErrors.push('`--daemon` was removed. OpenChamber now always runs in daemon mode.');
@@ -854,6 +861,8 @@ OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT})
--host Bind address (default: 127.0.0.1)
--ui-password Protect browser UI with single password
+ --foreground Run server in foreground (use with systemd/process managers)
+ --no-daemon Alias for --foreground
-h, --help Show help
-v, --version Show version
@@ -869,6 +878,7 @@ ENVIRONMENT:
EXAMPLES:
openchamber # Start in daemon mode on default port 3000 (or free port)
openchamber --port 8080 # Start on port 8080 (daemon)
+ openchamber serve --foreground # Start in foreground (for systemd Type=simple)
openchamber tunnel help # Show tunnel lifecycle help
openchamber logs # Follow logs for latest running instance
`);
@@ -966,7 +976,7 @@ _openchamber_tunnel() {
commands="serve stop restart status tunnel logs update"
tunnel_commands="help providers ready doctor status start stop profile completion"
profile_commands="list show add remove"
- common_flags="--port --json --all --help --version --plain --quiet"
+ common_flags="--port --foreground --no-daemon --json --all --help --version --plain --quiet"
start_flags="--provider --mode --profile --config --token --token-file --token-stdin --hostname --connect-ttl --session-ttl --qr --no-qr --dry-run --show-secrets"
if [[ \${COMP_CWORD} -eq 1 ]]; then
@@ -1072,6 +1082,8 @@ compdef _openchamber openchamber
# Save to ~/.config/fish/completions/openchamber.fish
complete -c openchamber -n '__fish_use_subcommand' -a 'serve' -d 'Start the web server'
+complete -c openchamber -n '__fish_seen_subcommand_from serve' -l foreground -d 'Run in foreground (for systemd/process managers)'
+complete -c openchamber -n '__fish_seen_subcommand_from serve' -l no-daemon -d 'Run in foreground (alias for --foreground)'
complete -c openchamber -n '__fish_use_subcommand' -a 'stop' -d 'Stop running instance(s)'
complete -c openchamber -n '__fish_use_subcommand' -a 'restart' -d 'Stop and start the server'
complete -c openchamber -n '__fish_use_subcommand' -a 'status' -d 'Show server status'
@@ -1738,6 +1750,7 @@ function writeInstanceOptions(instanceFilePath, options, onNotice) {
try {
const toStore = {
port: options.port,
+ launchMode: options.launchMode === 'foreground' ? 'foreground' : 'daemon',
uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : undefined,
hasUiPassword: typeof options.uiPassword === 'string',
startedAt: Number.isFinite(options.startedAt) ? options.startedAt : Date.now(),
@@ -1988,7 +2001,8 @@ async function discoverRunningInstances() {
if (Number.isFinite(storedOptions?.startedAt)) {
startedAt = storedOptions.startedAt;
}
- instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt });
+ const launchMode = storedOptions?.launchMode === 'foreground' ? 'foreground' : 'daemon';
+ instances.push({ port, pid, pidFilePath, instanceFilePath, mtime, startedAt, launchMode });
}
} catch {
}
@@ -2742,6 +2756,124 @@ const commands = {
console.warn(`Warning: ${warningLine}; ${warningDetail}`);
}
}
+ // Foreground mode: run server inline so the CLI process is the server process.
+ // Required for process managers like systemd (Type=simple) that track the
+ // direct child rather than a detached grandchild.
+ // IMPORTANT: foreground MUST remain inline (in-process). Do not convert to
+ // child-process orchestration — that causes shell job-control suspension.
+ if (options.foreground) {
+ if (isJsonMode(options)) {
+ throw new TunnelCliError(
+ '--json is not supported with --foreground. Use --json with background (daemon) mode instead.',
+ EXIT_CODE.USAGE_ERROR
+ );
+ }
+
+ // Propagate resolved values into env before importing the server module.
+ if (opencodeBinary) {
+ process.env.OPENCODE_BINARY = opencodeBinary;
+ }
+ if (effectiveUiPassword) {
+ process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
+ }
+
+ // In --quiet mode, redirect stdout/stderr to the log file so that
+ // server runtime output (console.log calls) does not pollute the
+ // deterministic CLI output contract. In plain human mode, close the
+ // log fd and let output go to the inherited terminal as before.
+ const suppressServerOutput = isQuietMode(options);
+ // Keep a reference to the real stdout.write so CLI output (port, JSON)
+ // can bypass the log-file redirect.
+ const realStdoutWrite = process.stdout.write.bind(process.stdout);
+ if (suppressServerOutput) {
+ const logStream = fs.createWriteStream(null, { fd: logFd });
+ process.stdout.write = (chunk, encoding, callback) => {
+ return logStream.write(chunk, encoding, callback);
+ };
+ process.stderr.write = (chunk, encoding, callback) => {
+ return logStream.write(chunk, encoding, callback);
+ };
+ } else {
+ // Close the log fd – in foreground human mode stdout/stderr are
+ // inherited from the parent (e.g. journald/terminal).
+ try {
+ fs.closeSync(logFd);
+ } catch {
+ }
+ }
+
+ if (!isQuietMode(options)) {
+ console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`);
+ }
+
+ const effectiveHost = typeof options.host === 'string' && options.host.length > 0
+ ? options.host : undefined;
+
+ const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
+ const controller = await startWebUiServer({
+ port: targetPort,
+ host: effectiveHost,
+ uiPassword: effectiveUiPassword,
+ attachSignals: false,
+ exitOnShutdown: false,
+ });
+
+ const resolvedPort = controller.getPort();
+
+ // Write PID / instance files so status, stop, and restart can discover
+ // this foreground instance the same way they discover daemon instances.
+ const fgPidFilePath = await getPidFilePath(resolvedPort);
+ const fgInstanceFilePath = await getInstanceFilePath(resolvedPort);
+ writePidFile(fgPidFilePath, process.pid, emitNotice);
+ writeInstanceOptions(fgInstanceFilePath, {
+ port: resolvedPort,
+ launchMode: 'foreground',
+ uiPassword: effectiveUiPassword,
+ }, emitNotice);
+
+ if (isQuietMode(options)) {
+ if (!options.suppressQuietOutput) {
+ realStdoutWrite(`${resolvedPort}\n`);
+ }
+ }
+
+ // Clean up PID / instance files.
+ const cleanupFiles = () => {
+ removePidFile(fgPidFilePath);
+ removeInstanceFile(fgInstanceFilePath);
+ };
+
+ process.on('exit', cleanupFiles);
+
+ // Idempotent graceful shutdown with deterministic exit codes.
+ let shutdownInProgress = false;
+ const shutdownForegroundServer = async (signal = 'SIGTERM') => {
+ if (shutdownInProgress) return;
+ shutdownInProgress = true;
+ try {
+ await controller.stop({ exitProcess: false });
+ } catch {
+ }
+ cleanupFiles();
+ foregroundServerActive = false;
+ foregroundShutdown = null;
+ const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143;
+ process.exit(exitCode);
+ };
+
+ // Expose shutdown to the global SIGINT handler.
+ foregroundShutdown = shutdownForegroundServer;
+ foregroundServerActive = true;
+
+ // Register signal handlers (additive, no removeAllListeners).
+ process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); });
+ process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); });
+ process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); });
+
+ // Block forever – the process stays alive until signalled.
+ await new Promise(() => {});
+ }
+
const serverArgs = [serverPath, '--port', String(targetPort)];
const effectiveHost = typeof options.host === 'string' && options.host.length > 0 ? options.host : undefined;
if (effectiveHost) {
@@ -2822,6 +2954,7 @@ const commands = {
writePidFile(pidFilePath, child.pid, emitNotice);
writeInstanceOptions(instanceFilePath, {
port: resolvedPort,
+ launchMode: 'daemon',
uiPassword: effectiveUiPassword,
}, emitNotice);
@@ -2830,6 +2963,7 @@ const commands = {
pid: child.pid,
url: buildLocalUrl(resolvedPort, '/'),
logs: `openchamber logs -p ${resolvedPort}`,
+ launchMode: 'daemon',
};
if (isJsonMode(options)) {
@@ -3091,9 +3225,14 @@ const commands = {
for (const instance of runningInstances) {
const storedOptions = readInstanceOptions(instance.instanceFilePath) || { port: instance.port };
+ const launchMode = instance.launchMode || 'daemon';
+ const isForeground = launchMode === 'foreground';
+
+ const restartPort = options.explicitPort ? options.port : instance.port;
+
const restartSpin = showOutput ? createSpinner(options) : null;
if (showOutput && !restartSpin) {
- logStatus('info', `restarting port ${instance.port}`);
+ logStatus('info', `restarting port ${instance.port}`, `mode: ${launchMode}`);
}
restartSpin?.start(`Restarting OpenChamber on port ${instance.port}...`);
try {
@@ -3103,9 +3242,24 @@ const commands = {
quiet: true,
suppressQuietOutput: true,
});
+
+ // Foreground instances are managed by a process manager (systemd,
+ // Docker, etc.) that will restart them automatically after stop.
+ // Do not call serve() here — just record the stop as a successful
+ // restart and let the process manager handle the actual restart.
+ if (isForeground) {
+ restarted.push({ fromPort: instance.port, toPort: restartPort, launchMode, ok: true });
+ restartSpin?.stop(`Stopped foreground instance on port ${instance.port} (process manager will restart)`);
+ if (showOutput && !restartSpin) {
+ logStatus('success', `port ${instance.port} stopped`, 'process manager will restart');
+ }
+ continue;
+ }
+
await new Promise((resolve) => setTimeout(resolve, 500));
+
const restartedPort = await this.serve({
- port: options.explicitPort ? options.port : (storedOptions.port || instance.port),
+ port: restartPort,
explicitPort: true,
uiPassword: options.explicitUiPassword ? options.uiPassword : storedOptions.uiPassword,
suppressStartupSummary: true,
@@ -3113,10 +3267,10 @@ const commands = {
suppressUiPasswordWarning: true,
suppressQuietOutput: true,
});
- restarted.push({ fromPort: instance.port, toPort: restartedPort, ok: true });
+ restarted.push({ fromPort: instance.port, toPort: restartedPort, launchMode, ok: true });
restartSpin?.stop(`Restarted OpenChamber on port ${restartedPort}`);
if (showOutput && !restartSpin) {
- logStatus('success', `port ${restartedPort} restarted`);
+ logStatus('success', `port ${restartedPort} restarted`, `mode: ${launchMode}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -3129,7 +3283,7 @@ const commands = {
}
if (isJsonMode(options)) {
- printJson({ restartedCount: restarted.length, results: restarted });
+ printJson({ restartedCount: restarted.length, results: restarted.map((r) => ({ ...r, launchMode: r.launchMode })) });
return;
}
@@ -3157,6 +3311,7 @@ const commands = {
runtime: 'desktop',
port: desktopInstance.port,
pid: Number.isFinite(desktopInstance.pid) ? desktopInstance.pid : null,
+ launchMode: null,
passwordProtected: null,
}
: null;
@@ -3170,6 +3325,7 @@ const commands = {
runtime: 'cli',
port: instance.port,
pid: instance.pid,
+ launchMode: instance.launchMode || 'daemon',
passwordProtected,
};
});
@@ -3194,7 +3350,7 @@ const commands = {
for (const instance of instances) {
process.stdout.write(
- `port ${instance.port} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
+ `port ${instance.port} mode:${instance.launchMode || 'n/a'} pass:${toPasswordProtectionLabel(instance.passwordProtected)}\n`
);
}
return;
@@ -3210,11 +3366,13 @@ const commands = {
for (const instance of instances) {
const pidSuffix = Number.isFinite(instance.pid) ? ` (PID: ${instance.pid})` : '';
+ const modeDetail = instance.launchMode ? `mode: ${instance.launchMode}` : '';
const protectionDetail = `password: ${toPasswordProtectionLabel(instance.passwordProtected)}`;
+ const detail = modeDetail ? `${modeDetail}; ${protectionDetail}` : protectionDetail;
if (instance.runtime === 'desktop') {
- logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, protectionDetail);
+ logStatus('info', `desktop app on port ${instance.port}${pidSuffix}`, detail);
} else {
- logStatus('success', `port ${instance.port}${pidSuffix}`, protectionDetail);
+ logStatus('success', `port ${instance.port}${pidSuffix}`, detail);
}
}
@@ -4646,6 +4804,12 @@ if (isCliExecution) {
if (isHandlingSigint) {
return;
}
+ if (foregroundServerActive) {
+ if (typeof foregroundShutdown === 'function') {
+ void foregroundShutdown('SIGINT');
+ }
+ return;
+ }
isHandlingSigint = true;
(async () => {
clackCancel('Operation cancelled.');