No description
Find a file
Kristian Krsnik 8f09b3ab58
Serve the web app's index.html with no-store
Store paths have mtime 1, so nginx sends Last-Modified 1970 and browsers
cache index.html heuristically for years. A cached page keeps the address
space it was first fetched from: loaded via the public IP, then opened on
a LAN whose split DNS resolves the IdP to a private address, Chrome's
Local Network Access blocks the sign-in requests. Refetching the page on
every load gives it the current network's address space.

The web app has no service worker, so it can no longer cold-start
offline; open tabs and the desktop/mobile apps are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:41:45 +02:00
docs Show the desktop app in GNOME's dash; drop the device-code prompt 2026-09-30 22:11:15 +02:00
examples Stay signed in against a self-hosted IdP 2026-10-03 13:50:36 +02:00
modules Serve the web app's index.html with no-store 2026-10-04 21:41:45 +02:00
.gitignore Sign in and use the API without Cognito-specific claims; MCP for self-hosters 2026-09-30 19:54:53 +02:00
AGENTS.md Pass the upload dialog's encryption choice through to the worker 2026-10-03 21:49:18 +02:00
flake.lock initial commit 2026-09-27 22:41:40 +02:00
flake.nix initial commit 2026-09-27 22:41:40 +02:00
README.md Choose encryption when uploading an existing local graph 2026-10-03 20:56:50 +02:00

Logseq — packaged with Nix

Logseq 2.0.1 (the DB-graph version), built from source and packaged as a fully self-hostable stack: desktop client, web app, sync server, publish service and Android app.

Upstream hardcodes an AWS Cognito user pool for login and Cloudflare-hosted logseq.io/logseq.com for sync and publishing. This flake makes every one of those (identity provider, sync endpoint, publish endpoint) configurable, so a self-hosted deployment never has to talk to Amazon or Cloudflare's infrastructure. Proven end to end against a self-hosted Keycloak realm (see Setting the identity provider below).

What's bundled

