Flowline
Admin · private

Hosting it yourself

Operator guide — private. This page is for whoever runs the server. End users never need it; they only follow Using a hosted server. It isn't linked from the public wiki.

You opt into hosted mode entirely through environment variables — the same code that runs locally also runs as a multi-user server.

On your laptop vs. on a hosted server

On your laptop On a hosted server
How it's reached npm run dev, then localhost:5174 A URL like https://flow.example.com
What's installed Node and this repo Nothing for users — a browser
Sign-in None Usually Google sign-in
Where projects live Files on your disk Each user's browser, computer folder, or Google Drive — not the server
Sharing Send files or sync Google Drive or git sync

How production serving works

In development, Vite serves the UI on :5174 and proxies /api to the API on :8787. In production there is one origin:

  1. npm run build compiles the React app into dist/ and packages the downloadable Flowline helper into downloads/flowline-helper.tgz.
  2. npm run start runs the server with NODE_ENV=production. It serves dist/ as static files and the API, both on PORT (default 8787), with a SPA fallback so client-side routes resolve. The helper tarball is served at /download/flowline-helper.tgz.

One origin means no CORS or cross-port cookie problems, and the session cookie is marked Secure automatically in production — so you must terminate HTTPS in front.

Deploy it

  1. Build on the server (or build in CI and ship dist/):

    npm ci
    npm run build
    
  2. Set the environment. Put these in a .env file next to the app, or in your process manager's environment:

    NODE_ENV=production
    PORT=8787
    APP_BASE_URL=https://flow.example.com   # the public URL users visit
    APP_CONFIG_DIR=/var/lib/flow/app         # per-user registries, sessions, OAuth config
    
  3. Start it, ideally under a process manager (systemd, pm2, or a container) so it restarts on crash and on boot:

    npm run start
    

    You should see API listening on http://localhost:8787.

  4. Put HTTPS in front. Terminate TLS at a reverse proxy (nginx, Caddy, Traefik) and forward to 127.0.0.1:8787. A minimal nginx location:

    location / {
      proxy_pass http://127.0.0.1:8787;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-Proto $scheme;
    }
    
    # The live channel is a WebSocket: pass the upgrade through.
    location /api/live {
      proxy_pass http://127.0.0.1:8787;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_set_header Host $host;
    }
    

    Behind this proxy, APP_BASE_URL must be the public https:// URL, or the live channel refuses every browser (The live channel, below).

    Bind the app to localhost and let the proxy be the only public listener.

Users now open https://flow.example.com and get the full tool.

After an upgrade that changes the file API, a browser tab still running the old app gets This Flowline client is out of date on save until it reloads, and the web app refuses an older flowline-mcp helper. Tell users to reload and update the helper (09). Who updates first, and what a team sees while only some have: Release notes.

Require sign-in (multi-user)

