Skip to content

Single-server self-hosting: instance-config refactor, selfhost deployment profile, fresh-install fixes - #2

Closed
flotob wants to merge 12 commits into
mainfrom
work/single-server
Closed

Single-server self-hosting: instance-config refactor, selfhost deployment profile, fresh-install fixes#2
flotob wants to merge 12 commits into
mainfrom
work/single-server

Conversation

@flotob

@flotob flotob commented Jul 13, 2026

Copy link
Copy Markdown

Goal

Make the complete Common Ground stack runnable on a single machine — 32 GB for a full production workload, 16 GB / 4–8 cores for a fresh instance — and make it easy for anyone to run CG locally or host their own instance.

Everything in this PR was validated on a real deployment: a fresh 8-core / 15 GiB Hetzner VPS now serves a fully working instance at https://cg.mogged.eu (built from this branch). Measured results below.

What's in here

1. Fresh-install fixes (0b6e48e)

Three things any new self-hoster hits on a clean machine:

  • mediasoup workers crash on modern kernelsio_uring_queue_init(): Operation not permitted under Docker's seccomp. MEDIASOUP_DISABLE_LIBURING=true is now the dev default (and compose gives mediasoup no restart policy, so this failure was silent and permanent).
  • hardhat container crash-loopsdocker/hardhat/node/package.json had unpinned ts-node/typescript ranges and no lockfile; a fresh image build resolves an incompatible pair (readConfig 'fileExists' TypeError). Pinned.
  • TokenSale dev deploy reverts — the cgstack branch of deploy.ts hardcoded an end timestamp of 2025-12-30, which is now in the past (OnlyFutureEndTimestampAllowed). Uses now+10y on the dev chain.

2. Instance identity decoupled from deployment mode (51e1d69)

Previously the app derived its mode and URLs from a hardcoded domain list — any domain other than app.cg/staging.app.cg silently ran with dev semantics, and several prod code paths were hard-broken on other domains:

  • session cookie pinned to .app.cglogin impossible on other domains
  • WebAuthn rpID pinned to id.app.cgpasskeys impossible
  • sitemap URLs and email sender hardcoded

Self-hosted instances now declare their identity at serve time via an injected window.__CG_INSTANCE__ script (deployment mode, app URL, CG ID URL, reCAPTCHA site key). The API server injects it into the index.html it serves for share links; the selfhost nginx image injects it into the statically served copies. src/common/instance.ts is the typed accessor; config.ts/urls.ts consult it first and fall back to the legacy domain detection.

app.cg, staging.app.cg and local dev behave exactly as before when no instance config is injected — cookie domain, rpID and sitemap all derive to their previous values.

reCAPTCHA is now optional: with no keys configured, the frontend hides the widget and the backend skips verification (with a warning). Registration works out of the box on private instances; instances that want captcha set their own keys.

3. Self-hosting profile (51897aa, dba7dd7)

