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:

  1. Give existing dogfood users an export deadline. Their accounts, server-side encrypted graphs and Recovery Codes will be replaced.
  2. Back up both PostgreSQL databases and the object store, then prove that the backups restore.
  3. Record who authorised deletion and where the final backups live.
  4. Drop the Corporate and Sync databases, then create their replacements. Do not apply either 0001_baseline.sql over 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:

  1. Use www.etherpk.com as Corporate's visible application, authentication origin and stable issuer. The naked domain redirects to www; no auth.etherpk.com application host is needed.
  2. Add Corporate's MANAGED_SERVER_CLIENT_ID, MANAGED_SERVER_CLIENT_SECRET, MANAGED_SERVER_REDIRECT_URIS and MANAGED_SERVER_POST_LOGOUT_REDIRECT_URIS.
  3. Add Sync's matching OIDC_PORTAL_CLIENT_ID and OIDC_PORTAL_CLIENT_SECRET, plus the independent OIDC_PORTAL_SESSION_SECRET.
  4. Keep MANAGED_SYNC_CONTROL_URL for Entitlement delivery and usage only. It is no longer an ordinary browser login redirect destination.
  5. Delete both PostgreSQL databases manually, then use the create-only infrastructure operator to create their eu-west-2 replacements and update SOPS. Sync now includes managed_portal_sessions; Corporate's baseline and OAuth registrations have also changed.
  6. 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:corporate is only needed when a local development process uses a local Corporate database.
  7. Keep managed mode active in the hosted Sync environment. Use the complete commented standalone block in apps/server/.env for the separate private Server deployment, with a different database and all-new secrets.
  8. Set Corporate STRIPE_ENABLED=false for the initial release. A Stripe catalogue, API key, webhook secret and Price ID are not required until paid plans are deliberately enabled later.
  9. Add the two exact coordinated-logout continuation URIs shown below. Server migration 0003 invalidates 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:

  1. Add the https://www.etherpk.com/api/auth/callback/github and https://www.etherpk.com/api/auth/callback/google callbacks 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.
  2. 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 migration 0002_canonical_corporate_issuer.sql before it accepts a token from the new issuer.
  3. 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.
  4. Passkeys registered with auth.etherpk.com as their RP ID cannot authenticate on www. Affected users must use another sign-in method once and register a new passkey.
  5. Remove the old social callbacks and the auth.etherpk.com DNS record after the new flow has been verified. There is no auth ingress 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:

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:

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:

  1. Confirm Corporate has STRIPE_ENABLED=false and a blank STRIPE_SYNC_PLUS_PRICE_ID. No Stripe catalogue or webhook is required for this initial release.
  2. Confirm the Corporate database etherpk_corporate_production and its role exist on the shared cluster, created by infrastructure migration 009. The role password in that migration must equal the password in both URLs in the Corporate Secret.
  3. 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-server and /api/auth/jwks through https://www.etherpk.com. Its logs should contain three Materialised OAuth ... messages. If build/oauth-seed.js reports Dynamic require of "fs" is not supported, the pod is using the earlier broken image which bundled dotenv; deploy the current Corporate image. Do not change the database or OAuth secrets for that error.
  4. Confirm the Sync database etherpk_server_production and its role exist on the shared cluster, created by infrastructure migration 008. It is a separate database and role from Corporate, with its own password.
  5. Deploy Sync in oidc / remote mode. Startup acquires the Server migration advisory lock and applies the baseline. Check https://server.etherpk.com/.well-known/etherpk and 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.
  6. Deploy Client only after both service probes pass.
  7. 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:

  1. Apply terraform/stripe-sandbox then terraform/stripe-live in appsoftware-infra.
  2. Read the etherpk webhook endpoint's signing secret from terraform state and copy it into the Corporate SOPS Secret with a live restricted API key.
  3. Copy the live price_... identifier into STRIPE_SYNC_PLUS_PRICE_ID and change STRIPE_ENABLED to true in the Corporate ConfigMap.
  4. Run the infrastructure encryption and tests, increment Corporate's configHash, then commit and deploy the reviewed GitOps change.
  5. 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.