2026-09-23·by Sijie Wang#standmeet#architecture#deployment

deployment

Deployment — self-hosted, single-tenant

Parent: architecture

StandMeet deploys as a self-hosted, single-tenant, single-box app. This is the shape the product demands, not a limitation to fix later — and the code's lack of horizontal scaling is evidence of the right architecture, not debt.

The stance

  • One instance per person. StandMeet is your truth layer / your stand-in (introduction); the natural unit is "you, running your own," the same shape as a personal website. There is no multi-tenant SaaS to scale.
  • Self-hosting completes the sovereignty already in the design. The corpus is yours, MCP-push is the trust boundary, nothing leaks you didn't put in — self-host puts your identity and voice on your infra, not a vendor's. It matches the "individual + AI subagent as the unit of ownership" bet (design-principles) and the "it's a foundation, not a SaaS" framing (boundaries).
  • Read-heavy, write-light, single-writer. Writes are rare — only the owner curates / MCP-pushes. Reads (visitor queries) may be higher but are bounded by one person's audience and are cacheable; the expensive thing is LLM inference, not the app tier. → a single Postgres primary + Redis cache on one box is trivially enough — no write-scaling, no sharding, no read-replicas.
  • The real invariant isn't "scalable" — it's "stays trivially self-hostable." Keep it docker compose up on one machine; never add a dependency that assumes a cluster.

The box (prod compose topology)

Note the compose has no caddy/proxy service — consistent with the one-command-deploy cut: TLS/domain is the owner's problem, outside the box (infra/deploy/docker-compose.yml:11).

Two compose files, and the one that actually runs

docker-compose.prod.yml is the source-build template (git checkout, make builds the images). The live instance runs infra/deploy/docker-compose.yml — the image-based stack (ghcr standmeet-{app,backend,builder,im-bridge,updater,db} images, STANDMEET_IMAGE_TAG channel tag), pasted into Coolify, whose SERVICE_* magic variables supply domains and passwords and whose Traefik terminates TLS. It replaced infra/coolify/docker-compose.coolify.yml on 2026-09-04 (a5e1cada9). They must be kept in step by hand, and drift between them is not theoretical: a networks.default.aliases block on app, present in one and not the other, left the app on two networks with no traefik.docker.network label — Traefik picked a network it was not on, the SYN went nowhere, and the site served 504 (a refused connection would have been an immediate 502; the timeout is the tell).

The same class of drift produced the search hole: dev shipped a service prod did not (corpus-retrieval). check-search-index-shipped and check-knobs-reachable (infra/scripts/) now gate the two deployment files together.

The instance has no control over its host — and says so

In the image-based stack the backend deliberately gets no docker.sock (infra/deploy/docker-compose.yml:197, :244; the source-build docker-compose.prod.yml:208 still mounts it on the backend, for the optional docker-driver managed plugins). The MCP capability sandbox runs on bubblewrap instead, which needs cap_add: [SYS_ADMIN, NET_ADMIN] + security_opt: [seccomp:unconfined, apparmor:unconfined] to build user/mount namespaces inside a container — without those every sandboxed plugin fails to spawn and a visitor turn binds zero tools.

The consequence for upgrades: the instance cannot pull images or recreate its own containers. Whatever orchestrates it can. Until 2026-09-04 the upgrade button POSTed one opaque URL the owner supplied (STANDMEET_REDEPLOY_HOOK). That path is gone (3c9fb6113, 3d1ce1aaf, 7a3a749e7): the button now writes a timestamp to a signal file (STANDMEET_UPGRADE_SIGNAL, backend/cmd/server/port/upgrade_signal.go:40-69) on a volume shared with an updater sidecar (infra/updater/main.go) — the one container that holds docker.sock. The sidecar pulls the channel tag and recreates every sibling container in place from its own inspected config (infra/updater/docker.go:91-95, the Watchtower model — not compose up over a fetched compose). The backend never learns how it is deployed; a Coolify-shaped substrate would run a different sidecar reading the same file. No sidecar (the source-build stack) → Configured() is false and the panel says plainly that the upgrade happens outside the instance and what to run. Offering an action that cannot happen is worse than offering none. The whole mechanism — signal, sidecar, boot-time migrations, release gates, client skew advisory — is product-owned-upgrade.

The receipt is measured, not assumed: the process answering the call is the one being replaced, so it cannot report the outcome. The browser polls /api/v1/instance afterwards (app/src/lib/admin/use-upgrade.ts:85-106) and reports what actually happened — including "the redeploy ran and the version did not change", which is what a compose with pinned tags produces.

