Deployment Guide

This guide deploys Web and MCP together on Cloudflare for multiple users, using the configuration under deploy/web/cloudflare/ and deploy/mcp/cloudflare/. Both Workers use the Node project and lockfile in deploy/; Docker builds use the repository root as context. See Authentication Design for the OAuth flow and credential boundaries.

For local use, start with the Quick Start or the User Guide. The repository layout identifies the application code, launchers, and provider configuration files.

Prerequisites the account owner supplies

Use this existing project directly. The dashboard's npm create cloudflare@latest -- --template=cloudflare/templates/containers-template command creates a separate starter project and is not needed here. The first wrangler deploy builds/uploads the image and creates the Container application; an empty Containers dashboard before that deployment is expected.

  1. Enable Workers Paid on the Cloudflare account.
  2. Choose the account's workers.dev subdomain, or configure custom domains.
  3. Install Node.js 22+ and start Docker with Linux container support. Images must target linux/amd64. A Linux CI runner can build/deploy instead.
  4. Register two GitHub OAuth Apps at https://github.com/settings/developers:
AppHomepageCallbackRequired settings
Webhttps://pkgfactory-web.ohnolab.workers.devSame URLEnable Device Flow; copy Client ID
MCPhttps://pkgfactory-mcp.ohnolab.workers.devhttps://pkgfactory-mcp.ohnolab.workers.dev/callbackCopy Client ID and Client Secret

Use your final hostnames in these settings.

Configure and deploy

Run these commands from deploy:

npm ci
npx wrangler login
npx wrangler whoami
# Only for a different account without the configured OAuth namespace:
npx wrangler kv namespace create OAUTH_KV --config mcp/cloudflare/wrangler.jsonc

Update the committed Wrangler files with non-secret values:

  • Both MCP_ORIGIN values: the final MCP HTTPS origin, without a trailing slash.
  • Web PUBLIC_ORIGIN: the final Web HTTPS origin, without a trailing slash.
  • MCP WEB_ORIGIN: that same Web origin, used to verify maintenance for recovery.
  • MCP MCP_ALLOWED_ORIGINS: an array of exact browser client origins allowed to call /mcp (for example ["http://localhost:6274"] for a local Inspector). The default empty array permits only the MCP site's own origin; clients without an Origin header remain supported. Do not use wildcards.
  • Each GITHUB_OAUTH_CLIENT_ID: its respective OAuth App Client ID.
  • MCP kv_namespaces[0].id: the namespace ID returned above.

The configured hostnames use the existing ohnolab.workers.dev subdomain. The OAuth KV namespace and both GitHub OAuth App Client IDs are configured for that account. To deploy to another account, create its own KV namespace and OAuth Apps, then update the namespace ID, both origins and both Client IDs. For custom domains, add a routes entry with custom_domain: true to each configuration, update these origins and GitHub callback settings, and disable workers_dev. Preview URLs are disabled so alternate origins cannot bypass the configured OAuth audience and browser origin.

Generate five distinct random secrets, each at least 32 bytes (for example 64 hexadecimal characters using a password manager). Register them as follows; only WEB_STATE_SECRET has the same value in both Workers:

npx wrangler secret put MCP_TICKET_SECRET --config mcp/cloudflare/wrangler.jsonc
npx wrangler secret put MCP_STATE_SECRET --config mcp/cloudflare/wrangler.jsonc
npx wrangler secret put WEB_STATE_SECRET --config mcp/cloudflare/wrangler.jsonc
npx wrangler secret put WEB_STATE_SECRET --config web/cloudflare/wrangler.jsonc
npx wrangler secret put WEB_PROXY_SECRET --config web/cloudflare/wrangler.jsonc
npx wrangler secret put STATE_RECOVERY_SECRET --config mcp/cloudflare/wrangler.jsonc
npx wrangler secret put GITHUB_OAUTH_CLIENT_SECRET --config mcp/cloudflare/wrangler.jsonc

Wrangler prompts for the value; do not put secrets in command arguments, git, GitHub issue text, or chat. The keys grant separate permissions:

SecretPurposePassed to container
MCP_TICKET_SECRETSign/verify short-lived MCP ticketsMCP only
MCP_STATE_SECRETMCP plan storage and MCP repository locksMCP only
WEB_STATE_SECRETAcquire/release Web repository locks onlyWeb only
WEB_PROXY_SECRETAuthenticate Web gateway forwardingWeb only
STATE_RECOVERY_SECRETOperator recovery onlyNeither

Web cannot claim MCP plans, release MCP-owned locks, or run recovery with either of its keys. Keep the recovery key in an operator password manager. Rotating a container key requires redeploying its application so the container receives the new value; rotating WEB_STATE_SECRET requires updating both Workers and the Web container.

When upgrading from INTERNAL_SECRET, put both apps into maintenance first, let active operations finish, and stop both containers. Register the new keys, deploy both applications, verify their health, then leave maintenance mode. There is no fallback to the old shared key; remove it from both Workers after migration. Existing plans and locks stay in the same Durable Object. Recover any old stuck locks before admitting new operations.

npm run deploy:mcp
npm run deploy:web

