Self-hosting the Sync Server

The open-source Sync Server works without an EtherPK Account, Corporate or Stripe. It stores and relays encrypted graph data while your devices keep the keys.

Run a private server as a separate deployment with its own database, browser-visible URL, BETTER_AUTH_SECRET, SYNC_TOKEN_SECRET and storage credentials. Do not reuse the managed service's database or secrets. The repository's apps/server/.env includes a complete commented standalone profile for local testing; enable that whole block only after disabling the managed profile above it.

Start with standalone accounts

Set an explicit browser origin and standalone auth mode:

AUTH_MODE=standalone
SERVER_PUBLIC_URL=https://sync.example.com
CLIENT_PUBLIC_URL=https://client.example.com
BETTER_AUTH_URL=https://sync.example.com
BETTER_AUTH_SECRET=<at least 32 random bytes>
AUTH_EMAIL_PASSWORD_ENABLED=true
REGISTRATION_ALLOWED_IPS=
ENTITLEMENT_MODE=unlimited

The private AppSoftware tailnet deployment uses this concrete profile:

AUTH_MODE=standalone
SERVER_PUBLIC_URL=https://etherpkserver.raptor-hen.ts.net
CLIENT_PUBLIC_URL=https://etherpkclient.raptor-hen.ts.net
BETTER_AUTH_URL=https://etherpkserver.raptor-hen.ts.net
AUTH_EMAIL_PASSWORD_ENABLED=true
ENTITLEMENT_MODE=unlimited
SYNC_ALLOWED_ORIGINS=https://etherpkclient.raptor-hen.ts.net

Its Client connects through Custom server using https://etherpkserver.raptor-hen.ts.net and a PAT created in that Server's account portal. The two raptor-hen.ts.net origins are HTTPS Tailscale Services and are available only to allowed tailnet devices. They proxy to private Kubernetes NodePorts and do not have public Traefik Ingresses. Remote document sync works without object storage. Encrypted asset upload remains disabled until the operator adds a dedicated S3-compatible bucket, credentials and a CORS rule allowing only https://etherpkclient.raptor-hen.ts.net.

The exact Tailscale Services console, Serve, ACL, validation, DNS-removal and rollback steps are in appsoftware-infra/docs/tailscale-configuration.md. Do not configure the applications with their plain HTTP NodePort addresses. Non-loopback EtherPK origins must use HTTPS, and the Client depends on a secure browser origin for Web Crypto and device storage.

Changing the old g9n.com addresses to the Tailscale Service names creates new browser origins. Make sure Sync is caught up and the Recovery Code is available before cutover. Client IndexedDB, cookies and locally held PAT configuration do not move to the new origin. The new Client behaves like a new device and must unlock its account vault. Existing Server passkeys remain tied to the old hostname and must be enrolled again after signing in with email / password.

CLIENT_PUBLIC_URL gives the Server's Graphs link an explicit destination. The paired Client uses PUBLIC_CUSTOM_SYNC_URL for its Sync Server link and Custom server form. Set both sides to the same Client / Server pair. Neither value grants access or replaces the user's PAT.

For an in-place reset which reuses a database name, delete the previous production database manually, then create a fresh database and apply apps/server/db-migrations/0001_baseline.sql. When the replacement uses a new database and owner name, create and verify it first, then delete the superseded resources manually. The current dogfood schema has no upgrade or account-linking path from the earlier account model.

For the private etherpkserver.raptor-hen.ts.net deployment, appsoftware-infra creates the isolated appsoftware_etherpk_server_production database and its appsoftware_etherpk_server_production_user role on the shared in-cluster PostgreSQL cluster, using numbered infrastructure migration 010. The pooled and direct URLs are held in apps/appsoftware-server/appsoftware-etherpk-server/secrets.yaml, and the role password there must match the one in the migration. It is a separate database and role from the managed workloads, so the ownership boundary is preserved even though the cluster is shared.

The paired Kubernetes namespaces are etherpkclient and etherpkserver. Their Deployments and Services are appsoftware-etherpk-client and appsoftware-etherpk-server, matching the top-level GitOps manifest directories under apps/appsoftware-server. Tailscale keeps the independent svc:etherpkclient and svc:etherpkserver routing identities.