A production deployment path for one machine:

  • docker/docker-compose.selfhost.yml — Caddy terminates TLS (automatic Let's Encrypt) for the app, the id. subdomain and mediasoup signalling (:4443); prod semantics on any domain; no hardhat/test contracts; postgres actually loads the tuned config; Redis capped at 512 MB each (was 2 GB); mediasoup auto-restarts.
  • docker/selfhost/init.sh — one-shot init: fresh secrets, matching S3 credentials, internal certs. Never overwrites.
  • docker/selfhost/selfhost.shbuild / up / down / logs / stats / update.
  • docker/SELFHOST.md — setup guide, optional-integrations matrix, tuning, backups.

A self-hoster's whole journey: two DNS records → init.sh <domain> <email>selfhost.sh buildselfhost.sh up.

4. Dev-stack fixes (d972d8b)

  • postgresql.conf was mounted but never loaded (no -c config_file=) — all tuning in that file, including max_connections=200, was silently inert. Now wired up.
  • updateFrontend.sh always died with a heap OOM: it ran craco without NODE_OPTIONS, and webpack needs ~3 GB (measured) against Node's ~2 GB default. Now 4 GB.
  • Removed the orphan, unpassworded redis-blockscout (its only consumer is commented out); dropped obsolete compose version attributes.

5. Bugs found by testing the live instance (a62f40d)

  • Community creation silently rolled back on fresh instances: the welcome-article template is a hardcoded prod article UUID that doesn't exist in a fresh DB; its failed INSERT aborted the whole creation transaction even though the error was caught. A savepoint now isolates the optional copy. This also protects app.cg — deleting that template article would break community creation the same way.
  • Self-hosted instances no longer attempt to load CG's matomo analytics.
  • Selfhost nginx: allow WASM compilation ('wasm-unsafe-eval' — web3 deps need it), SPA-shell fallback for deep links, /api/ws/ proxy, manifest-src 'self' for the CG ID manifest.

Measured results (8-core / 15 GiB VPS)

  • Full source build: ~17 min; webpack peaks at ~3 GB RSS (the 8 GB heap in build.sh is 2.5× headroom; selfhost build uses 4 GB + no sourcemaps)
  • Whole running stack (15 containers incl. Caddy): ~875 MiB RSS at idle
  • Verified end-to-end on cg.mogged.eu: Let's Encrypt issuance, email signup (captcha-less), login, session cookie on the instance domain, community creation, socket.io, full WebAuthn passkey ceremony (rp.id correct for the instance, verified: true, cross-origin session shared with the CG ID popup) — the passkey flow was driven headless with a CDP virtual authenticator.

Not in scope / follow-ups

  • Voice/video calls on the live instance not yet tested with real media (infrastructure verified: signalling TLS + UDP range reachable, 8 workers running)
  • Email flows need a SendGrid key (graceful degradation verified); generic SMTP support would be the biggest self-hoster win
  • Pre-built images (GHCR) to skip the source build
  • Known pre-existing bug (untouched): previewImageUpdate job fails with column "imageId" does not exist

🤖 Generated with Claude Code

flotob and others added 8 commits July 13, 2026 19:20
- docker/.env: default MEDIASOUP_DISABLE_LIBURING=true — io_uring is
  blocked under recent kernels/Docker seccomp, crashing all mediasoup
  workers on startup (and compose does not restart mediasoup)
- docker/hardhat: pin ts-node/typescript — unpinned ranges with no
  lockfile resolve to an incompatible pair, crash-looping the hardhat
  container (ts-node readConfig 'fileExists' TypeError)
- contracts/scripts/deploy.ts: TokenSale dev-chain end timestamp was
  hardcoded to 2025-12-30 (now in the past), making the constructor
  revert OnlyFutureEndTimestampAllowed; use now+10y on cgstack

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app previously derived its deployment mode and all URLs from a
hardcoded domain list (app.cg / staging.app.cg) — any other domain
silently ran with dev semantics, and several prod code paths were
outright broken on other domains (session cookie pinned to .app.cg,
WebAuthn rpID pinned to id.app.cg, sitemap URLs).

Self-hosted instances now declare their identity at serve time via an
injected window.__CG_INSTANCE__ script (deployment mode, app URL, CG ID
URL, reCAPTCHA site key):

- src/common/instance.ts: typed accessor for the injected config
- src/common/config.ts, src/data/util/urls.ts: instance config takes
  precedence; legacy domain detection unchanged as fallback
- srv/util/instanceConfig.ts + srv/api/getRoutes.ts: the API server
  injects the config into the index.html it serves for share links;
  the selfhost nginx image does the same for static serving
- srv/util/express.ts: session cookie domain derived from BASE_URL
  (.app.cg / .staging.app.cg unchanged)
- srv/api/cgid.ts: WebAuthn rpID/origin derived from CGID_URL so
  passkeys work on any domain
- srv/repositories/communities.ts: sitemap uses the instance URL
- srv/api/emails.ts: EMAIL_FROM env overrides the default sender
- reCAPTCHA is now optional: with no site/secret key configured, signup
  and trust verification run without captcha (frontend hides the
  widget, backend skips verification with a warning)

app.cg, staging.app.cg and local dev behave exactly as before when no
instance config is injected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New production deployment path for running the whole stack on one
machine (16 GB / 4-8 cores is enough — the stack idles well under 2 GB):

- docker/docker-compose.selfhost.yml: prod-semantics compose profile.
  Caddy terminates TLS (automatic Let's Encrypt) for the app, the CG ID
  subdomain and mediasoup signalling (wss :4443, internal self-signed
  hop). No hardhat/test-contract services, no orphan redis-blockscout,
  postgres actually loads the tuned postgresql.conf, redis capped at
  512mb each (was 2GB), seaweedfs volume limit raised to 1GB, mediasoup
  restarts automatically.
- docker/nginx/{Dockerfile_selfhost,nginx_selfhost.conf}: nginx image
  parameterized for arbitrary domains, with a CSP equivalent to prod
  and instance-config injection into the served index.html at container
  start (inject-instance-config.sh).
- docker/selfhost/init.sh: one-shot instance initialization — generates
  fresh secrets (.env.selfhost), matching S3 credentials and the
  internal mediasoup cert; never overwrites existing files.
- docker/selfhost/selfhost.sh: build/up/down/logs/stats/update wrapper.
- docker/SELFHOST.md: setup guide, optional-integration matrix
  (SendGrid, reCAPTCHA, RPC keys, Twitter, SumSub — all optional),
  resource tuning and backup notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- docker-compose.yml: pass -c config_file so postgres actually loads
  the mounted postgresql.conf (it was silently ignored — none of its
  tuning, including max_connections=200, ever applied)
- updateFrontend.sh: the craco build ran without NODE_OPTIONS and hit
  Node's default ~2GB heap limit — webpack needs ~3GB (measured), so
  the script always died with heap OOM; give it 4GB like build.sh
- comment out redis-blockscout: its only consumer (blockscout) is
  commented out, and it ran unpassworded with a 2GB cap
- drop the obsolete compose `version` attribute (warning spam)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prevents container/volume collisions with the dev stack when both
profiles are used from the same checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- communities.ts: creating a community silently rolled back entirely on
  fresh instances — the welcome-article template (a hardcoded prod
  article UUID) doesn't exist there, and its failed INSERT aborted the
  whole creation transaction despite being caught. A savepoint now
  isolates the optional article copy. This also protects app.cg: if the
  template article is ever deleted, community creation would break the
  same way.
- index.tsx: don't load CG's matomo analytics on self-hosted instances
  (the CSP blocked it anyway; now it isn't attempted)
- nginx_selfhost.conf:
  - allow WebAssembly compilation (script-src 'wasm-unsafe-eval') —
    web3 dependencies compile WASM at startup
  - serve the SPA shell for unknown paths so deep links work before
    the service worker controls navigation
  - proxy /api/ws/ to wsapi (websocket upgrade headers)
  - manifest-src 'self' for the CG ID web-app manifest

Verified on cg.mogged.eu: signup (captcha-less), login, community
creation, socket.io, and the full WebAuthn passkey ceremony
(rp.id id.cg.mogged.eu, verified=true, cross-origin session shared).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-hosted instances had all onchain features broken: the frontend's
Alchemy key is domain-locked to app.cg (every request fails CORS — this
also left Spark purchases stuck on "confirm ownership"), and the
backend's QUIKNODE_* endpoints were placeholders. Worse, the onchain
service crash-looped at startup: the premium-token bootstrap in
startEventListener runs in an unhandled async IIFE, and with dead RPC
endpoints its NOT_SUPPORTED throw killed the process on every boot.

- onchain robustness: the premium-token bootstrap and the event-listener
  setup can no longer crash the process; failures log and degrade
  (premium payments unavailable) instead
- public RPC defaults: init.sh prefills the QUIKNODE_*/INFURA_* vars
  (they take any JSON-RPC URL — the names are historical) with verified
  free public endpoints, so token gating, balances and premium payments
  work out of the box; operators swap in paid endpoints for scale
- CG_ACTIVE_CHAINS: one knob for which chains an instance offers.
  Backend services read it from env, the frontend receives the resolved
  list via the instance config — chain workers and every chain picker
  in the UI (token gating, wallets) stay consistent
- frontend: self-hosted instances skip the Alchemy provider and use
  each chain's default public RPC (wagmi publicProvider)
- selfhost CSP: allow the default public RPC hosts

Verified on cg.mogged.eu: onchain runs stably and fetched the premium
token contract data (USDT/DAI/USDC on eth + xdai) via public endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nginx resolved the api/wsapi container IPs once at startup, so every
backend restart (updates, crashes) left it proxying to dead IPs — 502s
and "Disconnected" toasts for everyone until nginx itself restarted.
Use docker's DNS with a short TTL and variable proxy_pass targets (the
set must come before the rewrite ... break, which ends rewrite-phase
processing) so backend containers can restart freely.

Verified: api and websocket routes keep working across an api container
restart with no nginx restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flotob

flotob commented Jul 13, 2026

Copy link
Copy Markdown
Author

Two more commits from continued live testing on cg.mogged.eu:

2e11965 — Blockchain support without paid RPC providers. Testing Spark purchases surfaced that self-hosted instances had all onchain features broken: the frontend's Alchemy key is domain-locked to app.cg (CORS-fails everywhere else, leaving wallet flows stuck), and the backend's placeholder RPC endpoints made the onchain service crash-loop at startup (the premium-token bootstrap in startEventListener runs in an unhandled async IIFE — its NOT_SUPPORTED throw killed the process; that robustness fix matters for app.cg too). Now: the bootstrap degrades instead of crashing, init.sh prefills verified free public RPC endpoints (the QUIKNODE_* vars take any JSON-RPC URL), a single CG_ACTIVE_CHAINS env drives both backend chain workers and every chain picker in the UI, and self-hosted frontends use the chains' default public RPCs instead of the Alchemy key. Verified: premium token contract data (USDT/DAI/USDC) fetched via public endpoints, onchain stable.

2e30f6b — nginx resolves upstreams dynamically. nginx cached backend container IPs at startup, so every backend restart caused 502s/disconnects until nginx itself restarted. Docker-DNS resolver + variable proxy_pass (with set before rewrite ... break, which ends rewrite-phase processing) fixes updates-without-downtime. Verified across a live api container restart.

🤖 Generated with Claude Code

The server now derives capability flags from which secrets it actually
has (features: email/twitterAuth/kyc, plus per-instance giphyApiKey and
walletConnectProjectId) and ships them in the instance config; the UI
hides or honestly labels what the instance can't do. Official instances
are unchanged — every flag defaults to enabled without an instance
config.

Email (SendGrid) unconfigured:
- OTP login option hidden; endpoint returns EMAIL_DISABLED instead of
  pretending a code was sent (password/passkey/wallet login unaffected)
- verification modals never open; email-change skips the "check your
  inbox" promise; event RSVP no longer requires unverifiable email
- newsletter UI (settings toggles, community management) hidden or
  labeled unavailable
- email-notification/newsletter jobs skip cleanly instead of
  process.exit(1)

Robustness fixes that also protect app.cg:
- event RSVP/update/delete endpoints no longer fail after the primary
  operation succeeded just because the notification email failed
- SumSub webhook ack survives email failures
- sendEmail error handler no longer crashes on errors without .response

Twitter/X keys unconfigured: X login/link buttons hidden everywhere
(previously a 500 in a popup). SumSub unconfigured: KYC steps show "not
available on this instance" (previously an infinite spinner). Mailchimp
unconfigured: newsletter preference stored locally instead of throwing
UNKNOWN. Giphy: self-hosted instances no longer piggyback on CG's
shared key — picker hidden unless the operator sets CG_GIPHY_API_KEY.
WalletConnect: project id per-instance (CG's is origin-allowlisted).

Verified on cg.mogged.eu: flags derived and injected correctly, X
buttons gone, zero console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flotob

flotob commented Jul 13, 2026

Copy link
Copy Markdown
Author

f349796 — Graceful degradation for every optional third-party service.

The server now derives capability flags from which secrets it actually has (features: {email, twitterAuth, kyc}, plus per-instance giphyApiKey / walletConnectProjectId) and ships them in the instance config; the UI hides or honestly labels what the instance can't do. Without an injected instance config every flag defaults to enabled, so app.cg/staging behavior is unchanged.

Highlights:

  • No SendGrid: OTP login hidden (endpoint returns EMAIL_DISABLED instead of pretending a code was sent), verification modals never open, newsletter UI shows honest unavailable states, event RSVP no longer demands an unverifiable email, email jobs skip cleanly instead of process.exit(1).
  • Robustness fixes that also protect app.cg: event RSVP/update/delete no longer return errors after the primary operation succeeded just because the notification email failed; the SumSub webhook ack survives email failures; the SendGrid error handler no longer crashes on errors without .response.
  • No Twitter keys: X buttons hidden everywhere (previously a 500 in a popup). No SumSub: KYC steps say "not available on this instance" (previously an infinite spinner). No Mailchimp: newsletter preference stored locally instead of throwing UNKNOWN. Giphy: self-hosted instances no longer piggyback on CG's shared key. WalletConnect: project id per-instance (CG's is origin-allowlisted upstream).

Verified live on cg.mogged.eu: flags derived/injected correctly, X buttons gone from onboarding, zero console errors.

🤖 Generated with Claude Code

All *.publicnode.com endpoints started rejecting unfiltered eth_getLogs
(code -32701 "Please specify an address in your request") in July 2026,
which broke the onchain event listener on every chain that used them —
errors and cancelled tasks in a retry loop, no event processing.

Replace them with endpoints verified against the listener's actual
access pattern (unfiltered getLogs over each chain's BLOCK_BATCHSIZE,
up to 40 blocks on arbitrum/nova):

- eth/matic/avax/optimism/linea/celo -> drpc.org (allows ranges <=10;
  all these chains batch <=8)
- arbitrum/arbitrum_nova -> official arb1/nova.arbitrum.io (40-block
  batches; drpc caps at 10)
- bsc -> bsc.blockrazor.xyz (dataseed limits getLogs)
- xdai/base/fantom/zkevm/scroll/zksync unchanged (still work)

Documented the getLogs requirement in init.sh and SELFHOST.md so
operators test candidate endpoints before swapping them in.

Verified on cg.mogged.eu: all six active chains process events cleanly
(eth handled 7135 watched-contract events on first sync, no errors over
several minutes of runtime).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flotob

flotob commented Jul 14, 2026

Copy link
Copy Markdown
Author

ac02a79 — publicnode restricted eth_getLogs; defaults replaced with verified endpoints.

The full self-hoster journey from SELFHOST.md was re-validated today on a freshly wiped server (same box, rebuilt from bare OS): install Docker → init.shselfhost.sh build (~17 min) → up. Let's Encrypt issuance, instance-config injection, socket.io, signup with the full WebAuthn passkey ceremony (headless CDP virtual authenticator: credentialAddedverified true in ~150 ms) and community creation all work — the latter two also verified manually by a human on the live instance.

One regression surfaced since yesterday's validation, fixed here: every *.publicnode.com endpoint now rejects unfiltered eth_getLogs (-32701 "Please specify an address in your request"), which broke the onchain event listener on eth/matic/arbitrum (retry loops, no event processing). The new defaults were tested against the listener's real access pattern — unfiltered ranged getLogs over each chain's BLOCK_BATCHSIZE (40 blocks on arbitrum/nova, which rules out drpc's 10-block cap there, hence the official Arbitrum RPCs):

chain old (broken) new (verified)
eth ethereum-rpc.publicnode.com eth.drpc.org
matic polygon-bor-rpc.publicnode.com polygon.drpc.org
arbitrum arbitrum-one-rpc.publicnode.com arb1.arbitrum.io/rpc
bsc bsc-rpc.publicnode.com bsc.blockrazor.xyz
avax / optimism / linea / celo *.publicnode.com *.drpc.org
arbitrum nova arbitrum-nova.publicnode.com nova.arbitrum.io/rpc
xdai / base / fantom / zkevm / scroll / zksync unchanged unchanged (still work)

After the swap, all six active chains sync cleanly on cg.mogged.eu (eth processed 7135 watched-contract events on first sync, zero errors since). init.sh and SELFHOST.md now document the unfiltered-getLogs requirement so operators can test candidate endpoints before swapping them in.

Free public endpoints changing their policies under our feet is exactly the operational risk the optional-integrations matrix predicted — worth keeping the "test with an unfiltered eth_getLogs call" note in mind whenever a chain goes quiet.

🤖 Generated with Claude Code

flotob and others added 2 commits July 14, 2026 11:21
The backend image's HEALTHCHECK reads healthcheck.txt, but the jobs
entrypoint was the only backend service that never wrote it — the
job-runner container reported permanently unhealthy in every deployment
(invisible in dev, where nothing gates on container health). Call
fakeHealthcheck() like api/wsapi/onchain/memberlist do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scheduled calls (every event call) are created with callServerId NULL —
a call server is only assigned when the call starts. Updating such a
call with changed slots/stageSlots/highQuality/audioOnly (e.g. editing
an event and switching Broadcast <-> Group call, which changes
stageSlots) made the notify_call_change() trigger build its notify
channel from the NULL callServerId:

    'callservercallupdate_' || NULL  ->  NULL
    pg_notify(NULL, ...)             ->  ERROR: channel name cannot be empty

aborting the whole UPDATE, so saving the event failed. This also
explains why the failure looked intermittent: it only triggers when one
of those four columns actually changes.

The migration adds NEW."callServerId" IS NOT NULL to the condition —
without an assigned call server there is no listener on that channel,
and the server receives the full call state when it is assigned at
call start.

Reproduced and verified on a live instance: the failing UPDATE now
succeeds with callServerId NULL, and the notification still fires with
the correct payload when a call server is assigned.

Fixes #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Kaesual Kaesual closed this Jul 25, 2026
@Kaesual
Kaesual deleted the work/single-server branch July 25, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants