Every enterprise CRM eventually admits the same truth. The surface a public visitor touches, the surface a tenant operator touches, the surface a platform operator touches, and the surface a partner touches are different products with different threat models, different uptime profiles, and different audit boundaries.

Actionary makes the boundary load-bearing. Four browser origins, four separately-built bundles, four independent cookie scopes, one shared data plane underneath.

Four hosts, one substrate

Each origin is a deliberate isolation boundary. Browser same-origin policy means JavaScript on one origin cannot read another origin’s cookies, storage, or DOM.

  • console.actionarycrm.com — the staff operator console. session_kind = staff_platform. Cookie __Host-acr_staff_session. No tenant binding.
  • app.actionarycrm.com — the tenant workspace. Every tenant shares the origin; the path segment /{slug}/... names the workspace. Cookie __Host-acr_tenant_session.
  • portal.actionarycrm.com — public-facing forms, knowledge base, gated assets, partner sign-in. Cookie __actionary_portal_session.
  • embed.actionarycrm.com — a ~30 KB framework-free widget at /v1/widget.js, rendering into a closed shadow root on the tenant’s own website.

The apex and www. serve the marketing site you are reading from a separate Astro static build on marketing-a / marketing-b nginx containers. Those containers stay outside product data entirely.

Three additional hostname shapes route to the same substrate without per-shape code: tenant subdomain ({slug}.actionarycrm.com), verified vanity domain (crm.acme.com, DNS-TXT-challenged at _actionary-verify.<host>), and dedicated single-tenant deployment. All flow through the same four-layer resolver in get_tenant: bearer JWT claims, URL path slug, host header subdomain, session-bound. The first resolver that returns a tenant wins.

The __Host- prefix as a browser-enforced contract

The prefix is a browser-side security guarantee. A browser refuses to accept a __Host--prefixed cookie unless it is host-only, Path=/, and Secure. A misconfigured server response attempting to issue a __Host- cookie with a Domain attribute is rejected by the browser itself.

Two physically separate identity planes, two non-overlapping cookie scopes, enforced before a single line of application code runs. CSRF protection lives in the SPA’s module-scope memory store and rides every state-changing request as X-CSRF-Token, constant-time compared against auth_sessions.csrf_token. The staff-to-tenant boundary is documented at tenant isolation.

One data plane

Underneath the four hosts sits a small set of backing services. Every one is interchangeable at a config level.

  • PostgreSQL 16 — the single source of truth. Rows, workflow state, audit log, agent reflection memory, and retrieval chunks all live in the same instance. pg_dump produces a portable backup of the entire stack. Continuous PITR at 5-minute granularity on Lightsail Managed Postgres today; the connection string is one env var away from RDS, Cloud SQL, or Supabase. Every multi-tenant table carries tenant_id UUID and a Postgres row-level-security policy keyed on the request-scoped GUC app.current_tenant_id.
  • pgvector — retrieval and agent memory in the same Postgres instance. vector(1024) for reflection embeddings, vector(1536) for content_asset_chunks. HNSW cosine search. The same record_access visibility gate that governs every SPA list endpoint governs the vector reads.
  • S3-compatible object store — uploads and backups through the StorageProvider Protocol. boto3 default credential chain with an endpoint_url override, so Cloudflare R2, MinIO, or Backblaze B2 is one env var away. Browser uploads use presigned PUT URLs; bytes never traverse the application server.
  • Redis — cache, rate limits, workflow-step claims via FOR UPDATE SKIP LOCKED, MFA pending-secret cache, and the upload-indexer queue drained by a single-consumer BLPOP worker.
  • OpenTelemetry backbone — vendor-neutral OTLP spans and metrics from every service. The exporter (Tempo, Honeycomb, Datadog, Grafana Cloud, AWS X-Ray) is a configuration choice.

Every load-bearing layer at the boundary is a public specification with more than one compatible implementation. See open standards for the full stance.

Isolated services with clean seams

Three stateless application services, each independently horizontally scalable.

  • core-api — FastAPI over psycopg. The authoritative surface. Serves the JSON API at /api/v1/*, hosts the embedded LLM agent loop, and drives every write to Postgres and S3. Reads shape metadata from entity_config and entity_fields on every request.
  • mcp-server — FastMCP with OAuth 2.1 authorization-code + PKCE + audience-bound tokens (RFC 8707). The Model Context Protocol surface for external AI clients. Zero authentication tools by design — credentials never travel as tool arguments.
  • SPA bundles — three separately built Vite artifacts (apps/web, apps/portal, apps/widget) served by minimal nginx containers behind Caddy.

Every service is stateless. Every service scales by adding replicas. Configuration flows via environment variables only, per Wiggins’s Twelve-Factor App from 2011. The mcp-server is a thin pass-through to the API service layer — fixes belong in core-api, and MCP inherits them. Every capability lands in exactly one place.

Rolling deploys, paired replicas, health-gated

Every application service ships as an -a / -b container pair — spa-a / spa-b, portal-a / portal-b, core-api-a / core-api-b, mcp-server-a / mcp-server-b. Caddy round-robins across each pair with active health probes and passive failover.

scripts/deploycrm drives a seven-phase pipeline: build the SHA-tagged artifact, write a deploy_history row, run the one-shot migrate container with the expand/contract phase runner, roll each replica sequentially with docker compose up --wait healthcheck gating, run the smoke canary against GET /api/v1/health/deploy-smoke with an HMAC bearer derived from PLATFORM_MASTER_KEY, and record the terminal outcome regardless of which phase failed.

Replica -a rolls first. Caddy removes it from the round-robin pool the moment its health probe fails; -b continues serving live traffic. When -a reports healthy again, -b rolls. The pool always retains a sibling. Zero-downtime for tenants. Rollback via scripts/rollbackcrm reads the most recent outcome=success SHA from deploy_history and skips the migrate phase — the expand/contract policy holds the schema forward-compatible with the prior code SHA. Full runbook at platform operations.

The takeaway

Four hosts. One data plane. Two services with one contract. Paired replicas rolling behind an active-health-probed round-robin. Every boundary that matters is a physical boundary; every backing service is portable at a config level. The code is the same across shared-tenancy, vanity-domain, and dedicated deployments — the deployment differs, the connection string differs, the application stays put.

This is the substrate technical buyers audit. Read for CTOs for the buyer-side view.