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:
- GitHub:
https://sync.example.com/api/auth/callback/github - Google:
https://sync.example.com/api/auth/callback/google
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.