Skip to content

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.

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 + Firestore

Both 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.

  • Development: Node.js 22.12 or later
  • Production: a GCP project with billing, gcloud, and crane
  • 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

Clone the repository and install dependencies:

Terminal window
git clone https://github.com/typeroll/typeroll
cd typeroll
npm ci

The portal includes a fixtures backend — a JSON file store that replaces Firestore for local development. No Firebase configuration needed to run locally:

Terminal window
npm run dev:portal

Open http://localhost:4321. You’ll be logged in as a dev user with access to the sample content in packages/portal/fixtures/.

Clone a tagged Core release and copy the non-secret serverless configuration contract:

Terminal window
git clone --branch core-v<VERSION> --depth 1 https://github.com/typeroll/typeroll
cd typeroll
cp config/self-host-gcp.example.json self-host.gcp.json

Fill 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:

Terminal window
npm ci
npm run self-host:gcp:plan -- --config self-host.gcp.json > self-host.gcp.plan.json

The 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.

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:

Terminal window
# 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-project

Add 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:

Terminal window
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-project

The 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:

Terminal window
npm run self-host:gcp:doctor -- --config self-host.gcp.json
npm run self-host:gcp:doctor -- --config self-host.gcp.json --json

The 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:

Terminal window
curl --fail https://cms.example.com/api/healthz
curl --fail https://cms.example.com/api/readyz
curl --fail https://cms.example.com/api/version
curl --fail https://forms.example.com/api/readyz
curl --fail https://forms.example.com/api/version

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:

Terminal window
cp .env.self-host.example .env
chmod 600 .env

TYPEROLL_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:

Terminal window
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:

Terminal window
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:

Terminal window
npm ci
npm run self-host:check
docker compose config --quiet

All 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:

Terminal window
npm run self-host:bootstrap

Bootstrap 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:

Terminal window
docker compose pull
docker compose up -d
docker compose ps

Caddy requests TLS certificates after both DNS names point at the host and ports 80 and 443 are reachable. Verify every portable role:

Terminal window
curl --fail https://cms.example.com/api/healthz
curl --fail https://cms.example.com/api/readyz
curl --fail https://cms.example.com/api/version
curl --fail https://forms.example.com/api/readyz
docker 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.

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_VCPU and DEPLOY_COST_MEMORY_GIB must 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.

  1. Create a Firebase project at console.firebase.google.com.
  2. Enable Firestore in Native mode and Authentication → Email/Password.
  3. Add a Firebase web app and copy its four public values to the serverless config.
  4. Attach the dedicated runtime service accounts from the generated plan to the Cloud Run services. Core uses FIREBASE_PROJECT_ID and Application Default Credentials; do not create a downloadable key.
  5. 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.
  6. For each user, set an org_id custom 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.

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:

Terminal window
docker compose stop portal forms worker
npm run self-host:backup -- --output /srv/typeroll-backups/2026-09-01
npm run self-host:restore -- --backup /srv/typeroll-backups/2026-09-01
docker compose start portal forms worker

The 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:

Terminal window
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.

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.

  1. Stop portal, Forms, and worker so the backup and migration have no writers.

  2. Create an encrypted backup and verify the copied backup directory.

  3. Check out the new signed/tagged Core release, run npm ci, npm run self-host:check, and docker compose config --quiet.

  4. Inspect the migration without changing data:

    Terminal window
    npm run self-host:migrate
  5. 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
  6. Set TYPEROLL_IMAGE and TYPEROLL_IMAGE_DIGEST to the new published digest, then run docker compose pull, docker compose up -d, and the health, readiness, and version checks above.

  7. 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.

For image development only, the container listens on port 8080:

Terminal window
docker build -t typeroll-core:dev .
docker run --rm -p 8080:8080 --env-file .env typeroll-core:dev

This single-container command does not provide the separate Forms origin or durable jobs and is not the production installation profile.

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_KEY set (see above). Without it the /api/mcp/oauth/token endpoint refuses to issue JWTs.
  • PORTAL_PUBLIC_URL set to the public origin (https://<your-portal-host>). The OAuth aud claim 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.

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.