Managed service production configuration
This guide is for the operator of the official hosted EtherPK service. It covers the three production workloads, the allowed configuration values and the order in which to release them.
If you are running your own Sync Server, use Self Hosting The Sync Server instead. A normal self-hosted deployment does not need Corporate, Stripe or any EtherPK-owned domain.
The working examples are also commented beside the values in apps/client/.env,
apps/corporate/.env and apps/server/.env. Those files contain local-development values. Their
SOPS-encrypted .env.enc copies are the checked-in source for deployment secrets, but production
must replace every development secret and localhost origin.
The Sync environment keeps managed OIDC / remote Entitlements active because that is the primary development path. A complete standalone / unlimited profile sits directly beneath it as a commented block. To switch locally, comment every setting in the managed block and uncomment the complete standalone block. Do not mix their authentication variables. A second private deployment should have its own environment and secrets rather than editing the managed deployment in place.
Important: this release needs fresh databases
The managed identity and billing model has a new Corporate baseline and a new Sync baseline. There is no migration, backfill or account-linking path from the earlier dogfood model.
Before changing production:
- Give existing dogfood users an export deadline. Their accounts, server-side encrypted graphs and Recovery Codes will be replaced.
- Back up both PostgreSQL databases and the object store, then prove that the backups restore.
- Record who authorised deletion and where the final backups live.
- Drop the Corporate and Sync databases, then create their replacements. Do not apply either
0001_baseline.sqlover an old database.
The Client has no server database.
Production runs on the shared in-cluster PostgreSQL cluster managed by appsoftware-infra, not on
a managed database provider. Each of the three workloads has its own database and login role,
created by a numbered infrastructure migration:
| Workload | Database | Role |
|---|---|---|
| Corporate | etherpk_corporate_production |
etherpk_corporate_production_user |
| Managed Sync | etherpk_server_production |
etherpk_server_production_user |
| Private standalone Sync | appsoftware_etherpk_server_production |
appsoftware_etherpk_server_production_user |
Infrastructure supplies an empty database and role. EtherPK builds its own schema at startup, so
both migration steps are required and neither replaces the other. Each deployment Secret holds a
pooled DATABASE_APPLICATION_URL and a direct DATABASE_MIGRATION_URL which share one role and
password and differ only by host. The migration URL must stay direct because the migration runner
holds a session advisory lock across an explicit transaction, which a pooler cannot carry.
Recovery is by scheduled logical dump to object storage rather than point-in-time restore. A backup task must be registered by hand for each database; nothing does it automatically. This means the recoverable points are the moments a dump ran, not any arbitrary instant.
Neon was the previous host and was retired on cost grounds. Its create-only operator is retained
but dormant in appsoftware-infra; see Neon Database Provisioning. Do not run it against the
live deployment.
The same rule applies to development databases created before this baseline was finalised. Drop
and recreate both the Corporate and Sync development databases, then run their current baselines
and seed operations. Applying the edited 0001_baseline.sql to an existing database is not a
supported upgrade path.
You do not need to clear the Client's IndexedDB. The Client now partitions Server-backed graph records and cached vault keys by the authenticated Server origin and Principal. It adopts an older unscoped graph record only after the current account proves membership. The old unscoped vault-key cache is deliberately discarded because it cannot safely be attributed to an account, so each browser may ask for the Recovery Code once after this upgrade.
Production changes in this release
These are the configuration and deployment changes compared with the earlier managed-auth build:
- Use
www.etherpk.comas Corporate's visible application, authentication origin and stable issuer. The naked domain redirects towww; noauth.etherpk.comapplication host is needed. - Add Corporate's
MANAGED_SERVER_CLIENT_ID,MANAGED_SERVER_CLIENT_SECRET,MANAGED_SERVER_REDIRECT_URISandMANAGED_SERVER_POST_LOGOUT_REDIRECT_URIS. - Add Sync's matching
OIDC_PORTAL_CLIENT_IDandOIDC_PORTAL_CLIENT_SECRET, plus the independentOIDC_PORTAL_SESSION_SECRET. - Keep
MANAGED_SYNC_CONTROL_URLfor Entitlement delivery and usage only. It is no longer an ordinary browser login redirect destination. - Delete both PostgreSQL databases manually, then use the create-only infrastructure operator to
create their
eu-west-2replacements and update SOPS. Sync now includesmanaged_portal_sessions; Corporate's baseline and OAuth registrations have also changed. - Deploy the current Corporate image so startup migrates the database and idempotently
materialises all three configured OAuth clients before accepting traffic. GitOps ConfigMaps and
SOPS Secrets are the production source of truth.
pnpm oauth:seed:corporateis only needed when a local development process uses a local Corporate database. - Keep managed mode active in the hosted Sync environment. Use the complete commented standalone
block in
apps/server/.envfor the separate private Server deployment, with a different database and all-new secrets. - Set Corporate
STRIPE_ENABLED=falsefor the initial release. A Stripe catalogue, API key, webhook secret and Price ID are not required until paid plans are deliberately enabled later. - Add the two exact coordinated-logout continuation URIs shown below. Server migration
0003invalidates existing portal login sessions once so it can require the encrypted ID token used by RP-Initiated Logout. Existing managed Client cookies do not contain that ID token either, so Client rejects the old cookie and asks the user to connect again. Neither change affects Principals, graph ownership, PATs, local graphs or sync data.
Public hosts
| Host | Workload | Purpose |
|---|---|---|
https://www.etherpk.com |
Corporate | Marketing, accounts, security, billing and the stable OIDC issuer |
https://app.etherpk.com |
Client | Local-first web Client and its own OAuth session |
https://server.etherpk.com |
Sync | Managed Sync API, WebSocket relay and encrypted asset control plane |
Route www.etherpk.com to Corporate and permanently redirect etherpk.com to it. Account,
security, billing, OAuth and auth API routes remain on www, so users do not move to another
Corporate hostname during sign-in.
Do not enable a shared .etherpk.com session cookie. A parent-domain cookie would be sent to
every EtherPK subdomain, including Client and Server, and cannot be restricted to only the naked
domain and www. Corporate therefore keeps a host-only cookie on www.etherpk.com; the apex
redirect makes the same signed-in state visible to someone entering etherpk.com. Corporate
single sign-on lets an anonymous managed Client or Server document visit complete a non-interactive
OAuth check without another password prompt. If Corporate has no session, that app simply renders
signed out. Neither app shares cookies or reads Corporate's cookie from its backend.
All three application hosts need valid TLS before the applications are released. Public
application URLs in the tables below must use HTTPS, contain no credentials and, unless described
as a callback, be an origin without a path, query or fragment. Plain HTTP is accepted only for
loopback development origins, including localhost, *.localhost, 127.0.0.1 and [::1]; it is
never an allowed production substitute for TLS.
Moving the issuer from auth to www
Changing the issuer is a coordinated authentication cutover, not a database migration. Before deploying it:
- Add the
https://www.etherpk.com/api/auth/callback/githubandhttps://www.etherpk.com/api/auth/callback/googlecallbacks to the enabled social providers. Where the provider supports multiple exact callbacks, the old callback may remain during the short rollout window; otherwise replace it as part of the deployment. - Deploy Corporate, managed Server and managed Client from the same release, with every issuer
setting changed to
https://www.etherpk.com. Do not apply the new ConfigMaps to an older Server image: the selected release must contain migration0002_canonical_corporate_issuer.sqlbefore it accepts a token from the new issuer. - Expect users to sign in again. The old host-only Corporate cookie is correctly unavailable on
www, and existing Client / Server access tokens name the retired issuer. - Passkeys registered with
auth.etherpk.comas their RP ID cannot authenticate onwww. Affected users must use another sign-in method once and register a new passkey. - Remove the old social callbacks and the
auth.etherpk.comDNS record after the new flow has been verified. There is noauthingress in the final configuration.
No database needs to be dropped or recreated for this hostname change. Server migration 0002
rewrites the old issuer on existing Auth Bindings so each user retains the same Sync Principal and
graph ownership. Corporate user, OAuth client and signing-key rows remain valid; browser sessions
and issued access tokens still require renewal as described above.
Corporate configuration
Corporate owns account authentication, the OAuth provider, Billing Accounts, Stripe and signed Entitlements.
Database and public origin
| Setting | Production value / allowed values |
|---|---|
DATABASE_APPLICATION_URL |
Pooled connection to the Corporate database |
DATABASE_MIGRATION_URL |
Direct, non-pooler connection to the same Corporate database |
ORIGIN |
https://www.etherpk.com |
BETTER_AUTH_URL |
https://www.etherpk.com |
PUBLIC_APP_URL |
https://www.etherpk.com |
CLIENT_PUBLIC_URL |
https://app.etherpk.com; Corporate's Graphs target and fixed global sign-out hop |
BETTER_AUTH_SECRET |
A new secret containing at least 32 bytes |
PASSKEY_RP_ID |
Optional. Omit it to use www.etherpk.com; parent etherpk.com is valid but broadens the RP scope |
Do not reuse the Corporate Better Auth secret for Client cookies, Sync Tokens or Entitlement reconciliation.
The Corporate and Sync migration runners each acquire a distinct PostgreSQL session-level advisory
lock through their direct connection before reading schema_versions. If two replicas start
together, one waits while the other migrates. The waiting replica then reads the completed schema
state and skips those files. No lock setting is required in the ConfigMap. PostgreSQL releases the
lock automatically if the migration connection closes or the process terminates.
The Kubernetes node must also provide enough inotify instances for K3s, containerd and application
containers. The Ubuntu default of 128 can be exhausted because root-owned containers share the
host root user's quota, producing failed to create fsnotify watcher: too many open files and
breaking log streams or system services. The appsoftware-infra production overlay includes a
node-tuning DaemonSet which sets fs.inotify.max_user_instances to at least 1024 after every
node restart. A different deployment platform should apply an equivalent persistent host setting.
This is node configuration, not an EtherPK environment variable.
Set adapter-node's fixed ORIGIN, Better Auth's base URL and the public client URL to the same
https://www.etherpk.com origin. Corporate has one browser host, so it does not need dynamic
forwarded-host resolution or a second authentication ingress.
If GitHub or Google sign-in is enabled, configure both values in the provider pair. Leave both absent to disable that provider.
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
Register these callbacks with the providers:
- GitHub:
https://www.etherpk.com/api/auth/callback/github - Google:
https://www.etherpk.com/api/auth/callback/google
For Google's web client, use https://www.etherpk.com as the authorised JavaScript origin and
the Google callback above as the authorised redirect URI. Better Auth completes the callback on
the server, but keeping the visible Corporate origin explicit avoids retaining the retired host in
the provider configuration.
Official OAuth clients
MANAGED_CLIENT_ID=etherpk-client
MANAGED_CLIENT_REDIRECT_URIS=https://app.etherpk.com/auth/callback
MANAGED_CLIENT_POST_LOGOUT_REDIRECT_URIS=https://app.etherpk.com/,https://server.etherpk.com/auth/portal/logout/managed?finish=client
MANAGED_CLIENT_ID may be another non-empty identifier, but it must match
MANAGED_OAUTH_CLIENT_ID in Client. URI lists are comma-separated exact HTTPS URIs. Wildcards are
not allowed.
The production values above register only the official browser Client using Authorization Code
with PKCE. The private etherpkclient.raptor-hen.ts.net Client is standalone-only and must not appear in
Corporate redirect configuration. It has no Corporate issuer, managed Client id or managed
session secret.
Register the managed Server portal as a different, confidential client:
MANAGED_SERVER_CLIENT_ID=etherpk-server-portal
MANAGED_SERVER_CLIENT_SECRET=<new secret containing at least 32 bytes>
MANAGED_SERVER_REDIRECT_URIS=https://server.etherpk.com/auth/portal/callback
MANAGED_SERVER_POST_LOGOUT_REDIRECT_URIS=https://server.etherpk.com/,https://app.etherpk.com/auth/logout/managed?finish=server
MANAGED_SERVER_CLIENT_ID must match Sync's OIDC_PORTAL_CLIENT_ID and the secret must match
OIDC_PORTAL_CLIENT_SECRET. The redirect and post-logout lists contain comma-separated exact
HTTPS URLs. Wildcards are not allowed. The callback path is fixed by the Server and cannot be
changed independently. Keep both root and continuation post-logout values for each client. The
continuations let each first-party origin clear its own host-only session; they are not arbitrary
user return URLs.
The Server portal client is confidential because its SvelteKit server exchanges codes and stores refresh capability on the server. The browser Client remains public because it cannot safely keep a client secret. Do not reuse either client id or secret for the other application. The reconciliation client is a third OAuth client, scoped to control-plane work only.
Plans and limits
No production price or allowance is compiled into EtherPK. Choose every value explicitly. The two plans are Free and Sync+ (ADR 0068, Managed sync is paid only, with a trial, and lapsed graph data is deleted): Free owns nothing on the managed Sync Server, so its limits are all zero, and Sync+ carries the whole managed allowance.
MANAGED_SYNC_PLUS_PRICE_UNIT_AMOUNT_CENTS=999
MANAGED_SYNC_PLUS_PRICE_CURRENCY=usd
MANAGED_SYNC_PLUS_BILLING_INTERVAL=month
MANAGED_SYNC_PLUS_TRIAL_DAYS=14
MANAGED_FREE_LIMITS_JSON={"ownedGraphs":0,"ownedStorageBytes":0,"playersPerGraph":0,"assetBytes":0,"assetChunks":0}
MANAGED_SYNC_PLUS_LIMITS_JSON={"ownedGraphs":25,"ownedStorageBytes":53687091200,"playersPerGraph":10,"assetBytes":536870912,"assetChunks":128}
MANAGED_USAGE_WARNING_PERCENT=80
MANAGED_PAYMENT_GRACE_DAYS=7
MANAGED_ENTITLEMENT_TTL_SECONDS=3600
The price figures are what the pricing and billing pages display. The price Stripe actually
charges is the terraform-managed one that STRIPE_SYNC_PLUS_PRICE_ID names, so the two must be
kept in agreement by hand. The trial length is applied by Corporate on the Checkout Session of a
Billing Account's first subscription; 0 disables it.
| Setting | Allowed values |
|---|---|
MANAGED_SYNC_PLUS_PRICE_UNIT_AMOUNT_CENTS |
Positive safe integer in the currency's minor unit |
MANAGED_SYNC_PLUS_PRICE_CURRENCY |
Three letters, for example gbp; stored lower-case |
MANAGED_SYNC_PLUS_BILLING_INTERVAL |
Exactly month or year |
MANAGED_SYNC_PLUS_TRIAL_DAYS |
Non-negative integer; 0 means no trial |
MANAGED_FREE_LIMITS_JSON |
Strict limits object described below |
MANAGED_SYNC_PLUS_LIMITS_JSON |
Strict limits object described below |
MANAGED_USAGE_WARNING_PERCENT |
Integer from 1 to 99 |
MANAGED_PAYMENT_GRACE_DAYS |
Positive integer |
MANAGED_ENTITLEMENT_TTL_SECONDS |
Positive integer |
Each limits object must contain all five fields, no extra fields, and non-negative integer values:
| Field | Meaning |
|---|---|
ownedGraphs |
Graphs the Billing Account may own |
ownedStorageBytes |
Encrypted document and asset bytes across owned graphs |
playersPerGraph |
Invited or active Players allowed in one owned graph |
assetBytes |
Maximum declared size of one encrypted asset |
assetChunks |
Maximum chunks in one encrypted asset |
Stripe
Stripe is opt-in. Use exactly one of these modes:
| Setting | Allowed values | Behaviour |
|---|---|---|
STRIPE_ENABLED |
false, absent or blank |
Paid controls are hidden. Checkout, customer portal and webhook routes return HTTP 503 before any Stripe call. New accounts still receive their Free Entitlement, which owns no managed graphs. |
STRIPE_ENABLED |
true |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and STRIPE_SYNC_PLUS_PRICE_ID must all be configured for the same Stripe test or live mode. |
Any other non-empty value is rejected during configuration parsing. The recommended initial production configuration is:
STRIPE_ENABLED=false
STRIPE_SYNC_PLUS_PRICE_ID=
The Stripe credentials in the SOPS Secret may remain blank while billing is disabled. Existing placeholder strings are also ignored in this mode, but blank values communicate the state more clearly. Authentication, OAuth and managed sign-in remain active; because Free owns no managed graphs, managed sync itself is only usable once billing is enabled or the simulated plan is on.
Do not use this switch as a way to pause a live subscription service. When Stripe is disabled, Corporate cannot receive subscription changes and existing durable subscription state is not automatically cancelled or reconciled. Cancel or pause billing in Stripe itself and plan the operational change before disabling an established paid service.
To enable paid plans, store credentials in the Corporate SOPS Secret:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Then store the explicit switch and reviewed Price ID in the Corporate ConfigMap:
STRIPE_ENABLED=true
STRIPE_SYNC_PLUS_PRICE_ID=price_...
The Price ID identifies a public Stripe catalogue object and grants no API access. Keeping it in the ConfigMap makes the manual catalogue decision visible in the deployment source. Never place the Stripe secret key or webhook signing secret there.
The Stripe product (etherpk_sync_plus), price (etherpk_sync_plus_monthly_usd) and webhook
endpoint (etherpk) are managed in the appsoftware-infra terraform, in terraform/stripe-live
and terraform/stripe-sandbox, alongside the other AppSoftware apps. Apply the module, read the
price ID from tofu state show, and paste it into the ConfigMap as STRIPE_SYNC_PLUS_PRICE_ID.
Test and live objects have different IDs. Nothing in this repository creates catalogue objects.
The production Stripe webhook the terraform creates is:
https://www.etherpk.com/api/webhooks/stripe
Subscribe it to:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deleted
The webhook signing secret belongs to this exact endpoint. Do not assume the secret from the old
Sync endpoint can be reused. Set the secret, Price ID and STRIPE_ENABLED=true in one reviewed
GitOps change, increment Corporate's configHash, then deploy a Corporate image which supports
this switch.
Entitlement delivery and usage
MANAGED_SYNC_CONTROL_URL=https://server.etherpk.com
ENTITLEMENT_DELIVERY_INTERVAL_SECONDS=30
ENTITLEMENT_RECONCILIATION_CLIENT_ID=etherpk-sync-reconciliation
ENTITLEMENT_RECONCILIATION_CLIENT_SECRET=<new secret containing at least 32 bytes>
ENTITLEMENT_DELIVERY_INTERVAL_SECONDS may be any positive integer and defaults to 30 when
absent. Set it explicitly in production. MANAGED_SYNC_CONTROL_URL must be the exact public Sync
origin, without a path, query or fragment. It is the control-plane API origin and must exactly
match the Sync deployment's SERVER_PUBLIC_URL. It is not a browser redirect allowlist. Browser
returns use the exact MANAGED_SERVER_REDIRECT_URIS registration above. Corporate also uses this
canonical public origin for its Sync Server navigation link.
The reconciliation client id may be another non-empty identifier, but its id and secret must be identical in Corporate and Sync.
The production Corporate container runs its migration and then reconciles the OAuth configuration
before starting the web process. It idempotently materialises the public Client, confidential
Server portal client and confidential reconciliation client in the Corporate database. A failed
reconciliation prevents the deployment from accepting traffic. A database recreation therefore
does not require an operator connection to production: the next Corporate startup reconstructs
the three records from the committed ConfigMap and Secrets. The local
Corporate development scripts perform the same operation before starting Vite and load
apps/corporate/.env themselves. Use pnpm oauth:seed:corporate to run the local reconciliation
without starting the application. The production bundle does not load an .env file; it reads the
ConfigMap and Secret values injected by Kubernetes. Either path replaces stored hashes when a
configured client secret is rotated.
Keep the existing Corporate mail, contact, attribution, logging and observability values. They remain Corporate concerns.
Sync configuration
Managed Sync is an OIDC resource server for its API and a confidential OAuth client for its own browser portal. It has no local managed-user password database and no Stripe configuration.
Database, relay and browser access
DATABASE_APPLICATION_URL=<pooled Sync database connection>
DATABASE_MIGRATION_URL=<direct Sync database connection>
SYNC_TOKEN_SECRET=<separate strong random secret>
SYNC_ALLOWED_ORIGINS=https://app.etherpk.com
SYNC_ALLOWED_ORIGINS must be set. It accepts a comma-separated list of exact browser origins.
A production build with it unset refuses every cross-origin sync request rather than reflecting
whichever origin asked; only a development build still reflects an unset allowlist. Sync API
preflights allow Authorization, Content-Type and x-sync-token.
SYNC_MAX_MESSAGE_BYTES is optional. When set, it must be a positive safe integer. Leave it absent
to use the protocol limit unless a separately tested deployment policy requires a smaller value.
The same applies to the per-connection bounds SYNC_MAX_PRESENCE_ENVELOPE_BYTES (16 KiB),
SYNC_MAX_SUBSCRIPTIONS_PER_CONNECTION (4,096), SYNC_MAX_OUTBOUND_BUFFERED_BYTES (64 MiB) and
SYNC_MAX_SOCKETS_PER_USER_GRAPH (10): each is optional, each must be a positive safe integer,
and the defaults are what the managed deployment runs.
Managed authentication
Use these exact protocol values:
AUTH_MODE=oidc
REGISTRATION_ALLOWED_IPS=
SERVER_PUBLIC_URL=https://server.etherpk.com
CLIENT_PUBLIC_URL=https://app.etherpk.com
OIDC_ISSUER=https://www.etherpk.com
OIDC_AUDIENCE=urn:etherpk:managed-sync
OIDC_REQUIRED_SCOPE=sync
OIDC_PORTAL_CLIENT_ID=etherpk-server-portal
OIDC_PORTAL_CLIENT_SECRET=<same secret as Corporate MANAGED_SERVER_CLIENT_SECRET>
OIDC_PORTAL_SESSION_SECRET=<new Server-only secret containing at least 32 bytes>
AUTH_MODE allows standalone or oidc, but the official managed deployment must use oidc.
Keep REGISTRATION_ALLOWED_IPS empty in managed mode. Managed account registration belongs to
Corporate and is not restricted by the Sync Server's standalone policy. A non-empty value in OIDC
mode is rejected at startup so an operator cannot mistake it for an active managed restriction.
SERVER_PUBLIC_URL must be the exact public Sync origin and must match Corporate's
MANAGED_SYNC_CONTROL_URL. The Server derives its only callback as
SERVER_PUBLIC_URL/auth/portal/callback and its coordinated post-logout continuation from
CLIENT_PUBLIC_URL. Both origins must be reachable by the browser and must not be internal proxy
hostnames. The audience and scope are protocol constants, not plan choices. HTTP origins are
accepted only for a loopback development host.
CLIENT_PUBLIC_URL must be the exact public Client origin. Corporate and Server both use it to
build a /graphs navigation link, and managed sign-out uses it as the fixed Client continuation
which clears the Client's own host-only session. Managed production therefore sets it to
https://app.etherpk.com; a standalone Server sets it to its own paired Client instead. It does
not share cookies or make the Client an OAuth issuer.
OIDC_PORTAL_CLIENT_ID and OIDC_PORTAL_CLIENT_SECRET must match the Corporate registration.
OIDC_PORTAL_SESSION_SECRET is independent. It encrypts access and refresh tokens held in the
Sync database and must not reuse the portal client secret, SYNC_TOKEN_SECRET or any Corporate
secret. Both portal secrets must contain at least 32 bytes.
The reverse proxy must preserve the public HTTPS scheme and host. Do not terminate TLS and then
publish an internal HTTP origin through SERVER_PUBLIC_URL. The Server portal cookie is Secure,
HTTP-only, SameSite Lax and host-only, so production sign-in requires HTTPS on the exact public
host.
No extra setting enables managed Server single sign-on. With the portal client values above, the
Server automatically checks Corporate on the first anonymous document visit using OIDC
prompt=none. An existing Corporate session creates the Server's own portal session without
showing a sign-in page. No Corporate session returns the visitor to the same Server page signed
out, without a 400 callback error or redirect loop. API and asset requests never start this flow.
Managed Client uses the same top-level prompt=none pattern with its public PKCE client. A valid
Corporate session creates the Client's own encrypted refresh session and restores the managed
connection choice without showing sign-in UI. No Corporate session returns to the requested Client
page signed out with a one-request loop guard. Disconnect this device suppresses automatic
Client reconnection until the user chooses Sign in again. A standalone-only Client has no
Corporate OAuth configuration, so it continues to authenticate only with its configured PAT.
Corporate, Client and Server expose Sign out of EtherPK. Starting it in any managed app clears all three browser sessions through fixed front-channel continuations and attempts to revoke both OAuth refresh grants. The sessions remain host-only; no wildcard-domain cookie is enabled.
Managed Corporate and Server menus expose no application-only sign-out. Their single sign-out
button always runs the complete managed cascade. Client retains Disconnect this device because
it removes only that Client's Sync connection and is not presented as an account sign-out. Client
top navigation obtains its displayed identity from the configured Server's /api/v1/sync/me
response, not a shared cookie. In managed mode its menu includes Account, Sign out of
EtherPK and Disconnect this device.
The shared global header keeps destinations in this order: Account, Sync Server, Graphs.
Corporate, Client and Server show all three in managed mode. The tier is cross-application only, so
no app puts a Dashboard in it: the Server reaches its own dashboard from its section navigation
and account menu, the Client has no dashboard at all, and Corporate never had one. Public pages omit
Account until the visitor is signed in. Account links from Client and managed Server go to
www.etherpk.com. Standalone Client and Server omit the Corporate Account link and show Sync
Server, then Graphs. Server and Corporate sidebars start below the full-width header and hold
only local sections.
All public landing pages use /home; /marketing is now a compatibility redirect. Corporate root
visits go to /account when signed in and /home when signed out. Server root visits go to
/dashboard when signed in and /home when signed out. Client root visits resolve in the browser
after the optional silent SSO check, opening the device's last-used graph and falling back to
/home when the device has no graphs (or to /graphs when it has several and no clear last one).
Authenticated users can still open /home explicitly on every app.
Standalone Server has one local Sign out action and no Corporate cascade. See Managed Sync
And Self Hosting for diagrams from every starting app.
Remove all non-empty standalone-only settings from managed Sync. Startup deliberately fails if any
of these are set with AUTH_MODE=oidc:
BETTER_AUTH_URL
BETTER_AUTH_SECRET
AUTH_EMAIL_PASSWORD_ENABLED
GITHUB_CLIENT_ID
GITHUB_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
PASSKEY_RP_ID
PASSKEY_ORIGIN
REGISTRATION_ALLOWED_IPS
An empty REGISTRATION_ALLOWED_IPS= value is treated as unset and is safe in the shared managed
environment template.
Managed Entitlements
ENTITLEMENT_MODE=remote
ENTITLEMENT_ISSUER=https://www.etherpk.com
ENTITLEMENT_RECONCILIATION_CLIENT_ID=etherpk-sync-reconciliation
ENTITLEMENT_RECONCILIATION_CLIENT_SECRET=<same reconciliation secret as Corporate>
ENTITLEMENT_RECONCILIATION_INTERVAL_SECONDS=300
ENTITLEMENT_SOFT_WARNING_PERCENT=80
ENTITLEMENT_EMERGENCY_STORAGE_HEADROOM_BYTES=10485760
| Setting | Allowed values |
|---|---|
ENTITLEMENT_MODE |
unlimited, fixed or remote; managed production uses remote |
ENTITLEMENT_RECONCILIATION_INTERVAL_SECONDS |
Positive integer |
ENTITLEMENT_SOFT_WARNING_PERCENT |
Integer from 1 to 99 |
ENTITLEMENT_EMERGENCY_STORAGE_HEADROOM_BYTES |
Non-negative integer |
The example timings and headroom are not automatic defaults. Choose them as an operational policy.
Reconciliation should repair a missed push well before half of
MANAGED_ENTITLEMENT_TTL_SECONDS has elapsed.
ENTITLEMENT_ISSUER must be an HTTPS origin in production. Its own parser accepts only the
documented loopback development values; this allows the local Corporate server to exercise the
same managed flow without weakening a deployed issuer.
Encrypted asset storage
Keep the existing private object bucket and configure Sync with:
ASSET_STORAGE_BACKEND=s3
S3_ENDPOINT=...
S3_BUCKET=...
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_REGION=auto
The current runtime enables remote assets only when ASSET_STORAGE_BACKEND=s3 and all four S3
connection values are present. S3_REGION defaults to auto.
The bucket needs this CORS policy because the browser uploads encrypted chunks directly:
[
{
"AllowedOrigins": ["https://app.etherpk.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
Stripe now belongs only to Corporate. Remove every STRIPE_* setting from Sync, including the old
STRIPE_PRICE_SUBSCRIPTION and STRIPE_PRICE_UNIT_AMOUNT_CENTS settings. The managed Sync workload
also no longer needs social-provider or mail credentials. Server navigation does not advertise
Contact, Terms or Privacy; those public destinations remain Corporate concerns.
Client configuration
The official Client has no database, Better Auth instance, social-provider secret, Stripe secret or S3 credential. It needs these values only for managed sign-in:
CLIENT_PUBLIC_URL=https://app.etherpk.com
CORPORATE_ISSUER=https://www.etherpk.com
MANAGED_OAUTH_CLIENT_ID=etherpk-client
MANAGED_SYNC_URL=https://server.etherpk.com
CLIENT_SESSION_SECRET=<new secret containing at least 32 bytes>
PUBLIC_MANAGED_SYNC_URL=https://server.etherpk.com
CLIENT_PUBLIC_URL, CORPORATE_ISSUER and MANAGED_SYNC_URL must be HTTPS origins. The OAuth
client id must exactly match Corporate's MANAGED_CLIENT_ID. PUBLIC_MANAGED_SYNC_URL is exposed
to browser code and is not a secret. It also supplies the Client navbar's Sync Server link.
CORPORATE_ISSUER supplies the managed Account link as well as the OIDC issuer, so production
must use the visible https://www.etherpk.com origin.
Generate CLIENT_SESSION_SECRET independently. It encrypts the Client-origin HTTP-only refresh
session cookie and must not be the Corporate Better Auth secret.
For a standalone-only Client, disable the managed preset as one complete unit:
CLIENT_PUBLIC_URL=https://client.example.com
CORPORATE_ISSUER=
MANAGED_OAUTH_CLIENT_ID=
MANAGED_SYNC_URL=
PUBLIC_MANAGED_SYNC_URL=
PUBLIC_CUSTOM_SYNC_URL=https://sync.example.com
Omit CLIENT_SESSION_SECRET from that workload. With the four managed settings blank, the Client
hides Managed Sync and offers only the PAT-authenticated Custom server form.
PUBLIC_CUSTOM_SYNC_URL is optional and only pre-fills that form; it does not sign the user in or
remove the need for a PAT. In a standalone-only deployment it also supplies the Client navbar's
Sync Server link. Once the user supplies a valid PAT, the Client navbar shows the
Server-confirmed email and provides Account, Access tokens and device-disconnection actions for
that exact origin. Do not put a standalone Server in MANAGED_SYNC_URL: standalone uses a local
account and PAT, while a managed Server expects a bearer token issued by the configured OIDC provider.
Remove legacy Corporate-style values from the Client workload, including BETTER_AUTH_*,
DATABASE_*, GITHUB_*, GOOGLE_*, STRIPE_*, mail, attribution and S3 credentials.
Release order
Promote the same release tag for all three managed workloads, in this order:
- Confirm Corporate has
STRIPE_ENABLED=falseand a blankSTRIPE_SYNC_PLUS_PRICE_ID. No Stripe catalogue or webhook is required for this initial release. - Confirm the Corporate database
etherpk_corporate_productionand its role exist on the shared cluster, created by infrastructure migration009. The role password in that migration must equal the password in both URLs in the Corporate Secret. - Deploy Corporate. Its startup acquires the Corporate migration advisory lock, applies the
baseline and materialises the configured OAuth clients before starting the web process. Check
/.well-known/openid-configuration,/.well-known/oauth-authorization-serverand/api/auth/jwksthroughhttps://www.etherpk.com. Its logs should contain threeMaterialised OAuth ...messages. Ifbuild/oauth-seed.jsreportsDynamic require of "fs" is not supported, the pod is using the earlier broken image which bundleddotenv; deploy the current Corporate image. Do not change the database or OAuth secrets for that error. - Confirm the Sync database
etherpk_server_productionand its role exist on the shared cluster, created by infrastructure migration008. It is a separate database and role from Corporate, with its own password. - Deploy Sync in
oidc/remotemode. Startup acquires the Server migration advisory lock and applies the baseline. Checkhttps://server.etherpk.com/.well-known/etherpkand confirm it reports the Accounts issuer and JWKS. Open a protected Server page, complete Corporate sign-in, and confirm the Server dashboard shows the authenticated account and plan. - Deploy Client only after both service probes pass.
- Register fresh dogfood accounts, create new Recovery Codes, switch between two accounts in one browser and run a browser asset upload / download check.
After the initial auth and Sync release is stable, enable paid billing separately:
- Apply
terraform/stripe-sandboxthenterraform/stripe-liveinappsoftware-infra. - Read the
etherpkwebhook endpoint's signing secret from terraform state and copy it into the Corporate SOPS Secret with a live restricted API key. - Copy the live
price_...identifier intoSTRIPE_SYNC_PLUS_PRICE_IDand changeSTRIPE_ENABLEDtotruein the Corporate ConfigMap. - Run the infrastructure encryption and tests, increment Corporate's
configHash, then commit and deploy the reviewed GitOps change. - Complete a live-mode smoke test with an intentionally controlled account before offering the paid plan publicly.
Keep Corporate and Sync at one replica with a recreate deployment during this rollout. The Client is stateless and may use its existing rolling deployment. Do not run an old application release against either new baseline.
Both Corporate and Sync drain on SIGTERM: in-flight requests finish, the Entitlement delivery
batch in flight completes, the database pool closes and the last logs ship, all inside a 10 s
budget that sits well within the default 30 s termination grace period. With Recreate that is
all a deploy needs. If either workload ever moves to a rolling update, a preStop sleep of a few
seconds becomes a prerequisite: the pod keeps receiving traffic for a moment after its endpoint is
withdrawn, and the drain cannot save a request that arrives after the process has stopped
listening.
The source repository keeps the identity / Entitlement boundary and operational evidence in its maintainer-only technical documentation.