Other self-hosters can supply any PostgreSQL service meeting the two connection requirements below. They do not need the AppSoftware infrastructure repository.

If an older development database has already run the edited 0001_baseline.sql, recreate that database too. Do not run the baseline a second time over existing tables.

Email verification and password reset need the normal mail-delivery settings for the deployment. After registration, account security can add MFA and passkeys from the local Account page. A passkey uses the hostname from BETTER_AUTH_URL unless the operator supplies a valid PASSKEY_ORIGIN and PASSKEY_RP_ID.

Name yourself the administrator

A Server has an Admin section holding Users and Storage - the second is where you scan for and reclaim storage left behind by deleted graphs. Both are hidden, and refused, unless you are the administrator, and a freshly installed Server has none.

Name yourself in the Server's environment:

ADMINISTRATOR_EMAIL_ADDRESS=you@example.com

Whoever registers with that address is granted the role immediately, whether or not they have clicked a verification link; if you have already registered, signing in again is enough. The comparison ignores case and surrounding spaces. Set it before you open registration to anyone else, or pair it with the IP restriction below - the address is a claim on the Server, so it should be one only you can register. The configuration is the thing being trusted here: whoever can set the address can register it first, so a verification step would add nothing for you and would lock a Server with no mail provider out of its own admin pages for ever.

Invites are the other place an address matters, and there the rule is stricter. When this Server can send email (the CF_ACCOUNT_ID and CF_EMAIL_TOKEN settings above), an invite only finds a member whose address has been verified, so nobody can receive a graph's keys by registering somebody else's address first. A Server with no mail provider cannot verify anyone, so its invites resolve any registered address; the IP restriction below is what keeps such a Server private.

The environment stays in charge: change the address and the previous holder is demoted the next time they sign in, and clear it and the Server has no administrator at all. That also means a database restored into a fresh deployment needs no repair - the Server reads who its operator is from its own configuration rather than from whichever row survived.

Restrict registration by IP address

Registration is open from every address when REGISTRATION_ALLOWED_IPS is empty or absent. To limit new standalone accounts, set a comma-separated list of exact IPv4 or IPv6 addresses:

REGISTRATION_ALLOWED_IPS=203.0.113.8,2001:db8::8

Hostnames, wildcards and CIDR ranges are not accepted. A bad entry stops the Server with a clear configuration error. Existing users can still sign in from any address. The restriction applies only when a request would create a new account, including email registration and social-provider registration.

A visitor outside the allowlist still sees the registration page, together with a warning that registration is unavailable from their current IP address. Submitting the form is not trusted as the security boundary. The Server checks the address again and returns HTTP 403 with code REGISTRATION_RESTRICTED. Social sign-in can still authenticate an existing linked account, but it cannot silently create a new account around the restriction.

The Server uses SvelteKit's adapter-resolved client address. With no reverse proxy address header configured, this is the direct TCP peer, which may be the proxy rather than the visitor. If a trusted reverse proxy sits in front of EtherPK, configure adapter-node to read only a header which that proxy removes and rewrites. For a proxy which replaces X-Forwarded-For with one client address:

ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

Do not enable ADDRESS_HEADER while clients can connect directly to the Server or supply that header unchanged. Otherwise a client could claim an allowed address. A provider-specific header, such as one set and protected by the ingress, can be used instead. Confirm the resolved ip in the Server request log before enabling the allowlist. If the address cannot be resolved while a list is configured, registration fails closed.

Browser session and Client PAT

The Server dashboard displays the current local account and authentication method. This browser session is separate from the PAT used by EtherPK Client.

sequenceDiagram
    actor User
    box rgb(240, 253, 244) Self-hosted Sync Server app - sync.example.com
        participant Portal as Browser-facing account portal
        participant Server as Local auth and Sync API
    end
    box rgb(239, 246, 255) EtherPK Client app
        participant Client as Custom-server connection
    end

    User->>Portal: Register or sign in
    Portal->>Server: Authenticate with the local account
    Server-->>Portal: Set the local portal session
    User->>Server: Create a Personal Access Token
    Server-->>User: Display the token once
    User->>Client: Enter the Server URL and token
    Client->>Server: Authenticate API and Sync requests
    Server-->>Client: Resolve the same local Principal
    opt Sign out of the Server portal
        User->>Portal: Sign out
        Portal->>Server: End local Better Auth session
        Server-->>Portal: Show Server signed out
        Note over Client,Server: Client PAT remains active
    end
    opt Disconnect or revoke Client access
        User->>Client: Remove the Custom server connection
        User->>Server: Optionally revoke the PAT
        Note over Portal,Client: Server browser session is independent
    end

