Self-Hosting
Typeroll is open-source (MIT) and can be self-hosted. The supported reference profile runs entirely in a GCP/Firebase project you own. Cloud Run hosts the portal and Forms from the same immutable Core image used by Typeroll Cloud; Cloud Tasks and Cloud Scheduler invoke authenticated internal routes when work is ready. There is no VM or always-running worker.
Architecture
Section titled “Architecture”Internet ── Cloud Run portal ───── Firebase Auth + Firestore │ │ ├── Cloud Tasks ──────────┤ deploy-worker route ├── Cloud Scheduler ──────┤ publish-sweep route ├── R2 media └── Cloudflare Pages static sites
Internet ── Cloud Run Forms ────── Firebase Auth + FirestoreBoth Cloud Run services use the same verified Core release. The public image is
copied by digest into the customer’s Artifact Registry without rebuilding it.
The portal enqueues durable Cloud Tasks; each task invokes
/api/internal/deploy-worker with an OIDC token. Cloud Scheduler invokes
/api/internal/publish-sweep with the same identity contract.
The runtime uses Application Default Credentials from dedicated Cloud Run service accounts. Do not create or mount a Firebase service-account key for the reference profile.
Prerequisites
Section titled “Prerequisites”- Development: Node.js 22.12 or later
- Production: a GCP project with billing,
gcloud, andcrane - Firebase: Firestore Native mode, Authentication, and a Firebase Web App
- Cloudflare account with R2 for media and Pages for static site output
- Two public DNS names for the portal and Forms origins
- Optional: Anthropic API key for AI chat
Quick start (local dev)
Section titled “Quick start (local dev)”Clone the repository and install dependencies:
git clone https://github.com/typeroll/typerollcd typerollnpm ciThe portal includes a fixtures backend — a JSON file store that replaces Firestore for local development. No Firebase configuration needed to run locally:
npm run dev:portalOpen http://localhost:4321. You’ll be logged in as a dev user with access to the sample content in packages/portal/fixtures/.
Plan the reference profile
Section titled “Plan the reference profile”Clone a tagged Core release and copy the non-secret serverless configuration contract:
git clone --branch core-v<VERSION> --depth 1 https://github.com/typeroll/typerollcd typerollcp config/self-host-gcp.example.json self-host.gcp.jsonFill in the customer project, region, Firebase Web App, public origins,
resource names, Secret Manager resource names, and the release’s published
@sha256: image. The JSON contains references to secrets, never secret values.
Generate an inspectable, non-mutating plan:
npm cinpm run self-host:gcp:plan -- --config self-host.gcp.json > self-host.gcp.plan.jsonThe plan fixes the supported topology before any remote mutation: required APIs, three least-privilege identities, Artifact Registry mirror, Cloud Run portal and Forms services, Cloud Tasks queue, Cloud Scheduler job, runtime configuration, and Secret Manager bindings. It also records that the artifact is copied by digest without a rebuild and explicitly forbids VM, managed instance group, reverse proxy, and long-running-worker resources.
Every required Secret Manager resource must have an enabled latest version
before deployment. Keep the actual secret values in your secret-management
workflow; do not add them to self-host.gcp.json or Git.
Apply and verify the reference profile
Section titled “Apply and verify the reference profile”The apply command is a dry run unless --apply and an exact project
confirmation are both present. Its two rerunnable phases separate resources
that contain no secret values from the runtime deployment:
# Local preview only; makes no remote calls.npm run self-host:gcp:apply -- --config self-host.gcp.json
# APIs, service accounts, IAM, Artifact Registry, Cloud Tasks, and empty# Secret Manager containers. This changes the named GCP project.npm run self-host:gcp:apply -- \ --config self-host.gcp.json \ --phase foundation \ --apply \ --confirm-project your-gcp-projectAdd one enabled version to every Secret Manager resource using your normal secret-management workflow. The apply command creates secret containers and IAM bindings, but it never accepts, reads, prints, or copies secret values. Then preview and apply the runtime phase:
npm run self-host:gcp:apply -- \ --config self-host.gcp.json \ --phase runtime
npm run self-host:gcp:apply -- \ --config self-host.gcp.json \ --phase runtime \ --apply \ --confirm-project your-gcp-projectThe runtime phase fails before copying an image or deploying Cloud Run if any
secret lacks an enabled version. It verifies the source digest, copies the
release to Artifact Registry under a deterministic digest tag without a
rebuild, deploys the portal and Forms roles, and converges the publish-sweep
Scheduler job. A new portal receives one bootstrap revision so its stable
run.app URL can be discovered; the command immediately converges the active
revision and both OIDC callers on that URL.
Run the read-only control-plane doctor at any time. It reads resource and secret-version metadata, never secret contents:
npm run self-host:gcp:doctor -- --config self-host.gcp.jsonnpm run self-host:gcp:doctor -- --config self-host.gcp.json --jsonThe operator running apply must already be allowed to enable services, manage the listed project resources and IAM bindings, and act as the three dedicated runtime service accounts. Core does not grant roles to the human or automation identity running the installer.
After deployment, verify both roles and the immutable release identity:
curl --fail https://cms.example.com/api/healthzcurl --fail https://cms.example.com/api/readyzcurl --fail https://cms.example.com/api/versioncurl --fail https://forms.example.com/api/readyzcurl --fail https://forms.example.com/api/versionPortable Compose fallback
Section titled “Portable Compose fallback”compose.yaml is retained for local evaluation and deployments outside GCP.
It is not the production-supported profile and is not the permanent self-host
E2E target. For this fallback only, copy the environment contract and keep it
readable only by the operator:
cp .env.self-host.example .envchmod 600 .envTYPEROLL_IMAGE must use the release’s published @sha256: digest, not a
mutable tag. Set TYPEROLL_IMAGE_DIGEST to the same digest.
Generate each HMAC/encryption value independently:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"Generate the Extension issuer key once and store the resulting private JWK as
single-line JSON in EXTENSION_SIGNING_PRIVATE_JWK:
node --input-type=module -e "import { generateKeyPairSync } from 'node:crypto'; const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); console.log(JSON.stringify(privateKey.export({ format: 'jwk' })))"Validate the complete portable contract before Docker receives credentials:
npm cinpm run self-host:checkdocker compose config --quietAll self-host operation commands also accept the complete contract through
their process environment. Process-injected values override an --env-file,
and a file is optional when the injected environment is complete. Prefer that
mode with a secret-manager runner so JSON keys and recovery credentials do not
need to be materialized on disk. The 0600 check still applies whenever an
environment file is supplied.
The check prints field names and validation failures only. It never prints
secret values. Keep TYPEROLL_BACKUP_KEY in a second, off-host secret store;
losing both the host and this key makes encrypted backups unrecoverable. The
Compose profile explicitly removes this key from every Core container.
Bootstrap the portable data contract once before starting Core:
npm run self-host:bootstrapBootstrap is idempotent. It refuses a project or bucket containing data unless
you pass --adopt; use that flag only after verifying all existing resources
belong to this Typeroll installation. Start the installation when the checks
and bootstrap pass:
docker compose pulldocker compose up -ddocker compose psCaddy requests TLS certificates after both DNS names point at the host and ports 80 and 443 are reachable. Verify every portable role:
curl --fail https://cms.example.com/api/healthzcurl --fail https://cms.example.com/api/readyzcurl --fail https://cms.example.com/api/versioncurl --fail https://forms.example.com/api/readyzdocker compose exec worker node -e "fetch('http://127.0.0.1:8080/api/readyz').then(async r => { console.log(await r.text()); process.exit(r.ok ? 0 : 1) })"The Forms hostname exposes only Forms, Analytics, and diagnostic routes. The
portable worker is not published through Caddy. Its Firestore queue is durable;
do not replace DEPLOY_QUEUE=firestore with in_process in an internet-facing
portable install.
Build-cost accounting
Section titled “Build-cost accounting”Every deploy records what the build cost in server time, on the deploy job
itself. You’ll see it in get_deploy_status responses — total, a
CPU/memory/request split, wall-clock duration, per-phase timings, and the size
of the generated site.
Nothing needs configuring to get this. The defaults above are the published rates for a request-billed container at 1 vCPU / 1 GiB, which is how the reference deployment runs.
Adjust the rates when your hosting differs:
- Different provider or region. Substitute your own per-second rates.
- Committed-use or reserved pricing. Use your effective discounted rates.
- Your own hardware. Set the rates to
0— every build then costs nothing, which is accurate: you’ve already paid for the machine.
Two things to keep in mind:
DEPLOY_COST_VCPUandDEPLOY_COST_MEMORY_GIBmust match what you actually allocate to the worker container. Change your container’s CPU or memory without changing these and every subsequent build is mispriced.- The rate card is snapshotted onto each deploy job when it runs. Changing a rate affects future builds only; history keeps the numbers it was costed with, so a rate change never silently rewrites past figures.
The numbers are estimates from that rate card, not billing records, and they’re gross — free-tier allowances and discounts aren’t deducted, so your real invoice will be lower.
Firebase setup
Section titled “Firebase setup”- Create a Firebase project at console.firebase.google.com.
- Enable Firestore in Native mode and Authentication → Email/Password.
- Add a Firebase web app and copy its four public values to the serverless config.
- Attach the dedicated runtime service accounts from the generated plan to
the Cloud Run services. Core uses
FIREBASE_PROJECT_IDand Application Default Credentials; do not create a downloadable key. - Ensure the backup identity can read the Authentication password hash
configuration (
firebaseauth.configs.getHashConfig) in addition to managing Auth users and Typeroll’s Firestore data. Backups fail closed if password hashes or their hash configuration are unavailable. - For each user, set an
org_idcustom claim with the modular Admin API:
import { applicationDefault, initializeApp } from "firebase-admin/app";import { getAuth } from "firebase-admin/auth";
initializeApp({ credential: applicationDefault(), projectId: process.env.FIREBASE_PROJECT_ID,});await getAuth().setCustomUserClaims(uid, { org_id: "your-org-id" });All content is scoped under organizations/{org_id}/ in Firestore.
The portable Compose fallback instead accepts FIREBASE_SERVICE_ACCOUNT JSON
because it does not have a GCP workload identity.
Backups and disaster recovery
Section titled “Backups and disaster recovery”Typeroll’s backup is one encrypted, authenticated snapshot of all three state planes:
- Firestore documents, subcollections, native value types, and installation schema metadata
- Firebase Authentication users, password hashes, password hash configuration, provider identities, custom claims, and MFA metadata
- R2 objects and their relevant HTTP metadata
The manifest exposes only source identifiers, schema version, timestamps, and counts. Data, user records, object keys, and object bodies are encrypted with AES-256-GCM. Every file and the manifest are authenticated before restore.
For a consistent manual backup of the portable Compose fallback, stop writers but leave its managed dependencies online:
docker compose stop portal forms workernpm run self-host:backup -- --output /srv/typeroll-backups/2026-09-01npm run self-host:restore -- --backup /srv/typeroll-backups/2026-09-01docker compose start portal forms workerThe restore command is verify-only unless --apply is present. Copy the
completed backup directory off-host and test that copy with the verify-only
command. Never copy a directory containing INCOMPLETE.
Restore to a completely empty Firebase project and R2 bucket with exact target and backup confirmations:
npm run self-host:restore -- \ --backup /srv/typeroll-backups/2026-09-01 \ --apply --empty-target \ --confirm-project my-disaster-recovery-project \ --confirm-bucket my-disaster-recovery-bucket \ --confirm-backup <backup-id> \ --allow-target-mismatch--allow-target-mismatch is required only when the backup source identifiers
differ from an intentional disaster-recovery target. The restore writes the
target identifiers into the recovered installation metadata.
To replace an existing installation, stop every Core role and substitute
--replace for --empty-target. Replace mode deletes Firestore documents,
Auth users, and R2 objects that are absent from the backup. It verifies the
entire backup first, requires all exact confirmations, and verifies the final
record sets, but it cannot make three external services transactional. Keep the
roles stopped until the command succeeds; if it fails, correct the cause and
rerun the same idempotent restore.
At least quarterly, restore the latest backup into isolated Firebase and R2 test resources, start the pinned Core image against them, and run the browser and agent acceptance suite. A backup that has only been created is not yet a proven recovery path.
Portable upgrade, migration, and rollback
Section titled “Portable upgrade, migration, and rollback”Core reports both its release and readable data-schema range at /api/version.
The command-level sequence below applies to the portable Compose fallback. The
serverless profile instead promotes a new digest through the generated GCP plan
and keeps the previous Cloud Run revision available for traffic rollback.
-
Stop portal, Forms, and worker so the backup and migration have no writers.
-
Create an encrypted backup and verify the copied backup directory.
-
Check out the new signed/tagged Core release, run
npm ci,npm run self-host:check, anddocker compose config --quiet. -
Inspect the migration without changing data:
Terminal window npm run self-host:migrate -
If migrations are pending, apply them only with the verified pre-migration backup and exact project confirmation:
Terminal window npm run self-host:migrate -- \--apply \--backup /srv/typeroll-backups/2026-09-01 \--confirm-project my-firebase-project -
Set
TYPEROLL_IMAGEandTYPEROLL_IMAGE_DIGESTto the new published digest, then rundocker compose pull,docker compose up -d, and the health, readiness, and version checks above. -
Run a site edit/deploy smoke test, one Forms submission, the browser visual suite, and the agent suite before ending the maintenance window.
Migration steps are ordered, idempotent, protected by a renewable installation lock, and update schema metadata after each successful step. They will not run without a matching verified backup.
If the new image fails while the previous image can read the resulting schema,
pin the previous digest again and restart Compose. If the previous image cannot
read the new schema, leave all roles stopped and restore the pre-migration
backup with --replace, then start the previous digest. Never run an image
against a schema outside the readable range reported by that image.
Direct Docker smoke test
Section titled “Direct Docker smoke test”For image development only, the container listens on port 8080:
docker build -t typeroll-core:dev .docker run --rm -p 8080:8080 --env-file .env typeroll-core:devThis single-container command does not provide the separate Forms origin or durable jobs and is not the production installation profile.
Hosted MCP connector
Section titled “Hosted MCP connector”A self-hosted portal serves the MCP Streamable HTTP transport at /api/mcp automatically — no extra deployment step. Your users add https://<your-portal-host>/api/mcp as a remote server in their MCP-compatible client, follow the consent screen, and enter a Typeroll API key (org-scoped from /app/settings/api-keys is the right default).
Requirements specific to this endpoint:
MCP_OAUTH_SIGNING_KEYset (see above). Without it the/api/mcp/oauth/tokenendpoint refuses to issue JWTs.PORTAL_PUBLIC_URLset to the public origin (https://<your-portal-host>). The OAuthaudclaim and the well-known metadata endpoints use this to bind issued tokens to your domain per RFC 8707.- TLS for remotely hosted MCP clients.
Agent clients with local process support can keep using the stdio install (npx -y @typeroll/mcp-server with TYPEROLL_API_URL pointed at your portal). Same tool surface, same API keys.
Extensions
Section titled “Extensions”A self-hosted portal can register and install private or unlisted Extensions
without contacting Typeroll’s hosted catalog. Set
EXTENSION_SIGNING_PRIVATE_JWK to a P-256 private JWK and keep it outside
source control.
Native Extension form bindings also require FORMS_HMAC_SECRET. Set
FORMS_PUBLIC_URL when form submissions are served from a dedicated service;
otherwise components call the self-hosted PORTAL_PUBLIC_URL endpoint
directly. Customer site deployment remains static and contains no generated
Functions.
The portal publishes issuer discovery and JWKS from its own
PORTAL_PUBLIC_URL. Extension providers must pair and validate that issuer
instead of assuming the hosted Typeroll domain. During key rotation, set
EXTENSION_SIGNING_PREVIOUS_PUBLIC_JWKS to the old public JWKS for an overlap
window, issue all new tokens with the new private key, then remove the old keys
after the longest token and pairing lifetime has passed.
See Extensions for the manifest, runtime, provider, and recipient-link contracts.
The Extension protocol does not bundle premium Typeroll Apps. Those apps are sold and operated separately in Typeroll-controlled accounts even when the CMS is self-hosted. Third-party and bespoke backends likewise remain in their developer’s accounts. In both cases the browser calls the provider directly; the self-hosted portal issues identity tokens but never proxies provider API traffic.
Other platforms can run the same three image roles, but the Compose profile is the compatibility baseline. Preserve the separate origins, immutable digest, and durable queue contract when translating it to another orchestrator.