Package What it is
logseq Desktop client (Electron) + bundled CLI
logseq-webapp Static PWA bundle; servable from any file root
logseq-android Unsigned APK
logseq-sync Sync server (Node adapter)
logseq-sync-worker Sync server, Cloudflare Worker build (REST/MCP/ChatGPT-Apps; not interchangeable with logseq-sync — different storage)
logseq-publish Publish service (Worker on wrangler's local runtime)
logseq-{webapp,sync,publish}-image Container images of the three deployable targets, configured from the environment

Every server/webapp target is also runnable directly with no install step:

nix run path:.#logseq-{webapp,sync,sync-worker,publish}

Using the packages

The clients read their identity provider and endpoints (clientConfig) at runtime, from js/logseq-config.js; anything unset falls back to upstream's values. So the web app is a generic build, configured when it's deployed: environment variables for the container image and nix run, services.logseq-webapp.clientConfig for the NixOS module. No rebuild to try a different identity provider:

LOGSEQ_OIDC_ISSUER=https://id.example.org/realms/logseq LOGSEQ_OIDC_CLIENT_ID=logseq \
  nix run path:.#logseq-webapp

Desktop and Android have no server to hand them config, so they bake it in with an override (on the web app, the same override only sets the defaults the environment can still change):

inputs.logseq.packages.${system}.logseq.override {
  clientConfig = {
    oidcIssuer = "https://id.example.org/realms/logseq";
    cognitoClientId = "logseq";
    apiDomain = "notes.example.org";
    syncUrl = "https://sync.example.org";
    publishUrl = "https://blog.example.org";
  };
}
clientConfig key Environment variable Sets
oidcIssuer LOGSEQ_OIDC_ISSUER Your OIDC provider; replaces Cognito login and refresh (see below)
cognitoClientId LOGSEQ_OIDC_CLIENT_ID OAuth client id
apiDomain LOGSEQ_API_DOMAIN Host answering /file-sync/user_info; point it at your web app
syncUrl LOGSEQ_SYNC_URL Default sync server (https://host); users can still change it
publishUrl LOGSEQ_PUBLISH_URL Default publish server, likewise
oauthDomain LOGSEQ_COGNITO_OAUTH_DOMAIN Cognito hosted-login host (upstream's login only)
cognitoIdp / userPoolId LOGSEQ_COGNITO_IDP / LOGSEQ_COGNITO_USER_POOL_ID Cognito API endpoint and user pool (upstream's login only)

logseq-sync, logseq-sync-worker and logseq-publish take no clientConfig — see Setting the identity provider for how they're configured instead.

Using the container images

nix build path:.#logseq-webapp-image && podman load -i result

All three images are generic and configured from the environment, so they can be built once and published. examples/docker-compose.yml wires the web app, sync and publish images together on ports 8080/8081/8082 (docker compose and podman-compose alike): copy it, fill in the LOGSEQ_* variables listed at its top (no defaults on purpose) and docker compose up -d.

Using the NixOS modules

One module per server/webapp target, inputs.logseq.nixosModules.logseq-{webapp,sync,sync-worker,publish}:

{
  imports = [ inputs.logseq.nixosModules.logseq-sync ];
  services.logseq-sync = {
    enable = true;
    oidcIssuer = "https://idp.example.org/realms/logseq";
    oidcClientId = "logseq";
    oidcJwksUrl = "https://idp.example.org/realms/logseq/protocol/openid-connect/certs";
  };
}
Option Modules Meaning
enable all Turn the service on
package all Override for a custom build
serviceName all systemd unit name, for cross-referencing from another module (default logseq-<name>)
port all Listen port
openFirewall all Open port in the firewall
user / group all Fixed user instead of the default DynamicUser (needed if your host doesn't keep a DynamicUser's UID stable across reboots)
clientConfig webapp The web app's clientConfig (keys above), served at runtime: changing it restarts nginx, it doesn't rebuild the bundle
oidcIssuer sync, sync-worker, publish Identity-provider issuer URL (required)
oidcClientId sync, sync-worker, publish Expected client id (required)
oidcJwksUrl sync, sync-worker, publish Signing-key endpoint (required)
r2AccountId, r2Bucket, r2AccessKeyId, r2SecretAccessKey sync-worker, publish R2 binding — placeholders are fine, both run on wrangler's local runtime with no real R2 to reach
dataDir sync, sync-worker, publish Data directory (default /var/lib/<serviceName>); under /var/lib, nested paths included, systemd creates it for user
publicUrl sync-worker The URL clients reach it at behind a reverse proxy (SYNC_WORKER_PUBLIC_URL); MCP clients need it

services.logseq-webapp runs its own dedicated nginx (there's no backend here to proxy to); the other three own a systemd unit each. examples/keycloak.nix shows all of these wired up against a self-hosted Keycloak realm.

Using the desktop client

inputs.logseq.nixosModules.logseq (system-wide) and inputs.logseq.homeManagerModules.logseq (per-user) both expose programs.logseq:

{
  imports = [ inputs.logseq.homeManagerModules.logseq ];
  programs.logseq = {
    enable = true;
    autostart = true; # home-manager only; also needs xdg.autostart.enable
  };
}
Option Meaning
enable Install the desktop client
package Override for a custom clientConfig
autostart (home-manager only) Start on login via XDG autostart (needs xdg.autostart.enable = true too)

Setting the identity provider

Servers accept any OIDC provider — token verification is issuer + audience + expiry + RS256-against-JWKS, with no Cognito-specific calls. examples/keycloak.nix is a complete, working self-hosted Keycloak deployment; adapt its realm export for another provider.

Env var (or NixOS option above) Meaning
COGNITO_ISSUER (oidcIssuer) Issuer URL; compared to the token's iss claim exactly
COGNITO_CLIENT_ID (oidcClientId) Expected aud/client_id claim
COGNITO_JWKS_URL (oidcJwksUrl) Signing-key endpoint, fetched on first use and cached

(The COGNITO_* names are upstream's; they carry no Amazon-specific meaning and work with any OIDC provider.)

Clients sign in with the OAuth device flow once they're given an oidcIssuer, the same value the servers get. The login dialog shows a short code and a link to your identity provider, you log in there with whatever it offers (password, MFA, or a brokered GitLab/Forgejo login), and the app picks up the tokens. The same code runs on web, desktop and Android. For the web app that's runtime configuration, e.g. for the container image:

LOGSEQ_OIDC_ISSUER=https://id.example.org/realms/logseq  # same value as the servers' oidcIssuer
LOGSEQ_OIDC_CLIENT_ID=logseq                             # your provider's public client
LOGSEQ_API_DOMAIN=notes.example.org                      # where the web app is served
LOGSEQ_SYNC_URL=https://sync.example.org

(desktop and Android: the same keys as a clientConfig override, see Using the packages).

The client reads every endpoint it needs from the provider's standard discovery document (<oidcIssuer>/.well-known/openid-configuration), so nothing has to be rewritten or proxied. Your provider needs:

  • a public client with the OAuth 2.0 device authorization grant enabled;
  • CORS allowed for your app's origins: the web app's own, lsp://logseq.com for desktop;
  • nothing else: the clients send the id token, whose aud is the client id, and take the user name from cognito:username or else the standard preferred_username, both of which any provider puts there by default.

And apiDomain has to answer POST /file-sync/user_info with {"UserGroups":["rtc_2025_07_10"]}. Without it the app signs in but never syncs. The web app's image, NixOS module and nix run wrapper already serve exactly that, so point apiDomain at your web app.

Proven end to end for the web app by checks.login. Desktop and Android run the same code but haven't been exercised end to end yet. See docs/self-hosted-identity.md for how it works and what's left.

REST API and MCP

logseq-sync-worker (not logseq-sync) also serves a REST API (docs at /api-docs, spec at /openapi.json) and an MCP server at /mcp. Their clients send an access token, so the provider additionally needs:

  • the access token's aud to be exactly the client id (Keycloak: an audience mapper, and no roles scope);
  • logseq/read and logseq/write in its scope (Keycloak: two client scopes, default on the client);
  • loopback redirect URIs on the client (http://localhost:*, http://127.0.0.1:*) for MCP clients to sign in with it.

examples/logseq-realm.json has all of it. In Keycloak, declaring any client scope in a realm import stops it creating the built-in ones, so that realm declares basic, profile and email itself. Set the module's publicUrl when the worker sits behind a reverse proxy. Then, reusing the realm's public client:

claude mcp add --transport http --client-id logseq --callback-port 47111 logseq https://sync.example.org/mcp
{ "mcp": { "logseq": { "type": "remote", "url": "https://sync.example.org/mcp",
                       "oauth": { "clientId": "logseq", "scope": "logseq/read logseq/write" } } } }

(the second is opencode's opencode.json; opencode mcp auth logseq signs in). checks.sync-worker runs both clients' sign-ins against the realm and calls the API directly and through MCP. It doesn't run the clients themselves.

The API and MCP only see graphs that are not end-to-end encrypted (the server can't read the others; it answers 409 semantic-api-unavailable-for-e2ee). Encryption is fixed when a graph is first uploaded. Upstream lets you choose it only when creating a new synced graph; uploading an existing local graph (e.g. an imported one) always encrypted it. The patched clients add an Encrypt data checkbox to the upload dialog too, so either kind of graph can go up either way.

More detail

AGENTS.md has the full internals: how each package is built, every check nix flake check runs and what it proves, the repo layout, and known gaps.