Skip to content

Self-hosting

Subspace is designed to be run by the person who owns the data. There is no managed control plane you depend on and no separate database server to provision: one Node process supervises one embedded PostgreSQL instance, and the whole system’s durable state lives under a single home directory. You can start on a laptop and move to an always-on box later without changing anything about the data model, just where the files sit.

The server is one Node process. On boot it supervises an embedded PostgreSQL child process (own bundled binaries, no system Postgres dependency), runs migrations transactionally, and then serves the web SPA, the tRPC API, and the WebSocket bus from that single process. Every other surface, the Electron desktop app, the Expo mobile app, and the Chrome extension, is a thin client that talks to this one process over the network. There is nothing else to run: no message queue, no separate worker fleet, no external cache.

This is deliberate. A local-first, single-owner tool should be as easy to operate as a single binary, and a single Postgres cluster gives every subsystem (pages, mail, calendar, tasks, agent runs, search) one transaction boundary and one backup stream. See backup and restore for how that pays off operationally.

Everything durable lives under $SUBSPACE_HOME (default ~/Subspace):

pg/

The embedded PostgreSQL data directory. Unix socket only, no TCP listener ever binds here, even on a remote box. All rows: pages, comms, ops, search, agent runs.

blobs/

The content-addressed blob store, sharded ab/cd/<sha256>. Uploads, attachments, and code-cell image outputs. Immutable once written.

okf/

The optional filesystem mirror of the knowledge graph, one Markdown file per page. Backs GitOps and coding-agent access to your notes.

secret.key

The 0600 master key used to decrypt ops.secrets at rest. See secrets at rest below.

Because the data directory is self-contained, moving Subspace is moving a directory. Stop the process, copy (or wal-g backup-fetch) pg/, blobs/, and okf/ to the new machine, start the same server build there, and repoint your clients at the new address. Nothing else is coupled to where the process runs.

The blessed path is to start on the machine you already use. pnpm dev (or the packaged desktop app) boots the server locally on launchd, supervised by the shell, so a crash restarts both the server and its embedded Postgres child transparently. This is a fully functional instance: your knowledge base, mail, calendar, agents, and search all run against ~/Subspace with no network dependency beyond the providers you configure.

  1. Install and boot

    Follow installation to get the server running against $SUBSPACE_HOME. First boot runs initdb and applies migrations.

  2. Bind to localhost

    Leave SUBSPACE_HOST at its default, 127.0.0.1. Nothing outside the machine can reach the server.

  3. Optionally bind to your tailnet

    Set SUBSPACE_HOST to a Tailscale IP to reach the instance from your phone or another device without exposing it to the public internet. Localhost and tailnet addresses are the only binds the server accepts without opting into the claim-token flow below.

  4. Move to an always-on box when you’re ready

    Stop the process, move the home directory (or restore it from backup) to a NUC, a Hetzner box, or any always-on machine joined to the same tailnet, and start the same server build there. The restore path and the migration path are the same operation.

By default the server refuses to bind anywhere other than 127.0.0.1 or a private tailnet address. This is the safety rail for the common case: a self-hosted instance that only its owner and their own devices ever reach. Reaching it from a phone or a second machine goes through Tailscale, not through opening a public port.

For deployments that do need a public bind (a team box, a demo, or the managed Railway hosting model), Subspace ships a production container image built from a multi-stage Dockerfile:

  1. Build stage: installs dependencies, resolves the platform-matched @subspace/pg-* published binary package, compiles TypeScript, builds the web SPA, and rebuilds the agent workflow bundle.
  2. Prune stage: strips dev dependencies, leaving a runtime image that starts the compiled server directly (no tsx, no source transpilation at boot).
  3. Runtime: the image respects $PORT (mapped to SUBSPACE_PORT) so it boots correctly on platforms that assign a port dynamically, and binds 0.0.0.0 only under an explicit cloud capability profile, never by default.

The image bundles the same host-matched PostgreSQL and pgvector binaries described in installation, so a container boots its embedded database exactly the way a local install does, just inside a volume-mounted $SUBSPACE_HOME.

An unclaimed instance (zero users) is a permissive state by design, it exists so a fresh local install works immediately without a signup flow. That posture is only safe on a loopback bind. The moment a server binds to a non-loopback address, it refuses to serve an unclaimed principal: you must supply a claim token (SUBSPACE_CLAIM_TOKEN, or a pre-seeded invite) before the first login can claim the instance and create the admin user. This closes the gap where an instance briefly reachable from outside the machine would otherwise hand out anonymous admin access to whoever gets there first.

Named credentials (API tokens, git credentials, MCP server headers) never live in plaintext. They’re stored as rows in ops.secrets, encrypted with libsodium secretbox, referenced everywhere else by name only, never by value: an mcp/ page says Authorization: github.mcp_token, not the token itself.

The secretbox master key lives at <home>/secret.key, mode 0600, minted on first use. It’s deliberately not an environment variable: a key that rode process env would show up in process listings, crash dumps, and log capture. On desktop the key file is the baseline; the capability broker is the seam that later upgrades this to the OS keychain (Electron safeStorage) without changing how the rest of the system references secrets.

The capability broker itself is the component that resolves secret names to values at connect time, for example when an MCP server binding needs its bearer token, or when GitOps needs its git credential. It runs inside the trusted plane of the process, so the plaintext value is only ever materialized in memory at the point of use, never handed to a sandboxed tool or a scrubbed worker process.