Run the deployment tests before publishing. deploy:* builds the images and publishes the Worker/container. The MCP deployment must be ready before Web creation can use its shared operation store. All deployments use the same checkout, including uncommitted changes if run locally; use a reviewed commit.

The Cloudflare GitHub Actions workflow tests Workers and builds both Linux images on relevant changes. It does not publish them. For unattended deployment, use Cloudflare account-scoped CI credentials instead of wrangler login and follow Cloudflare's Containers deployment guide.

Web request controls

The Web Worker overwrites X-Real-IP with Cloudflare's client address and authenticates forwarding with WEB_PROXY_SECRET. The Julia container rejects requests without that gateway credential. Browser Origin headers must match PUBLIC_ORIGIN; requests without Origin still require the caller's GitHub authorization for repository operations.

The Web application accepts JSON objects for POST endpoints, limits request bodies to 64 KiB, active connections to 128, and concurrent repository operations to eight. Its fixed-window limits apply per 60 seconds:

ScopeLimit
API requests per client IP (excluding health checks)120
OAuth starts per client IP6
OAuth polls per client IP60
Creation attempts per token fingerprint5

Rate-limit responses use HTTP 429. These in-memory counters reset on container restart; edge limits are configured separately in the Wrangler files. They bound request volume, not monthly cost.

Acceptance checks

OAuth discovery, dynamic registration, and token endpoints accept browser CORS requests through the OAuth provider. /mcp, including preflights, validates MCP_ALLOWED_ORIGINS; consent and callback routes keep the same-origin policy and browser-bound CSRF checks. Bearer tokens are still required for MCP calls.

Before admitting users, exercise both HTTPS interfaces with authorized test accounts:

  1. Complete the hosted MCP login and the Web login. Check MCP discovery with the client's registration method; the service supports dynamic client registration and Client ID Metadata Documents.
  2. List tools and preview a minimum package. Wait more than five idle minutes, then execute the saved plan before it expires. The exact files and UUID must survive a container restart.
  3. Use two GitHub accounts to verify isolation. An account must not execute another account's plan_id. Repeat a completed plan to verify the plan lifecycle.
  4. Test concurrent users, revoked credentials, GitHub throttling, lost responses, and a restart during creation. Inspect the created repositories and their workflows, and exercise the recovery procedure.

Measure cold starts, request latency, and peak memory under expected load before choosing resource limits. The local tests use simulated GitHub responses; they cannot establish that deployed credentials, domains, or upstream permissions are configured correctly.

Streamable HTTP uses stateless POST requests and JSON responses. The optional GET/SSE channel returns 405. No MCP session ID needs to survive a container restart.

/health on MCP and /api/health on Web check the gateway, without waking Julia. Exercise tool discovery/Web configuration to check container readiness. The initial request can take longer while Julia loads; container readiness waits up to 120 seconds. The images use --compile=min --heap-size-hint=600M to reduce Julia's startup compilation and leave memory for native code and subprocesses. Package caches are built with JULIA_CPU_TARGET=generic, so a different CPU on Cloudflare can reuse them without recompiling at startup. The container profile first loads the application with --cpu-target=generic --compiled-modules=strict to verify that the shipped caches work without generating replacements.

Persistence, failures, and recovery

Each service handles multiple users in one named container, with max_instances: 1 and a five-minute idle timeout. No always-on keepalive is configured. Static Web requests and MCP OAuth discovery do not start Julia. Sleep and deployments clear container memory/disk, but not Durable Object storage.

The MCP plan lifetime also applies here. The durable store allows at most 16 retained plans per authenticated principal and 256 across all users, with a 100 KB snapshot limit. Successful results remain available for a fresh plan lifetime measured from completion. Expired inactive plans are cleaned up when a new preview is stored. Interrupted running plans remain blocked for operator recovery.

Repository locks apply across Web and MCP and do not expire automatically. This prevents a slow or disconnected first request from writing concurrently with a retry. A crash, lost lock-release response, or state-service outage can leave a lock behind. GitHub calls have connect/read timeouts and do not automatically retry writes or follow redirects. A browser timeout does not cancel an operation already accepted by the server.

Cleanup and plan-recording errors never replace a successful creation result or the original creation error. Successful responses retain the repository URL and may include warnings: repository_lock_release_failed means lock release was not confirmed; plan_completion_record_failed means the completed MCP result was not confirmed in storage. MCP keeps isError: false, and Web displays the warning beside its success result. Do not create again to clear either warning. Have the operator inspect GitHub and recover only if needed. A lost write response may mean the state was already saved; otherwise the running plan stays blocked. State errors such as plan_expired, plan_not_found, operation_in_progress, operation_failed, capacity_exceeded, and repository_busy retain distinct MCP codes. Transport or unrecognized state errors use state_unavailable.

To recover a stuck operation:

  1. Set MAINTENANCE = "true" in the vars of both Wrangler files and deploy both Workers with --containers-rollout=none. New API requests now return 503.

  2. In the Cloudflare dashboard, stop both application containers and verify that they have fully stopped. A client timeout alone is not proof that work stopped.

  3. Inspect the target GitHub repository. Supply MCP_ORIGIN and STATE_RECOVERY_SECRET through your local environment, then run:

    node scripts/cloudflare/recover.js OWNER/REPO.jl --confirm-containers-stopped
  4. This clears only that repository's lock and marks its interrupted plans failed. Remove maintenance mode and redeploy both Workers, then follow Resuming an interrupted setup.