The local browser session protects human-facing Server pages. The PAT authenticates EtherPK Client and can be revoked without ending the browser session. Neither credential gives the Server access to graph encryption keys. The top navigation shows the signed-in email and opens an account menu with Dashboard, Account, Access tokens and Sign out. Signing out ends only this Server's local Better Auth browser session; it does not revoke a Client PAT.

After the PAT is saved, Client's own top navigation shows the identity confirmed by the Server. Its menu links to that Server's Account and Access tokens pages. Disconnect this device forgets the Custom server URL and PAT held by Client; it does not end the Server portal session or revoke the Server-side token record. Client returns to Graphs so any open workspace and live Sync connection are closed immediately.

There is no managed Sign out of EtherPK cascade in standalone mode because there is no Corporate session or Client OAuth session. To remove Sync access from a Client device, disconnect its Custom server setting and revoke its PAT from Access tokens. The complete managed and standalone comparison is diagrammed in Managed Sync And Self Hosting.

Standalone mode never performs the managed Corporate SSO check. Visiting the Server signed out shows its local Sign in link, and authentication remains on SERVER_PUBLIC_URL. This behaviour does not need an additional setting: it follows from AUTH_MODE=standalone.

Allowed standalone settings

Setting Required Allowed value
DATABASE_APPLICATION_URL Yes Pooled PostgreSQL connection for runtime queries
DATABASE_MIGRATION_URL Yes Direct PostgreSQL connection to the same database
AUTH_MODE Yes Exactly standalone
SERVER_PUBLIC_URL Yes Browser-visible HTTPS origin, or loopback HTTP for development
CLIENT_PUBLIC_URL Yes Browser-visible origin of the Client paired with this Server
BETTER_AUTH_URL Yes Usually the same exact origin as SERVER_PUBLIC_URL
BETTER_AUTH_SECRET Yes A per-deployment secret containing at least 32 bytes
AUTH_EMAIL_PASSWORD_ENABLED No true or false; defaults to true
REGISTRATION_ALLOWED_IPS No Comma-separated exact IPv4 or IPv6 addresses; empty means unrestricted
PASSKEY_ORIGIN No HTTPS origin; defaults to BETTER_AUTH_URL
PASSKEY_RP_ID No Passkey hostname or a deliberate parent domain
SYNC_TOKEN_SECRET Yes Independent strong secret for graph-scoped Sync Tokens
SYNC_ALLOWED_ORIGINS Production Comma-separated exact Client origins
SYNC_MAX_MESSAGE_BYTES No Positive safe integer, otherwise the protocol limit
SYNC_MAX_PRESENCE_ENVELOPE_BYTES No Positive safe integer, otherwise 16 KiB
SYNC_MAX_SUBSCRIPTIONS_PER_CONNECTION No Positive safe integer, otherwise 4,096
SYNC_MAX_OUTBOUND_BUFFERED_BYTES No Positive safe integer, otherwise 64 MiB
SYNC_MAX_SOCKETS_PER_USER_GRAPH No Positive safe integer, otherwise 10
SYNC_REPLICA_COUNT No Must be 1 or unset; anything higher refuses to start (see below)
ENTITLEMENT_MODE Yes unlimited or fixed for ordinary self-hosting
ADDRESS_HEADER Reverse proxy only Trusted proxy header used by adapter-node for the client address
XFF_DEPTH With X-Forwarded-For Positive trusted-proxy depth; normally 1 when the proxy replaces the header

Browser-facing URLs must not include credentials, a path, query or fragment. Plain HTTP is accepted only for localhost, its subdomains, 127.0.0.1 and [::1].

SYNC_ALLOWED_ORIGINS must be set in production. It is a comma-separated list of exact Client origins. Leaving it unset used to reflect whichever origin asked, which is convenient in development and wrong in production, so a production build with it unset now refuses every cross-origin sync request instead. In development an unset allowlist still reflects the requesting origin, so a Client on an ad-hoc port is not blocked.