Without auth, anyone who can reach the URL has full access as a single shared identity. Turn on Google sign-in to give each person their own account and private data.

  1. Create a Google OAuth client for login. This is separate from the Google Drive client — do not reuse it. In the Google Cloud console, add an authorized redirect URI of:

    https://flow.example.com/api/auth/google/callback
    
  2. Set the credentials and public origin:

    APP_BASE_URL=https://flow.example.com
    GOOGLE_LOGIN_CLIENT_ID=...apps.googleusercontent.com
    GOOGLE_LOGIN_CLIENT_SECRET=...
    

    Auth switches on automatically once both login credentials are set (or force it with AUTH_ENABLED=true). The redirect URI defaults to ${APP_BASE_URL}/api/auth/google/callback; override it with GOOGLE_LOGIN_REDIRECT_URI if needed.

  3. Restart. The SPA now shows a login screen, gated by GET /api/auth/me, and every /api/* data route requires a session (it returns 401 otherwise). The flow is:

    • GET /api/auth/google/start → redirects to Google with a CSRF state cookie.
    • GET /api/auth/google/callback → validates state, creates a session, sets the session cookie, and returns to the app.
    • POST /api/auth/logout → ends the session and clears the cookie.

The session cookie is opaque, httpOnly, SameSite=Lax, and Secure in production — so sign-in requires HTTPS. All OAuth redirects route through APP_BASE_URL, which is why it must be your real public origin.

Each signed-in account gets its own workspace registry and Drive and GitHub grants under APP_CONFIG_DIR/users/<id>/. Accounts don't see each other's data; teammates collaborate by pointing workspaces at a shared Google Drive folder or git repo.

Confine workspaces on a shared host

Locally you can point a workspace at any folder, and the folder picker browses your machine. On a shared server that would let any signed-in user browse and open arbitrary paths on the host. When sign-in is enabled, this is locked down automatically: ALLOW_ARBITRARY_WORKSPACE_PATHS defaults to false, so the host filesystem cannot be browsed (/api/fs/list returns 403) and arbitrary local workspace paths are rejected. Signed-in users create browser, computer-folder, or Google Drive workspaces instead.

To instead offer a confined local sandbox, opt local folder workspaces back in and set a root:

LOCAL_WORKSPACES_ENABLED=true
ALLOW_ARBITRARY_WORKSPACE_PATHS=false
WORKSPACE_ROOT=/var/lib/flow/workspaces

The folder picker is then confined to WORKSPACE_ROOT, new local workspaces must live under it, and paths outside it are rejected. Google Drive workspaces still work for per-user cloud storage — see Google Drive setup.

A server MCP connection sees only server-side workspaces. It reads the server's REST API, so list_workspaces returns Google Drive and server-folder workspaces and never browser workspaces. Set LOCAL_WORKSPACES_ENABLED=true if you want MCP clients to reach server-folder workspaces. A folder on a user's own computer is reached the other way: they run the flowline-mcp helper on that machine and connect their assistant to it — see MCP.

Data and backups

These directories hold the server's state. Put them on persistent, backed-up storage, along with any server-folder workspaces (WORKSPACE_ROOT):

Directory Env var Contains
App config APP_CONFIG_DIR Per-user workspace registries, OAuth config, and (by default) sessions.
Sessions SESSION_DIR One file per login session (defaults to APP_CONFIG_DIR/sessions).

Run a single instance per APP_CONFIG_DIR. Two servers sharing one app-config folder can overwrite each other's registries. Scale with one process and a proxy, not multiple processes on the same data dir.

Driving a server with MCP

The server exposes a Streamable-HTTP MCP endpoint at /mcp (server/mcp/httpTransport.ts). This is the route to a sign-in-protected instance: each session authenticates with a personal access token sent as Authorization: Bearer fl_pat_…, the server resolves that token to a user, and the session reads and writes exactly the workspaces that user can reach.

  1. A user signs in and creates a token in the MCP access tokens panel of their profile. The token is shown once and stored only as a SHA-256 hash; they revoke it from the same panel.
  2. They point their assistant at https://<your-host>/mcp, sending the token as a bearer header. The client-by-client steps are in MCP.

Serve /mcp over HTTPS. On a public deployment, list your public host(s) in MCP_ALLOWED_HOSTS — that is what turns DNS-rebinding protection on.

The stdio server (npm run mcp) is the self-host alternative. It sends no credentials, so it can only reach an instance with sign-in turned off:

FLOW_API_URL=http://127.0.0.1:8787 npm run mcp

Never disable sign-in on a public instance to make that route work — use /mcp and a token, and keep auth-disabled instances on a private network.

The live channel

While teammates have the same project open, the server passes their avatars and save notices between browsers over a WebSocket at /api/live. What users see is in Working as a team.

It is on by default. LIVE_RELAY_ENABLED=false switches it off: GET /api/health then reports "live": false, browsers open no socket, and editors find teammates' saves on their 15-second check. With the relay on, the check still runs every 15 seconds.

Behind ngrok or a TLS proxy, set APP_BASE_URL

The live channel works behind a tunnel or a TLS-terminating proxy only when APP_BASE_URL is the public URL. A browser's upgrade must come from the app's own page: its Origin must equal APP_BASE_URL or the connection's own <scheme>://<Host>. The server trusts no proxy headers, so behind ngrok the connection is http while the page is https, and only APP_BASE_URL can match.

APP_BASE_URL=https://your-name.ngrok-free.dev   # scheme, host and port, exactly

Without it every browser upgrade gets 403: no avatars, and saves arrive on the 15-second check. The browser can't see the 403 — DevTools shows only a failed /api/live socket that retries, waiting up to 30 s between tries. Sign-in redirects use the same APP_BASE_URL.

Limits

The limits are constants (LIVE_LIMITS in server/live/relay.ts), not settings. Each open project tab holds one socket.

Limit Value When it's exceeded
Frame size 64 KB The socket is closed (1009).
Frames per socket 50 a second, bursts of 100 The socket is closed (1008).
Sockets per room (one project) 50 The socket is closed after it opens (1013).
Sockets in total 1000 1013.
Sockets per owner 20 per signed-in user; with sign-in off, 20 per client address 1013. With sign-in off behind a proxy, every user arrives from the proxy's address and all share one 20-socket cap.
POST /api/live/rooms/:room body 96 KB, frame 64 KB, 50 a second per owner, bursts of 100 413, 429.

A text frame closes the socket (1003). A socket with more than 256 KB waiting to be sent is dropped, and one that misses a 30-second ping is dropped. After a 1008 or 1013 close the browser waits 30 s before it tries again.

What the server sees and who may connect

  • Ciphertext only. A room id is an HMAC of a key kept in the project's files (settings.liveKey in project-meta.json), and every frame is AES-GCM-encrypted with a key derived from it. The server holds no key. The relay forwards each frame to every socket in the room, the sender's included, and never reads, logs or keeps one. It sees room ids, sequence numbers, frame sizes and connection counts.
  • Who joins a room. Anyone who can read the project's files has the key. There is no key rotation: someone removed from the Drive folder or the repository keeps the key.
  • Sign-in. With sign-in on, the upgrade needs a session cookie or a personal access token (Authorization: Bearer fl_pat_…), else 401. With sign-in off, anyone who can reach the server may connect.
  • Origin. A cookie upgrade needs an Origin that passes the rule above, else 403; so does any upgrade that sends an Origin with sign-in off. A token upgrade (MCP, the helper) needs no Origin.
  • Auth is checked only at the upgrade. An open socket outlives sign-out, session expiry and token revocation, until the tab closes, the network drops or the server restarts. Restart the server to close every socket.
  • POST /api/live/rooms/:room { frame } puts one frame, sealed by the sender, into a room. MCP uses it, since it has no socket. It needs the same sign-in as other /api routes. It parses only application/json, which a cross-site page can't send without a CORS preflight the server never grants; the session cookie is SameSite=Lax.

MCP and the live channel

MCP announces each write with one sealed frame through the route above. It asks /api/health once, and keeps a "live": false answer for the life of the process (for the hosted /mcp, of the session): after switching the relay on, restart the stdio server or the helper. A failed or timed-out ask (2 s) is asked again on the next write.

Node version

The live channel uses the global Web Crypto API. Node 18 needs --experimental-global-webcrypto (for example NODE_OPTIONS=--experimental-global-webcrypto); use Node 19 or later.

Environment variable reference

Variable Default Purpose
PORT 8787 Port the server listens on.
NODE_ENV — Set to production to serve dist/ and mark cookies Secure. npm run start sets it for you.
APP_BASE_URL http://localhost:5174 Public origin users visit; all OAuth redirects route through it, and the live channel accepts browsers from it. Set to your real URL — behind ngrok too.
APP_CONFIG_DIR ./.data/app Per-user registries, OAuth config, sessions.
SESSION_DIR APP_CONFIG_DIR/sessions Login session files.
ALLOW_ARBITRARY_WORKSPACE_PATHS true locally, false when auth is on Allow local workspaces at any host path and host filesystem browsing. Defaults off once sign-in is enabled; set false to force lockdown, or true to override.
LOCAL_WORKSPACES_ENABLED derived (true when auth is off) Enable server-disk local folder workspaces and the confined /api/fs browser. Defaults off when sign-in is enabled; set true to allow folder workspaces under auth (required for MCP to reach them).
WORKSPACE_ROOT — Confine local workspace browsing and creation under this root (works even when arbitrary paths are off).
AUTH_ENABLED derived Force sign-in on. Auto-true when the login client credentials are set.
GOOGLE_LOGIN_CLIENT_ID / GOOGLE_LOGIN_CLIENT_SECRET — Login OAuth client (separate from Drive). Enables sign-in.
GOOGLE_LOGIN_REDIRECT_URI ${APP_BASE_URL}/api/auth/google/callback Login OAuth callback.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET — Google Drive sync client — see Google Drive setup.
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET — GitHub OAuth for git sync — see GitHub OAuth setup.
FLOW_API_URL http://127.0.0.1:8787 API base URL the stdio MCP server connects to.
MCP_ALLOWED_HOSTS — Comma-separated Host values accepted at /mcp. Setting it enables DNS-rebinding protection.
LIVE_RELAY_ENABLED true The live channel at /api/live: avatars and instant save notices. false switches it off; editors then rely on the 15-second check.
GOOGLE_PICKER_API_KEY — Browser API key for the Google Drive folder picker. Without it Choose Drive folder and Create new folder stay disabled; adding a folder by id still works.
SCOPE_CACHE_TTL_MS 1800000 How long a resolved workspace scope is cached, in milliseconds.
SCOPE_CACHE_MAX 500 Maximum cached workspace scopes.

Hardening checklist

  • HTTPS terminated at the proxy; app bound to 127.0.0.1.
  • APP_BASE_URL set to the real public origin.
  • The proxy passes the WebSocket upgrade on /api/live.
  • Sign-in enabled (GOOGLE_LOGIN_CLIENT_ID / GOOGLE_LOGIN_CLIENT_SECRET).
  • Login OAuth client is separate from the Drive client, with the correct redirect URI.
  • ALLOW_ARBITRARY_WORKSPACE_PATHS=false and WORKSPACE_ROOT set.
  • APP_CONFIG_DIR, SESSION_DIR (and WORKSPACE_ROOT, if used) on backed-up storage.
  • One server process per APP_CONFIG_DIR.
  • MCP, if used, runs on a trusted machine and is never pointed at a public auth-disabled instance.

For how the pieces fit together, see docs/ARCHITECTURE.md in the repository.