The recovery endpoint requires the operator key and checks MCP maintenance plus the Web gateway's /api/health maintenance response. Container shutdown is still an operator check: --confirm-containers-stopped is an explicit attestation, not a Cloudflare control-plane verification. Keep both apps in maintenance throughout recovery; a health response cannot prove that an earlier request stopped running.

Do not delete the ApplicationState namespace or change its class/name during ordinary releases. That would discard plan history and repository exclusion. Worker observability is disabled by default to avoid recording OAuth callback URLs or credentials. Web application request logs contain only a generated request ID, a known route, HTTP status, and duration. Keep Authorization headers, request bodies, and OAuth response bodies out of logs when adding observability.

Cost estimate (rates checked 2026-09-26)

The configuration uses two basic instances (each 1 GiB RAM, 0.25 vCPU, 4 GB disk). Memory/disk are charged for provisioned capacity while running; CPU is charged for actual use. Cloudflare Containers do not support swap. For the idle timeout and retained state, see Persistence, failures, and recovery.

ItemIncluded in Workers PaidAdditional usage
Base planAccount-wideUSD 5/month
Container memory25 GiB-hours/monthUSD 0.009/GiB-hour
Container CPU6.25 vCPU-hours/monthUSD 0.072/vCPU-hour
Container disk200 GB-hours/monthUSD 0.000252/GB-hour

For a 30-day month, assuming other projects have not consumed the allowances, and combined actual CPU use remains within 6.25 vCPU-hours/month:

Each app's running time per dayBase + container memory/diskContainer Durable Object durationEstimated monthly total
20 minutesUSD 5.00USD 0.00USD 5.00
1 hourUSD 5.33USD 0.00USD 5.33
24 hours, mostly idleUSD 19.14USD 3.29USD 22.43

Running time includes startup and the five-minute wait before sleep, not just the seconds spent generating a package. Periodic requests from an MCP client can keep the container awake; check actual running time after connecting it. The DO estimate conservatively keeps each container's 128 MB Durable Object active for its entire running time, with 400,000 GB-seconds included and USD 12.50 per additional million GB-seconds. The separate plan store adds brief operations; request/storage overages are not included in this table. Every additional vCPU-hour over 6.25 costs USD 0.072. At continuous full CPU use, the two basic instances could consume 360 vCPU-hours in 30 days: about USD 47.90 including the estimated DO duration, before other overages. The 24-hour idle estimate is not a busy-service estimate.

For light use, Workers, KV, Durable Objects and egress will usually stay within their included allowances, but this is an estimate, not a cap. Taxes, exchange rates, domain registration, build overages, other apps on the account, and unexpected traffic are additional. Local measurements support the basic size; check cold starts and real concurrency after deployment. Configure billing notifications in Cloudflare.

Recalculate the base/container subtotal with:

node scripts/cloudflare/pricing.js 1 1 basic 2
# arguments: running hours per app/day, active vCPU-hours/month, size, app count
node scripts/cloudflare/pricing.js 24 1 basic 2

Sources: Containers pricing, Workers and KV pricing, Durable Objects pricing, no-swap FAQ.

Reproduce the local memory measurement

From the repository root, with Docker Desktop in Linux mode:

docker build --platform linux/amd64 -f deploy/mcp/cloudflare/Dockerfile -t pkgfactory-mcp:cloudflare .
docker build --platform linux/amd64 -f deploy/web/cloudflare/Dockerfile -t pkgfactory-web:cloudflare .
node deploy/scripts/cloudflare/profile.js mcp
node deploy/scripts/cloudflare/profile.js web

The script uses the image's actual Julia command-line flags and enforces --memory=1g --memory-swap=1g --cpus=0.25. Equal memory/swap values disable swap. Networking is disabled; loopback HTTP remains available. It exercises each template through the actual private HTTP server, real SSH key generation and secret encryption, with simulated GitHub responses. MCP plan storage is in memory for this isolated test; workerd tests separately cover durable claims, caller isolation, OAuth consent, PKCE, token exchange and refresh.

Measurements on Docker Desktop/Linux amd64, 2026-09-26:

RuntimeListening afterPeak process RSSPeak cgroup memory
MCP, default Julia compiler (comparison)52.1 s729.5 MiB505.0 MiB
MCP, shipped compiler/heap settings6.5 s485.8 MiB260.8 MiB
Web, shipped compiler/heap settings12.0 s448.6 MiB229.8 MiB

Both optimized runs completed all three templates and a four-request burst without an OOM. RSS and cgroup peaks differ because of shared/mapped pages and Linux accounting; they are not interchangeable measurements. These are local smoke measurements, not Cloudflare production latency or a high-concurrency capacity guarantee. The heap hint is a GC hint, not a hard process memory cap. Cold image loading, real GitHub latency and concurrent users need live validation.