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:
npm run buildcompiles the React app intodist/and packages the downloadable Flowline helper intodownloads/flowline-helper.tgz.npm run startruns the server withNODE_ENV=production. It servesdist/as static files and the API, both onPORT(default8787), 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
Build on the server (or build in CI and ship
dist/):npm ci npm run buildSet the environment. Put these in a
.envfile 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 configStart it, ideally under a process manager (systemd, pm2, or a container) so it restarts on crash and on boot:
npm run startYou should see
API listening on http://localhost:8787.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_URLmust be the publichttps://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.
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/callbackSet 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 withGOOGLE_LOGIN_REDIRECT_URIif needed.Restart. The SPA now shows a login screen, gated by
GET /api/auth/me, and every/api/*data route requires a session (it returns401otherwise). The flow is:GET /api/auth/google/start→ redirects to Google with a CSRFstatecookie.GET /api/auth/google/callback→ validatesstate, 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_workspacesreturns Google Drive and server-folder workspaces and never browser workspaces. SetLOCAL_WORKSPACES_ENABLED=trueif you want MCP clients to reach server-folder workspaces. A folder on a user's own computer is reached the other way: they run theflowline-mcphelper 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.
- 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.
- 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_URLis the public URL. A browser's upgrade must come from the app's own page: itsOriginmust equalAPP_BASE_URLor the connection's own<scheme>://<Host>. The server trusts no proxy headers, so behind ngrok the connection ishttpwhile the page ishttps, and onlyAPP_BASE_URLcan match.APP_BASE_URL=https://your-name.ngrok-free.dev # scheme, host and port, exactlyWithout it every browser upgrade gets
403: no avatars, and saves arrive on the 15-second check. The browser can't see the403— DevTools shows only a failed/api/livesocket that retries, waiting up to 30 s between tries. Sign-in redirects use the sameAPP_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.liveKeyinproject-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_…), else401. With sign-in off, anyone who can reach the server may connect. - Origin. A cookie upgrade needs an
Originthat passes the rule above, else403; so does any upgrade that sends anOriginwith sign-in off. A token upgrade (MCP, the helper) needs noOrigin. - 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/apiroutes. It parses onlyapplication/json, which a cross-site page can't send without a CORS preflight the server never grants; the session cookie isSameSite=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_URLset 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=falseandWORKSPACE_ROOTset. -
APP_CONFIG_DIR,SESSION_DIR(andWORKSPACE_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.