AppCrane
The self-hosted home for the apps your AI builds and your AI deploys.
Vibe-code an app with Claude Code or Cursor, then have your AI agent deploy it — over MCP — to a server you own. AppCrane is a self-hosted, agent-first deployment platform with the enterprise guardrails the cloud PaaS crowd skips: Docker isolation per app, SAML/OIDC/SCIM SSO, per-user audit, and a middleware hard-wall so the platform operator can't read your app secrets (your model API keys stay yours). A self-hosted alternative to Heroku, Vercel, and hosted agent-deploy services like AppDeploy.
MCP-first. AI agents connect once via claude mcp add ... /api/mcp and operate the platform through 39 appcrane_* tools. No curl, no separate scripts — appcrane_get_guide(topic="onboarding"|"operations") returns the latest playbook on demand.
Why AppCrane
| Feature | AppCrane | Coolify | Dokploy |
|---|---|---|---|
| Agent-first / MCP-native | ✅ | ❌ | ~ add-on |
| Self-hosted, your infra | ✅ | ✅ | ✅ |
| Enterprise SSO (SAML/OIDC/SCIM) | ✅ | ~ | ❌ |
| Secret hard-wall (operator can't read) | ✅ | ❌ | ❌ |
| Managed repo (no GitHub account) | ✅ | ❌ | ❌ |
| Docker isolation per app | ✅ | ✅ | ✅ |
| Dual sandbox/prod environments | ✅ | ~ | ✅ |
| Zero-downtime deploys | ✅ | ~ | ✅ |
| Open source | ✅ AGPL-3.0 | ✅ Apache-2.0 | ✅ Apache-2.0 |
Full matrix vs AWS Copilot / App Runner / Lightsail / CodeDeploy / Vercel / AppDeploy → glick.run/comparison.html
Features
- Docker container isolation — every app runs in its own container; no shared dependencies, no runaway processes
- Enterprise SSO — SAML 2.0, OIDC, and SCIM provisioning; connect to Okta, Azure AD, Google Workspace
- Identity forwarded to apps as headers —
X-AppCrane-User-Role,X-AppCrane-App-Role, etc. are injected by the proxy afterforward_authverifies the user; deployed apps read identity directly off the request without a callback (oauth2-proxy / IAP pattern) /api/meendpoint — canonical "who is the caller" for proxied apps; accepts thecc_tokencookie, Bearer, orX-API-Key; returns global role + per-app role (?app=<slug>orReferer-inferred)- Headless app type — set
auth_mode: 'headless'to bypassforward_authentirely on an app; right tool for telemetry ingest, public webhooks, status pages, and single-purpose unauthenticated services - AppStudio AI pipeline — AI proposes code improvements on a schedule; you review and approve before anything ships
- Real-time presence — see who's active on each app, which environment, and when they last deployed
- Dual environments per app: production + sandbox, always-on, separate ports
- Auto-HTTPS via Caddy reverse proxy with Let's Encrypt
- GitHub webhook auto-deploy on push (HMAC-verified)
- Zero-downtime deploys (start new, health check, swap, drain old)
- Rollback in seconds (symlink-based, keeps last 5 releases)
- Encrypted env vars (AES-256-GCM) — admin cannot read them by design
- Health checks with auto-restart and email notifications
- Audit log for every action
- MCP server at
/api/mcpexposing 39appcrane_*tools — agents operate the platform without ever touching curl, gh, or shell
Quick Start
One command on a fresh Ubuntu server installs and wires up everything — Node, Caddy (with automatic HTTPS), Docker, the systemd service, an encrypted-secrets key, and your admin user:
curl -fsSL https://raw.githubusercontent.com/gitayg/appCrane/main/install.sh | sudo bash
It prompts for just two things — your domain and admin email — and is safe to re-run. When it finishes, point your domain's DNS at the server and you're live.
Prerequisites: a fresh Ubuntu server (root / sudo) and a domain whose DNS A
record points at it — Caddy provisions TLS automatically on first request.
Non-interactive (CI / automation) — no prompts:
sudo CRANE_DOMAIN=crane.example.com [email protected] bash install.sh
# flags also work: --domain / --admin-email / --admin-name / --tls-cert / --tls-key
Everything below is done for you, idempotently, by the one command above:
- Node.js 20 + AppCrane, with the
craneCLI linked globally - Caddy — the reverse proxy that routes
<domain>/<slug>to each app, runs the SSO auth, injects theX-AppCrane-*identity headers, and auto-provisions TLS — plus the group, file permissions, and asudoersrule so AppCrane can reload Caddy on every deploy - Docker + a systemd
appcraneservice (Restart=always— survives crashes and reboots, and powers one-click self-update) - A
.envwith a freshly generatedENCRYPTION_KEY— back this up; losing it makes every stored secret unrecoverable — and your admin user (crane init)
Installing by hand means reproducing all of that — especially the Caddy install +
permissions + sudoers, which is the most-missed step and later surfaces as
permission errors or apps that never receive their identity headers. If you must,
treat install.sh as the source of truth rather than a shortened list.
AppStudio (optional): to enable AI app-building, set an Anthropic API key —
systemctl edit appcrane --force, addEnvironment="ANTHROPIC_API_KEY=sk-ant-..."under[Service], thensystemctl daemon-reload && systemctl restart appcrane.
Deploy your first app
The installer already created your admin user, so once DNS points at the box:
# Reachable at https://<your-domain>/myapp
crane app create --name "MyApp" --slug myapp --repo https://github.com/yourorg/myapp
crane deploy myapp --env sandbox
# Give a teammate access (optional)
crane user create --name sarah --email [email protected]
crane app assign myapp --email [email protected]
CLI Reference
Server
crane status # Server health: CPU, RAM, disk, apps
crane config --show # Show CLI config
crane config --url http://localhost:5001 # Set API URL
crane config --key dhk_admin_xxx # Set API key
# Recover a lost platform-owner API key (run on the box, direct DB).
# Defaults to the platform_admin; override to target a specific account:
crane regenerate-key # Regenerate the platform owner's key
crane regenerate-key --email [email protected] # ...for a specific user by email
crane regenerate-key --user-id 1 # ...or by user id
Migrate config between instances
Move the platform settings (including encrypted secrets) to another AppCrane —
without sharing encryption keys. Export keeps secrets ciphertext; import
re-encrypts them with the target instance's own key.
# On the SOURCE instance:
crane config export --out config.json
# Copy config.json to the TARGET, then on the TARGET:
OLD_ENCRYPTION_KEY=<source ENCRYPTION_KEY> crane config import config.json
The source ENCRYPTION_KEY (from the source's .env) is needed only to decrypt
the secrets during import; it is used transiently, never stored. One-way values
(e.g. the SCIM token, stored as a hash) can't be migrated — the import lists them
to regenerate on the target. Delete config.json afterward.
Apps (admin)
crane app list
crane app create --name X --slug x --domain x.example.com --repo https://github.com/...
crane app info myapp
crane app delete myapp --confirm
crane app assign myapp --email [email protected]
Deploy (app user)
crane deploy myapp --env sandbox
crane deploy myapp --env production
crane deploy:history myapp --env prod
crane deploy:log myapp --id 5
crane rollback myapp --env production
crane promote myapp # sandbox → production, zero downtime
Env Vars (app user — admin cannot access)
crane env set myapp --env sandbox DATABASE_URL=postgres://... API_KEY=sk-test
crane env list myapp --env production
crane env list myapp --env sandbox --reveal
crane env delete myapp API_KEY --env sandbox
Health, Webhooks, Backups
crane health status myapp
crane health config myapp --env prod --endpoint /api/health --interval 30
crane webhook myapp --auto-sandbox on
crane backup create myapp --env prod
crane backup list myapp
crane logs myapp --env production
crane audit --app myapp
MCP (for AI agents)
AppCrane is MCP-first. One claude mcp add and the agent gets 35
appcrane_* tools — list apps, deploy, set/get secrets, read logs,
manage access, rotate icons, the lot. Tool names are AWS-aligned
(stage, set_secret/get_secret, cp).
claude mcp add --transport http appcrane https://crane.example.com/api/mcp \
--header "X-API-Key: dhk_admin_or_user_xxxxxxxxxxxxx" \
--header "X-Github-Token: ghp_your_github_pat"
Then in any Claude Code session:
Onboard a new app. Start by calling
appcrane_get_guidewithtopic="onboarding"for the playbook.
The agent pulls the current guide from the server, so edits propagate
without a redeploy of your tooling. topic="operations" returns the
post-onboarding reference (deploy lifecycle, troubleshooting fast
failures, access management, etc.).
Architecture
Ubuntu Server
├── Caddy (reverse proxy, auto-HTTPS)
│ ├── myapp.example.com → production app
│ └── myapp-sandbox.example.com → sandbox app
├── Docker (container isolation)
│ ├── myapp-production ← isolated container per env
│ └── myapp-sandbox
├── AppCrane API (:5001)
│ ├── Express 5 + SQLite
│ ├── Health checker (cron)
│ ├── SSO (SAML / OIDC / SCIM)
│ ├── AppStudio AI pipeline
│ └── Presence (WebSocket)
└── /data/apps/myapp/
├── production/releases/ (symlink-based, last 5)
└── sandbox/releases/
Security
- Init locked to localhost — admin setup only from the server itself
- API key auth — all requests require
X-API-Keyheader - Admin isolation — admin cannot read env vars or
/data/; enforced at middleware level - AES-256-GCM encrypted env vars at rest
- Webhook HMAC verification for GitHub
- SCIM deprovisioning — removing a user from your IdP revokes AppCrane access automatically
- All actions audited — who did what, when
Identity contract for deployed apps
Apps deployed on AppCrane never need to implement their own auth. The Caddy proxy verifies every request against /api/identity/verify before forwarding it to the container, and the result is delivered to the app in three complementary ways. Apps should consume them in this precedence order:
1. Request headers (zero-fetch, recommended)
Caddy copy_headers the verified identity onto the upstream proxy request. The app reads them directly:
| Header | Value | Notes |
|---|---|---|
X-AppCrane-User |
Backward-compat single identifier. Always set for authenticated requests. | |
X-AppCrane-User-Id |
numeric id (string) | Always set. |
X-AppCrane-User-Email |
Granular. May be absent if the user has no email. | |
X-AppCrane-User-Name |
display name, encodeURIComponent-d |
decodeURIComponent on read. May be absent. |
X-AppCrane-User-Role |
platform_admin | admin | user |
Raw token, underscore intact. Always set. |
X-AppCrane-App-Role |
owner | admin | user | viewer |
Per-app role. Platform admins collapse to admin on every app — branch on X-AppCrane-User-Role if you specifically need to target platform admins. |
Trust model: the Caddy generator emits request_header -X-AppCrane-* strip directives before the forward_auth block in each per-app handler. Caddy zeroes out any client-set X-AppCrane-* headers first, then copy_headers re-injects only what /verify returned. Header smuggling is impossible — what the app receives is guaranteed platform-issued.
Absence semantics: if X-AppCrane-User-Role isn't on the request, the request was not verified (Caddy failed closed at forward_auth and you wouldn't receive it). So presence = trusted.
// Express example
app.use((req, res, next) => {
const role = req.get('X-AppCrane-User-Role') // 'platform_admin' | 'admin' | 'user'
const appRole = req.get('X-AppCrane-App-Role') // 'owner' | 'admin' | 'user' | 'viewer'
const email = req.get('X-AppCrane-User-Email') || req.get('X-AppCrane-User')
req.user = role ? { id: req.get('X-AppCrane-User-Id'), email, role, appRole } : null
next()
})
2. GET /api/me (when you need more than the basics)
Returns the full user object — name, email, username, global role — plus the per-app role for whatever app the caller is asking about. Same origin as the app, so the browser auto-sends cc_token; no SDK or token plumbing required:
const r = await fetch('/api/me') // ?app=<slug> optional; Referer-inferred otherwise
if (r.status === 401) { location.href = '/login?redirect=' + encodeURIComponent(location.href); return }
const { user, app_role } = await r.json()
Auth precedence inside /api/me:
cc_tokencookie (proxied apps' default —httpOnly, browser-managed).Authorization: Bearer <session>(CLI / programmatic).X-API-Key: dhk_*(admin / agent keys).
App slug resolution:
- Explicit
?app=<slug>query. Referer-inferred (first path segment; sandbox-suffix retry).- Lean global-only payload if neither resolves.
3. Headless apps — opt out entirely
For services where the whole app is meant to be unauthenticated — telemetry ingest, public webhooks, status pages, the squash CLI's ping/stats — set the app's auth_mode to headless (owner-only toggle in the Launcher, or appcrane_set_app_meta slug=<…> auth_mode=headless via MCP). The Caddy block then skips forward_auth, copy_headers, and the strips entirely. No X-AppCrane-* headers, no /api/me, no cc_token. The app's own server takes responsibility for any payload-level authn it needs (HMAC, install-id, IP allowlist, etc.).
Pick by shape:
- The whole app is unauth ingest → headless app (clean separation, smaller blast radius).
- Mostly-auth app with a couple of public endpoints → keep
authenticated, gate the public paths at the app's own router.
4. Per-tenant DB (multitenancy) — opt in
Opt in with "multitenant": true in deployhub.json and AppCrane gives each of
your app's users an isolated SQLite database on the persistent /data volume —
you don't build tenant isolation yourself. A tenant is (org, user), where
org is the user's email domain. This is fully opt-in: apps that don't set
the flag are completely unaffected.
When enabled, AppCrane injects APPCRANE_TENANT_ROOT=/data/tenants. Use the
appcrane-tenant helper to derive the tenant DB from the
identity headers above (section 1) — no path-building by hand:
import { tenantDb } from 'appcrane-tenant'
app.get('/api/notes', (req, res) => {
const db = tenantDb(req) // opens /data/tenants/<org>/u<userId>/db.sqlite
res.json({ notes: db.prepare('SELECT * FROM notes').all() })
})
tenantDbPath(req) returns just the path if you use a different SQLite driver.
Each tenant also gets a storage/ dir (tenantStorageDir(req) / tenantFile(req, name))
for files. Set "tenant_quota_mb": <n> in deployhub.json to cap per-tenant
usage — AppCrane injects it and assertTenantQuota(req) throws once a tenant is
full (the quota covers DB + storage).
Always build tenant paths via the helper (never from raw user input) — the
identity headers are platform-signed and the org slug is sanitised against
traversal. When a user's access is revoked, AppCrane purges that tenant's dir
automatically. Consumer domains (e.g. gmail.com) share an org label, but
isolation is per-user, so data never mixes. The helper isn't on npm yet — copy
packages/tenant/index.js or depend on it by path;
see the multitenant-notes example.
Permission Model
| Action | Admin | App User |
|---|---|---|
| Create/delete apps | Yes | No |
| Assign users | Yes | No |
| Server health | Yes | No |
| Deploy / rollback / promote | No | Yes (own apps) |
| View/edit env vars | No | Yes (own apps) |
| Configure health/webhooks | No | Yes (own apps) |
| Backups | No | Yes (own apps) |
Tech Stack
Node.js 20, Express 5, SQLite, Docker, Caddy 2, SAML/OIDC/SCIM, AES-256-GCM, Commander.js, Ubuntu 22.04+
License
GNU AGPL v3. Free and open source — use, modify, and self-host. If you run a modified version as a network service, you must make your source available under the same license. Need to run private modifications as a service, or embed AppCrane in a proprietary product? A commercial license is available.
Feedback & Contributions
Open an issue: https://github.com/gitayg/appCrane/issues
Pull requests welcome — please read CONTRIBUTING.md first. It includes the short CLA that keeps AppCrane's dual-licensing (AGPL + commercial) possible.
No comments yet
Be the first to share your take.