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_dumpproduces 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 carriestenant_id UUIDand a Postgres row-level-security policy keyed on the request-scoped GUCapp.current_tenant_id. - pgvector — retrieval and agent memory in the same Postgres instance.
vector(1024)for reflection embeddings,vector(1536)forcontent_asset_chunks. HNSW cosine search. The samerecord_accessvisibility gate that governs every SPA list endpoint governs the vector reads. - S3-compatible object store — uploads and backups through the
StorageProviderProtocol.boto3default credential chain with anendpoint_urloverride, 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-consumerBLPOPworker. - 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 fromentity_configandentity_fieldson 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.