Self-Hosting Guide
This guide walks you through deploying Simple Agent Manager to your own infrastructure. Deployment is automated via GitHub Actions + Pulumi from your own fork.
Prerequisites
Section titled “Prerequisites”| Requirement | Purpose | Tier |
|---|---|---|
| Cloudflare account | API hosting, DNS, storage | Workers Paid ($5/mo) |
| GitHub account | Authentication, CI/CD | Free tier |
| Domain on Cloudflare | Workspace URLs | Any registrar |
Workers Paid is required because SAM uses Durable Objects for real-time chat, task execution, and node lifecycle, and Cloudflare Containers for the default instant-session runtime. Go to Workers & Pages in the Cloudflare dashboard to upgrade. You also need R2 enabled for Pulumi state and Analytics Engine enabled (free): Workers & Pages → Analytics Engine → Enable.
You do not need a shared cloud provider account. Users provide their own Hetzner API token, Scaleway API key, or GCP configuration through the Settings UI. GCP support also requires optional Google OAuth configuration on the SAM instance.
Step 1: Choose Your Domain and Cloudflare Account
Section titled “Step 1: Choose Your Domain and Cloudflare Account”Use a top-level domain as BASE_DOMAIN (e.g., example.com), not a subdomain (sam.example.com). Cloudflare’s free Universal SSL covers *.example.com but not nested wildcards like *.sam.example.com. The root domain is not used by SAM — only api., app., and *. subdomains are created.
Copy your Cloudflare account ID from the dashboard URL. In a URL like https://dash.cloudflare.com/<account-id>/<domain>, the account ID is the 32-character value immediately after dash.cloudflare.com/.
Open your domain overview at https://dash.cloudflare.com/<account-id>/<domain> and copy the Zone ID from the right sidebar.
Step 2: Fork the Repository
Section titled “Step 2: Fork the Repository”Fork simple-agent-manager on GitHub.
Step 3: Create Cloudflare API Token
Section titled “Step 3: Create Cloudflare API Token”In the Cloudflare account from Step 1, enable R2 first if it is not already active. Then open https://dash.cloudflare.com/<account-id>/api-tokens → Create Custom Token:
| Permission Type | Resource | Access |
|---|---|---|
| Account | Cloudflare Workers: D1 | Edit |
| Account | Workers KV Storage | Edit |
| Account | Workers R2 Storage | Edit |
| Account | Workers Scripts | Edit |
| Account | Workers Observability | Read |
| Account | Cloudflare Pages | Edit |
| Account | AI Gateway | Edit |
| Account | Containers | Edit |
| Account | SSL and Certificates | Edit |
| Zone | DNS | Edit |
| Zone | Workers Routes | Edit |
| Zone | SSL and Certificates | Edit |
| Zone | Zone | Read |
Set Zone Resources to your specific domain and Account Resources to your account.
The default deploy configures AI Gateway and writes Analytics Engine data, so keep the AI Gateway permission and enable Analytics Engine even if every user brings their own model keys. SAM also enables the Cloudflare Container instant-session runtime by default, so Containers: Edit is required for the default Worker deploy. The Account → SSL and Certificates → Edit permission is required for issuing per-node Origin CA certificates used for VM-agent TLS — without it, new nodes cannot obtain a TLS certificate and will fail to boot.
Optional — SAM-hosted repositories (experimental): SAM can create and host Git repositories for projects directly on Cloudflare Artifacts (currently in beta), letting users start a project without connecting GitHub. If your Cloudflare account has Artifacts access, add an Account → Artifacts → Edit permission to the token. The deploy auto-detects whether the token can reach the Artifacts REST API and enables the SAM-hosted project option only when it can — so it is safe to add the permission if it is available, or omit it entirely with no effect. To force the behavior regardless of detection, set the ARTIFACTS_BINDING_ENABLED GitHub Actions variable to true or false.
Because the token includes Workers R2 Storage access, Cloudflare’s final creation screen shows both the API token and a set of R2 S3 keys (Access Key ID + Secret Access Key) together. You do not need to create a separate R2 API token. Copy all of the values before leaving the page:
CF_API_TOKEN— the API token itselfR2_ACCESS_KEY_ID— the R2 S3 Access Key ID shown alongside the tokenR2_SECRET_ACCESS_KEY— the R2 S3 Secret Access Key shown alongside the token
Pulumi uses the R2 S3 keys for its state backend. The deploy workflow creates the Pulumi state bucket later as ${RESOURCE_PREFIX}-pulumi-state.
Step 4: Create GitHub App
Section titled “Step 4: Create GitHub App”You can deploy SAM before creating a GitHub App. On first deploy, SAM generates a one-time SETUP_TOKEN Worker variable and the workflow prints a Cloudflare dashboard link where you can read it. Open https://app.yourdomain.com/setup, paste that token, and configure GitHub App, GitHub OAuth, and Google login OAuth credentials in the setup wizard. The Google login client must have https://api.yourdomain.com/api/auth/callback/google registered as an authorized redirect URI, and is a separate OAuth client from any Google/GCP infrastructure credentials (GOOGLE_CLIENT_ID). The values are stored in SAM’s encrypted platform credential store and can be rotated later by a superadmin.
If you prefer to create the GitHub App first, use the wizard below to create it with all settings pre-filled. Enter your domain, click through to GitHub, and paste the generated values into /setup or set the optional GH_* GitHub Environment secrets before deploy.
Your Domain
Enter the domain you'll use for SAM (the same BASE_DOMAIN you'll set in Step 6).
The GitHub App needs Pull requests: Read-only because it subscribes to the pull_request webhook event. Enable Redirect on update under the Setup URL settings so repository access changes return users to SAM.
Step 5: Generate Pulumi Passphrase
Section titled “Step 5: Generate Pulumi Passphrase”openssl rand -base64 32Add this passphrase to the GitHub Actions environment in the next step and keep a copy in your password manager for future deployments or teardown.
Step 6: Configure GitHub Environment
Section titled “Step 6: Configure GitHub Environment”In your fork: Settings → Environments → New environment → name it production.
Environment variables:
| Variable | Description | Example |
|---|---|---|
BASE_DOMAIN | Your domain | example.com |
RESOURCE_PREFIX | Domain-derived Cloudflare resource prefix | sa379a6 |
CF_CONTAINER_ENABLED | Optional instant-session runtime toggle. Defaults to true; set false to force VM runtime. | false |
The guided setup generates RESOURCE_PREFIX from BASE_DOMAIN as s plus the
first six hex characters of the domain’s SHA-256 hash. Use that generated value
instead of choosing a generic prefix.
Environment secrets:
| Secret | Description |
|---|---|
CF_API_TOKEN | Cloudflare API token |
CF_ACCOUNT_ID | Cloudflare account ID (32-char hex) |
CF_ZONE_ID | Domain zone ID (32-char hex) |
R2_ACCESS_KEY_ID | R2 API token access key |
R2_SECRET_ACCESS_KEY | R2 API token secret key |
PULUMI_CONFIG_PASSPHRASE | Generated passphrase |
GH_CLIENT_ID | Optional GitHub App client ID; can be configured in /setup instead |
GH_CLIENT_SECRET | Optional GitHub App client secret; can be configured in /setup instead |
GH_APP_ID | Optional GitHub App ID; can be configured in /setup instead |
GH_APP_PRIVATE_KEY | Optional GitHub App private key (PEM or base64); can be configured in /setup |
GH_APP_SLUG | Optional GitHub App URL slug; can be configured in /setup instead |
GH_WEBHOOK_SECRET | Optional GitHub App webhook secret; can be configured in /setup instead |
GOOGLE_LOGIN_CLIENT_ID | Optional Google login OAuth client ID (Sign in with Google); can be configured in /setup instead. Register redirect URI https://api.yourdomain.com/api/auth/callback/google |
GOOGLE_LOGIN_CLIENT_SECRET | Optional Google login OAuth client secret; can be configured in /setup instead |
GOOGLE_CLIENT_ID | Optional Google infra/GCP OAuth client ID for GCP deployment authorization (separate client from login; NOT set via /setup) |
GOOGLE_CLIENT_SECRET | Optional Google infra/GCP OAuth client secret (separate client from login; NOT set via /setup) |
CF_AIG_TOKEN | Optional narrower Cloudflare AI Gateway Unified Billing token |
DEVCONTAINER_CACHE_CLOUDFLARE_API_TOKEN | Optional narrower Cloudflare token for managed devcontainer registry credentials |
DEVCONTAINER_CACHE_CLOUDFLARE_ACCOUNT_ID | Optional Cloudflare account override for managed devcontainer registry credentials |
Step 7: Deploy
Section titled “Step 7: Deploy”Go to Actions → Deploy Production → Run workflow. Choose the main branch. No commit is needed after the environment is configured.
The workflow:
- Validates configuration
- Provisions infrastructure via Pulumi (D1, KV, R2, DNS)
- Deploys API Worker and Web UI
- Runs database migrations
- Builds and uploads VM Agent binaries
- Runs health check
Verification
Section titled “Verification”After deployment completes:
# API health checkcurl https://api.yourdomain.com/health# Should return: {"status":"healthy","timestamp":"..."}Open the Cloudflare dashboard link printed by the workflow, copy the plaintext SETUP_TOKEN Worker variable, then open https://app.yourdomain.com/setup. The setup page accepts the token only while first-run setup is incomplete. After you save a valid login provider and complete setup, /setup returns Gone and future changes move to the superadmin platform config UI.
Open https://app.yourdomain.com — you should see the login page with whichever providers you configured.
First Login & Admin Access
Section titled “First Login & Admin Access”The first genuine human to sign in to a fresh deployment becomes the superadmin — the account that can approve other users, manage platform credentials, and reach the admin dashboard.
Fresh deployments are seeded with an internal sentinel user (system_anonymous_trials, status='system', created by a database migration) used for anonymous trials. This sentinel is not a real user and never counts toward “is this the first human” checks. Two mechanisms guarantee the first real human is promoted regardless of the sentinel:
- Deploy-time backfill (migration): when a deployment has exactly one non-system human and no superadmin yet, that human is promoted to
superadmin/activewhen migrations run. This also covers accounts created via token-login or device-flow. - Login-time self-heal (all sign-in paths): on every session creation — GitHub OAuth, token-login, and device-flow — if the signing-in user is the only non-system human and no superadmin exists, they are promoted in a single atomic, idempotent write. A failure here never blocks login.
Both mechanisms apply the same promotion conditions and are strictly no-ops in any other state — they never promote a second user, never touch an existing superadmin, and never auto-promote a suspended or system account. They differ only in how they exclude the sentinel: the login-time hook excludes it by both status='system' and id, while the deploy-time migration excludes it by status='system' alone. On managed/multi-user deployments nothing changes.
Notes:
- Promotion happens regardless of
REQUIRE_APPROVAL. Even with open registration, the first human owns the deployment. - First-login race: if two brand-new users sign in at the exact same moment, the atomic guard ensures at most one becomes superadmin; the other remains a normal user.
- Cookie-cache staleness: the session role is cached for up to 5 minutes. If you were promoted while already holding a session (e.g. the deploy-time backfill ran during a deploy while you were logged in), the cached session still shows your old
userrole. Log out and log back in to pick up superadmin access immediately; otherwise you must wait for the cache to expire (up to 5 minutes). If the admin dashboard is still not visible after re-logging in, the promotion did not occur — recheck the guard conditions above.
Overriding the sentinel id (optional)
Section titled “Overriding the sentinel id (optional)”The sentinel user id defaults to system_anonymous_trials. If your deployment uses a different sentinel id, set TRIAL_ANONYMOUS_USER_ID to that value so the first-user checks exclude it correctly. This variable scopes the login-time self-heal hook and the first-user creation check; the deploy-time migration excludes the sentinel by its status='system' flag (not by id), so it stays correct regardless of this setting because the sentinel is always seeded with status='system'. Most deployments never need to set this.
Teardown
Section titled “Teardown”To remove all resources: Actions → Teardown → Run workflow → type DELETE to confirm.
Cost Estimation
Section titled “Cost Estimation”Platform Costs
Section titled “Platform Costs”| Component | Free Tier | Paid Overage |
|---|---|---|
| Cloudflare Workers | 100K req/day | $0.15/million |
| Cloudflare D1 | 5M rows read/day | $0.001/million |
| Cloudflare KV | 100K reads/day | $0.50/million |
| Cloudflare R2 | 10GB storage | $0.015/GB/month |
| Cloudflare Pages | Unlimited | Free |
The Workers Paid plan ($5/month) is required for Durable Objects and Cloudflare Containers. Beyond the base plan, usage-based costs stay within free tier allowances for small to medium usage.
User VM Costs
Section titled “User VM Costs”VMs are billed to each user’s own cloud provider account. SAM supports Hetzner, Scaleway, and GCP.
Hetzner:
| Size | Specs | Hourly | Monthly |
|---|---|---|---|
| Small (cx23) | 2 vCPU, 4GB RAM | ~$0.007 | ~$4.15 |
| Medium (cx33) | 4 vCPU, 8GB RAM | ~$0.012 | ~$7.50 |
| Large (cx43) | 8 vCPU, 16GB RAM | ~$0.030 | ~$18 |
Scaleway:
| Size | Type | Hourly |
|---|---|---|
| Small (DEV1-M) | 3 vCPU, 4GB RAM | ~€0.024 |
| Medium (DEV1-XL) | 4 vCPU, 12GB RAM | ~€0.048 |
| Large (GP1-S) | 8 vCPU, 32GB RAM | ~€0.084 |
Troubleshooting
Section titled “Troubleshooting””error: failed to decrypt state”
Section titled “”error: failed to decrypt state””Your PULUMI_CONFIG_PASSPHRASE doesn’t match the one used when state was created. Use the original passphrase or delete the stack in R2 and start fresh.
”OAuth callback failed” / redirect URI mismatch
Section titled “”OAuth callback failed” / redirect URI mismatch”Check that your GitHub App’s Callback URL matches your BASE_DOMAIN exactly: https://api.yourdomain.com/api/auth/callback/github. If you changed BASE_DOMAIN after initial setup, update the Callback URL, Setup URL, and Webhook URL on the same GitHub App.
Origin CA Certificate Error (1016)
Section titled “Origin CA Certificate Error (1016)”Your API token is missing the Account → SSL and Certificates → Edit permission. New nodes require this permission so the API Worker can sign the node-generated CSR via Cloudflare Origin CA for VM-agent TLS. Edit the token in the Cloudflare dashboard and add it.
Rotating legacy shared Origin CA certificates
Section titled “Rotating legacy shared Origin CA certificates”Older deployments may have existing nodes that were provisioned before per-node CSR signing and therefore received the legacy shared ORIGIN_CA_KEY in cloud-init. To rotate safely:
- Drain or delete existing workspace/deployment nodes so no running VM depends on the old certificate/key pair.
- Re-deploy SAM with this per-node certificate model so new nodes generate their own private key locally and fetch only a signed certificate.
- In Cloudflare SSL/TLS → Origin Server, revoke the old wildcard Origin CA certificate after all old nodes are gone.
- Remove any manually configured
ORIGIN_CA_CERTorORIGIN_CA_KEYWorker secrets. They are legacy rotation inputs and are not required for new node provisioning.
Analytics Engine Not Enabled (10089)
Section titled “Analytics Engine Not Enabled (10089)”Go to Workers & Pages → Analytics Engine → Enable. This is free but must be explicitly activated.
Durable Objects Free Plan Error (10097)
Section titled “Durable Objects Free Plan Error (10097)”Upgrade to the Workers Paid plan ($5/month). Go to Workers & Pages → upgrade plan.
Containers Forbidden
Section titled “Containers Forbidden”Your API token is missing the Account → Containers → Edit permission. SAM enables Cloudflare Container instant sessions by default, so edit the token and add the permission. To deploy without the container runtime, set the GitHub environment variable CF_CONTAINER_ENABLED=false and re-run the workflow.
SSL Handshake Failure
Section titled “SSL Handshake Failure”If using a subdomain as BASE_DOMAIN (e.g., sam.example.com), the free Universal SSL certificate does not cover nested wildcards (*.sam.example.com). Use a top-level domain as BASE_DOMAIN instead.
DNS Record Already Exists
Section titled “DNS Record Already Exists”If you changed BASE_DOMAIN, old DNS records from a previous deployment may conflict. Go to Cloudflare DNS and delete the stale api, app, and * records, then re-run the deploy.
”D1_ERROR: no such table”
Section titled “”D1_ERROR: no such table””Migrations haven’t been applied. The deploy workflow runs them automatically, but you can also run manually:
wrangler d1 migrations apply <deployed-d1-database-name> --remoteUse the D1 database name from the deploy workflow’s Pulumi stack output.
”Workspace stuck in provisioning”
Section titled “”Workspace stuck in provisioning””Check Hetzner console for VM status. If the VM is running, SSH in and check systemctl status vm-agent.
This page is the canonical troubleshooting reference for self-hosted deployments.