The version an instance reports is the build it is running

port.appVersion is a var so -ldflags -X can stamp it at release (backend/cmd/server/port/sysinfo.go:49, backend/Dockerfile:73). For a long time nothing did, and the source literal was what every instance reported: prod ran the v0.1.3 images and answered 0.1.0. A version number exists to answer which build is this when something breaks; a number unrelated to the build cancels that use entirely and is worse than none, because it looks like knowing. The release build now stamps it, the default is dev so an unstamped binary says so, standmeet --version answers without a database, and a gate asks the image itself between build and push (release-assert-version, Makefile:1369).

Release and schema mechanics (shipped 2026-08-31 → 2026-09-07)

  • The backend applies its own migrations at bootpgstore.Migrate in backend/cmd/server/main.go:91 (bd45353f4, 2026-08-31); backend/db/migrations/*.sql is the upgrade path, schema.sql the fresh-install one. The schema is baked into the standmeet-db image (infra/db/Dockerfile) because a pasted compose has no repository to bind-mount from.
  • ghcr images carry the OCI source label (org.opencontainers.image.source, backend/Dockerfile:96, app/Dockerfile:14, infra/db/Dockerfile:30; ba219c391, 2026-09-01) so the packages link to the repo.
  • Release gates (Makefile): release-push depends on secrets + secrets-image (the image is scanned for secrets, with Next.js framework build keys allowlisted — d846337bc, 2026-09-04); release-assert-stripped proves no data-testid survives in the SDK widgets of the release build (bec5394fb, 2026-09-07; Puck library chunks exempt, 92be8bed5); release-assert-version; release-assert-multiarch.
  • Eight UI locales ship in the app image (app/src/i18n/messages/{de,en,es,fr,hi,ja,ko,zh}; a5e1cada9, 2026-09-04) — locale rides the URL prefix (e2e/test/ui-locale-in-url.spec.ts), with a key-parity guard (infra/scripts/check-i18n-keys).

Why "can't scale horizontally" is correct here

A single person's StandMeet serves their own visitors — tens to hundreds of concurrent at most, not web-scale. Vertical scale (one bigger box) covers it; horizontal scaling would buy nothing and would actively hurt self-hostability. So the single-host couplings below are simplifications that make self-hosting easy, not debts:

Single-host coupling (verified in code)Where
Local unix sockets /run/standmeet/*.sock to the sandboxed built-in MCP plugins (retrieval / booker / summarize / ask-visitor / mail-sender)cmd/server/axiscap/register.go, internal/infra/hostop/op.go, corpus/usecase/corpus_index_socket.go, routes/connector/invoke_socket.go
In-process built-in MCP capabilities (in-memory transport, no network)internal/capabilities/mcpclient/inprocess.go
Builder writes a shared local volume /srv/microsites (MICROSITES_ROOT)builder/runner.mjs
Upgrade signal is a file on a volume shared with the updater sidecarcmd/server/port/upgrade_signal.go, infra/updater/
In-process caches (sync.Once tool caches; marketplace dir cache)routes/capload/capreg_mcp_app.go, marketplace/usecase/github.go
Single Postgres / MinIOcompose

All fine under single-tenant — sessions are Redis-backed and every table is owner_id-scoped, so this is right-sized, not sloppy.

If it ever went SaaS (brief, on purpose)

Two separate axes, neither large:

  • Horizontal scaling (N replicas of one instance): the only real refactor is the builder (single-backend long-poll + shared local volume → a job queue + object storage); in-process caches would drift (move to Redis or tolerate); the sandbox/sockets just constrain replicas to privileged, self-contained nodes.
  • Multi-tenancy (many owners on one deployment): a product change, not a replica-count one — the instance_settings singleton + one-time claim become per-tenant signup + auth. The data layer is already owner_id-scoped and there's a multi_tenant flag, so it's closer than it looks.

Note: the detached agent-loop / persist-at-completion design (a turn survives client disconnect and lands in the DB) is intentional UX consistency, orthogonal to scaling — it already works across replicas (history is in the shared DB; a reload recovers it). Not a scaling task.

One-command deploy + auto-Let's-Encrypt (cut July 2026)

The "one-command deploy" vision with automatic Let's Encrypt provisioning was cut in July 2026. Owner now binds their domain and certificate at their own provider. The surviving mechanism (owner profile public_url + allowed_domains) already exists to pin the public face.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

deployment