One Sync Server process, on purpose

The Sync Server does not run as a replica set, and it will refuse to start if you tell it to.

Two things it holds live in memory: which sockets are currently in which graph, and the signal that closes a socket the moment somebody is removed from a graph. Run two copies behind a load balancer and both stop working properly. Two people editing the same graph, connected to different copies, stop seeing each other live - their edits still arrive eventually, through catch-up, but multiplayer quietly stops feeling like multiplayer. Worse, removing somebody from a graph only closes the connection they hold on the copy that handled the request; a connection on the other copy keeps receiving traffic.

Neither failure announces itself, which is why scaling past one replica is refused rather than allowed to degrade. Leave SYNC_REPLICA_COUNT unset (or set it to 1). Scale the Server vertically if it needs more capacity. ADR 0049 records the decision and what would have to be built to lift it.

Each connection is bounded on its own - presence envelopes at 16 KiB, 4,096 document subscriptions, 64 MiB of unsent outbound data before a stalled reader is cut and reconnects, and 10 sockets per user on one graph - so one member cannot grow the single relay process past what a healthy client uses. SYNC_MAX_PRESENCE_ENVELOPE_BYTES, SYNC_MAX_SUBSCRIPTIONS_PER_CONNECTION, SYNC_MAX_OUTBOUND_BUFFERED_BYTES and SYNC_MAX_SOCKETS_PER_USER_GRAPH raise or lower them, each a positive safe integer like SYNC_MAX_MESSAGE_BYTES.

Do not set OIDC_*, OIDC_PORTAL_*, ENTITLEMENT_ISSUER or reconciliation variables in this standalone profile. Do not set any STRIPE_* value on Sync Server. The repository's Server .env keeps the complete managed block active and the complete standalone block commented, so switch the whole group rather than mixing individual lines.

Optional GitHub and Google login

Supply both credentials for a provider or leave both absent:

GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...

GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

Register these exact callback URLs with the providers:

Only configured providers appear on registration and sign-in pages. One missing value is a startup error, so users are not offered a login method which cannot work.

Connect EtherPK Client

Sign in to the server, open Access tokens, create a Personal Access Token, then choose Custom server in the Client's Sync settings. Enter the server origin and token. A PAT proves the local Sync Principal but cannot decrypt any graph by itself. Revoke a device or automation token from the same page when it is no longer needed.

Expiry. A token can be given one when you create it: 30 days, 90 days, a year, or no expiry. No expiry is the default, because a token pasted into a Client's sync settings is kept there indefinitely and an expiry would eventually break that sync. Choose one for anything you only need for a while, such as a script or a machine you are borrowing. An expired token stops working and disappears from the list; it does not need revoking. One account may hold up to 50 live tokens at once.

An operator deploying a standalone-only Client can set PUBLIC_CUSTOM_SYNC_URL to this Server's HTTPS origin. The Client pre-fills that address and hides Managed Sync when all managed OAuth settings are blank. Each user must still create and enter their own PAT. Once authenticated, Client shows the Server-confirmed email in its top navigation. This is the PAT identity, not a shared standalone Server browser session.

Choose the service-limit policy

ENTITLEMENT_MODE=unlimited is the normal self-hosted choice and needs no quota values.

For operator-owned limits, use fixed:

ENTITLEMENT_MODE=fixed
FIXED_ENTITLEMENT_LIMITS_JSON={"ownedGraphs":10,"ownedStorageBytes":1073741824,"playersPerGraph":5,"assetBytes":104857600,"assetChunks":1024}
ENTITLEMENT_SOFT_WARNING_PERCENT=80
ENTITLEMENT_EMERGENCY_STORAGE_HEADROOM_BYTES=10485760

Those numbers are examples of the required format, not recommended allowances. Pick values which fit the deployment. The emergency storage headroom lets a small amount of encrypted offline work land after the advertised allowance, while the warning threshold gives the Owner time to act.

remote is for an operator with an OIDC control plane which publishes EtherPK's signed Entitlement contract. It is not required for ordinary self-hosting.

Independence check

A standalone deployment should still register, sign in, create a PAT and sync when EtherPK-owned domains are unavailable. If it cannot, check that no managed Client preset or AUTH_MODE=oidc configuration has been used by mistake.