Self-hosted PaaS where the agent is the operator and the platform keeps the receipts.
AppCrane runs the internal apps your team builds with Claude Code or Cursor, on a server you own. An agent creates the app, deploys it, reads the logs and rolls it back through 59 MCP tools — no browser, no curl — while the platform enforces SSO and per-app roles, records every action against the actor that took it (tagged agent or human), and keeps app secrets out of reach of the person administering the box.
It is for teams that have to self-host — data residency, a customer contract, an internal-only network — and still have to answer who deployed this, what was in it, and can we undo it?
Self-hosted PaaS caught up on governance during 2026. Coolify shipped structured audit logging and first-class OIDC; Dokploy shipped SSO, SCIM, custom roles and audit logs. Komodo has had granular per-resource permissions and a full audit trail for longer than either. Most of what used to be a differentiator here no longer is, and the claims below are the ones that survived checking their docs. Four things still stand out:
1. Governance is in the open-source build, not behind a license key. SAML 2.0, OIDC, SCIM provisioning, per-app roles and the audit log are all in the AGPL-3.0 build with nothing to activate. Dokploy ships the same category of capability as Enterprise, gated on a license key. Coolify's are free, but its changelog lists OIDC and audit logging without SAML or SCIM. Komodo's are free too — GPL-3.0, with per-resource permissions and an audit trail in the box — but its documented sign-on is username/password and OAuth (GitHub, Google, generic OIDC), with no SAML or SCIM in the docs. So the free-versus-paid line is really only Dokploy's; against Coolify and Komodo the difference is which enterprise-directory protocols are covered, not what you have to pay to turn them on.
2. The built-in agent interface can change things. Coolify's instance-level MCP server is deliberately read-only — ten list/get tools. AppCrane's 59 include appcrane_deploy, appcrane_rollback, appcrane_promote, appcrane_set_secret and appcrane_grant_app_access. Dokploy's official MCP package is write-capable too, and far larger (508 tools across 49 categories) — AppCrane's surface is smaller by choice, not by capability, and is paired with appcrane_get_guide(topic="onboarding"|"operations"), which serves the current playbook from the server so the agent reads the procedure instead of inferring it from a tool list. Komodo ships no MCP server of its own; neither its repo nor its docs contain one, and the several that exist are third-party wrappers over its REST API.
3. The audit log tells an agent from a person. Every row carries actor_kind, so "what did the agents do on this box last week" is one query. The others record a user identity — Komodo's trail records "who made it and when" (intro) — but none of their docs describe separating automated actors from humans.
4. The operator is locked out of app secrets. Env-var access follows app assignment, and that holds for platform_admin as well — the role that installs and updates the platform cannot read the plaintext of an app it is not assigned to. Reveals are throttled and audited across both doors, HTTP and MCP, so switching transport does not buy a fresh allowance. Komodo documents the opposite arrangement explicitly: marking a variable secret prevents access to the value for non-admin users, which is to say an admin can read it.
Four more that are unusual but worth measuring against your own requirements rather than reading as headlines: per-tenant data isolation for deployed apps; repo-less uploads identified by a server-side SHA-256 over the received bytes instead of a self-reported commit SHA; a daily vulnerability digest mailed per recipient — fleet-wide to a platform admin, own-apps-only to an app owner, so the digest cannot leak which other apps are exposed; and managed repos, so an agent can create and ship an app for someone who has no GitHub account at all.
Versus vendor-hosted governed platforms (Replit, Lovable, Retool, Superblocks), the trade is the usual one: their governance is more mature, and your app data, database connections and API keys live on their infrastructure.
| AppCrane | Coolify | Dokploy | Komodo | CapRover / Dokku | |
|---|---|---|---|---|---|
| Multi-host / fleet deploys | no — single host | yes (experimental) | yes, remote servers | yes, agent per host | Swarm cluster (CapRover) |
| Built-in MCP that can deploy | 59 tools, incl. rollback | 10 tools, read-only | 508 tools (official package), incl. rollback | community projects only | community projects only |
| SAML 2.0 | yes | not in changelog | Enterprise | not documented | no |
| OIDC | yes | yes (v4.4-rc.1) | Enterprise | yes, generic OIDC | no |
| SCIM provisioning | yes | not in changelog | Enterprise | not documented | no |
| Audit log | yes, agent vs human attributed | yes, structured (v4.1.0) | Enterprise | yes, full change trail | no |
| Governance behind a paid tier | no | no | yes | no | n/a |
| Operator cannot read app secrets | yes | not documented | not documented | no — admins can | no |
| Per-tenant data isolation for apps | yes | not documented | not documented | not documented | no |
| Deploy identity for repo-less uploads | server-side SHA-256 | not documented | not documented | not documented | no |
| Core license | AGPL-3.0 | Apache-2.0 | Apache-2.0 + paid Enterprise | GPL-3.0 | open source |
The first row is the one AppCrane loses outright. Komodo is built around fleet management: a Core web server plus a stateless Periphery agent on every connected machine, with "no limit to the number of servers you can connect", Docker Swarm management, and declarative resource sync from a git repo. AppCrane has no agent, no host registry and no remote-execution path — it deploys containers on the machine it is installed on, and that is the whole design. Coolify and Dokploy both reach other servers too, and CapRover joins nodes through Docker Swarm.
CapRover and Dokku are in one column because their access model is the same shape: a single admin account (CapRover) or SSH keys where the word admin in a key name grants key-management rights (Dokku), with multi-user access an explicitly out-of-scope request in one and an unaudited community plugin in the other. That is a reasonable design for a one-operator box; it is not something to put an IdP in front of.
Checked against each project's own documentation and changelog in September 2026. "not documented" means the capability does not appear in their docs — that is not proof it is absent, and a vendor page is a claim, not a test. Verify anything load-bearing on your own install.
Honest scope. Coolify has a far larger template marketplace, a much bigger community, and multi-server orchestration; if you want one-click Postgres and hundreds of app templates, use Coolify. Dokploy's API surface is broader than AppCrane's and it sells support with an SLA. Komodo is the better choice the moment the answer involves more than one machine — a fleet of hosts, a Swarm, builds farmed out to spot instances, configuration synced declaratively from git; AppCrane deploys to the box it runs on and nowhere else, so a multi-host estate is not a smaller version of this, it is a different product. Dokku and CapRover are simpler and lighter if one person operates the box. Choose AppCrane when the apps are agent-built, the agent should do the deploying, everything lands on one server you own, and someone will later ask you to prove who did what.
Why it matters now. Three things changed in 2026:
- The bottleneck moved from writing software to operating it. In Anthropic's Claude Code study (~400k sessions), "operating software" — deploying, configuring, running pipelines — grew from 14% to 21% of sessions while fixing broken code fell from 33% to 19%. Non-engineers now ship deployable code within 7 points of professional engineers. The scarce thing isn't the app any more; it's somewhere safe to run it.
- Shadow AI became measurable. The 2026 Verizon DBIR reports shadow-AI detections up 4×, AI use on corporate devices rising 15% → 45% in a year with 67% through non-corporate accounts — and source code as the most commonly submitted data type. Bans make it worse; a sanctioned platform is the answer that works.
- Governance-by-console is the failure mode. Platforms that gate every app behind a human clicking through an approval UI stall once there are hundreds of apps. AppCrane's answer is different in kind: the agent drives the governed lifecycle over MCP, and the platform records and constrains it — rather than a person mediating each step.
- Docker container isolation — every app runs in its own container; no shared dependencies, no runaway processes
- Managed databases — PostgreSQL, MariaDB, MongoDB and Redis provisioned per app (and per tenant), with credentials injected under whatever env-var names the app actually reads. Postgres, MariaDB and Mongo share one server per engine with isolation enforced inside the engine — a Mongo user is scoped to its own database and
listDatabasesreturns only that one, which is stricter than Postgres, wherepg_databaseleaks every database name. Redis instead gets a container per scope, because its ACLs cannot scope to a numbered database: a user pinned to db 1 can still reach db 2 withCOPY ... DBandMOVE. Mongo runs as a single-node replica set, so change streams and transactions work - Per-app container command and volumes — an app can declare the argv its image needs (
["start-dev"]) and the paths it actually persists. Commands are argv arrays, never shell strings, so nothing in a stored command can become a second token. Declared paths survive the stop-and-recreate every redeploy performs, and 23 catalogue entries carry paths measured from their own images — so an app that was already installed starts keeping its state without anyone editing it. A newly created mount is seeded from the image's content first, because a bind mount (unlike a named volume) masks what the image ships there: without seeding, declaring/var/www/htmlwould hand the app an empty directory where its 7,747 files used to be - Managed-app repositories live on the AppCrane host — a new managed app's code is a bare git repository under
DATA_DIR/repos, not a GitHub repo: no service account, and the code never leaves the box. Repositories are written with git plumbing (no worktree), isolated from the host's git configuration and hooks, and when two pushes race the newest wins — the push that landed first is parked under arefs/dropped/ref, reported back to the pusher, and carried in backups, so it can be recovered. Deploys clone from the local repository and the pre-deploy commit check reads it directly. A push deploys on its own when the app has auto-deploy on for that environment (the same flags and branch filter a GitHub webhook uses), and check-for-updates compares against the local branch. Ask Claude answers from the repository itself through read-only file listing, reading and search pinned to one commit — no container is started; it needs the server'sANTHROPIC_API_KEY(per-app subscription credentials are not used for these apps). AppStudio analyzes the repository and files the result as a new request instead of writing code. Studio and coder sessions are not yet available for these apps. Existing GitHub-backed managed apps move over on their own: on the first boot after upgrading, before AppCrane starts serving, each one is fetched (branches and tags only) into a staging copy and switched to the local repository only when every branch and tag matches GitHub exactly; anything else leaves that app on GitHub and records why. Apps move one at a time, each git operation is killed at a per-app limit and the whole run at a total budget (apps not reached are retried next boot), a failure never stops boot, and the GitHub token is passed to git through its environment, never a URL, config file or command line. While it runs, sign-in for hosted apps is unavailable. Platform admins can see each app's outcome atGET /api/github-service/repo-migration; setAPPCRANE_REPO_MIGRATION=offto skip it. - Uploaded apps become Crane-hosted on their own — on the first boot after upgrading, right after the managed-repo move and before AppCrane starts serving, each app deployed from uploaded bundles is turned into a Crane-hosted app, one at a time, with no deploy and no container touched. Its repository gets one commit holding the release production is running, plus a second commit on top when sandbox runs a different release; each environment's live deployment then points at its commit (the upload's SHA-256 stays in the commit message), so check-for-updates and promote work from the repository.
node_modulesand.gitat any depth are left out of the repository, symlinks are committed as symlinks (never followed; one pointing outside the release is left out) and the excluded paths are listed per app. Every.env*file, at the root or nested, is kept as well, but never in git: its content is stored encrypted in AppCrane's database (so it travels in the data backup) and written back, at its original path and with its original file mode, into that environment's release on every deploy before the build, so build-time keys such asVITE_*reach the build exactly as they did from the bundle. The stored file is the base, and that environment's AppCrane environment variables are layered on each time it is written: in a file a production build loads (.env,.env.local,.env.production,.env.production.local,.env.sandbox,.env.sandbox.local, at the root or nested) a key that is also a variable takes the variable's value, with only that line rewritten and comments, order and formatting kept; a variable that no root-level loadable file defines is appended to the root.env(created with mode 0600 if the app has none), never to a nested file; example, sample and template files are written as stored. Deploys never change the stored copy, so editing or deleting a variable changes the file on the next deploy that builds a new image (an unchanged commit reuses its cached image). Because the file is part of the build context, its values, including appended variables, are inside the built image. The deploy log names the overridden and appended keys, never their values. Production only ever gets production's files and sandbox only sandbox's; a.envsymlink pointing outside the release (what promote leaves in production) is not kept; the conversion status shows platform admins the stored paths, never the contents. An app's owner (or a global admin assigned to the app) manages the stored files on the app page under Stored .env files, or throughGET /api/apps/:slug/env-files(paths, env, mode, size and time, no content),GET /api/apps/:slug/env-files/content?env=&path=(the content; audited asenv_file.reveal, throttled and notified like an env-var reveal, sharing its budget),PUT /api/apps/:slug/env-fileswith{ env, path, content }(create or replace;encoding: "base64"for raw bytes; a new file gets mode 0600, a replaced one keeps its mode; at most 1 MiB of valid UTF-8 that parses as dotenv, a parse error is reported by line number without echoing the content) andDELETE /api/apps/:slug/env-files?env=&path=. Paths follow the same rule as the restore (relative, no..or.git, a.env*file name). Changes are audited asenv_file.replace/env_file.deletewith the path only and take effect on the next deploy of that environment. Other app members get 403 and an app that is not Crane-hosted gets 409. A push to a Crane-hosted app's repository that contains any.env*file (by file name, at any depth, in any letter case,.env.exampleincluded) is refused whole withENV_FILE_IN_PUSHand nothing is committed;appcrane_push_to_managed_appandappcrane_managed_patchrefuse it before touching the repository,appcrane_managed_push_chunkat the first part, andappcrane_managed_assemblebefore committing. The error lists the paths, never the content, and points to environment variables (appcrane_set_secret) or the stored file; a refused file's content is also kept out of the MCP audit record. GitHub-backed managed apps are not affected. Values from the bundled root.env, overridden by.env.productionfor production or.env.sandboxfor sandbox, become the app's encrypted environment variables; a variable already set in AppCrane is never overwritten, example/sample/template, development and nested files are not imported, and no value is ever logged or recorded, only key names. An app is left as an upload app, with the reason recorded, when its build depends on the bundlednode_modules, when its releases are missing, when nothing but excluded content remains, when a.envto import is malformed, when a.envfile is over 1 MiB, or when it is over the size cap (512 MiB,APPCRANE_UPLOAD_CONVERSION_MAX_BYTES). Once converted, bundle uploads for that app are refused with 409; push withappcrane_push_to_managed_appinstead. The upload release directories stay on disk: to revert, set the app'ssource_typeback touploadandrepo_backendto NULL and keep its repository where it is (the next boot then leaves it alone). Platform admins see each app's outcome atGET /api/github-service/upload-conversion; setAPPCRANE_UPLOAD_CONVERSION=offto skip it. When sandbox was running different code, its commit is the newest one, so every later deploy — production included — builds sandbox's code; production cannot be redeployed from its own earlier commit, because the pre-deploy check accepts only the latest pushed commit. Legacy upload apps (source_type='managed_legacy', renamed by v2.3.1) are converted too, unless they carry agithub_url; the status route'soriginal_source_type/revert.source_typesays which type a converted app reverts to. If only one environment still has its uploaded release on disk (for example sandbox after its last uploads failed), the app converts from that one; the other environment's next deploy builds the repository's newest commit. - Sign in inside an embedded frame — an app embedded by a site under the platform's own domain (or an origin the app lists) can sign its user in without leaving the frame: every hop of the sign-in chain (the auth check's redirect,
/login,/launch) dropsX-Frame-Optionsand sends the app'sframe-ancestorspolicy, and only when the redirect names that app. The deep link now survives sign-in, so a signed-out visitor lands on the app they asked for rather than the dashboard. Password sign-in completes in the frame; OIDC and SAML hand off to the identity provider's own page, which the provider may refuse to frame. AppCrane's embedding policy also wins over the app's ownContent-Security-Policy: theframe-ancestorsthe app sends for itself is rewritten to the configured policy at the edge (everything else in the app's CSP — itsscript-srcand nonce,object-src,form-action— is passed through untouched), so an app hard-coded toframe-ancestors 'none'is still embeddable once someone allows it. When the sign-in page detects it is framed, OIDC and SAML instead show a Sign in button that opens the identity provider in a small top-level popup, which closes itself once signed in while the frame reloads into the app. If that sign-in fails (the provider refuses, there is no account, or the attempt expires) the popup stays open with a short reason and a Close button, and the frame stops waiting at once and offers Sign in again; a popup simply closed cannot be detected behind a provider'sCross-Origin-Opener-Policy, so the button stays usable while the frame waits. The sign-in page confirms a stored session with the server before sending the frame back to the app, so a dead session shows the sign-in form, a user with no role on the app sees "You don't have access", and a chain that keeps bouncing stops after three tries with "Sign-in couldn't complete" instead of reloading forever. - Restore onto a bare host, with no registry — a backup is a set of archives, each written straight to disk and never held in memory: a data archive (database, secrets, icons, per-app
/dataand every declared volume), one archive per managed-app code repository, and a streamed archive of the container images themselves, saved by digest. The data archive records which repositories and commits it expects, so restoring it next to a repository archive from a different backup is reported rather than silently mixed. Export runs off the main thread, so hosted apps keep answering their sign-in checks while a backup is taken, and restore streams the upload to disk with a free-space check instead of a fixed size cap. Symbolic links are archived as links, never followed, so an app cannot pull a host file into a backup through its own/data. The scheduled off-site (S3) backup uploads the data archive only, and is a no-op until a bucket and credentials are entered — so a local nightly backup is on by default instead of nothing being on by default: it writesdeployhub.dband.envtoDATA_DIR/backups/localat 04:00 and keeps the newest seven (Settings → Backup changes the hour, the count, or switches it off). That covers a corrupted, deleted or badly-restored database and nothing else — not app icons, per-app/data, declared volumes, repositories or images, which would make an unattended nightly job unbounded on the very disk it is protecting. It is not a substitute for an off-site copy, so until an upload has actually completed every backup surface — the settings API,appcrane_get_backup_status, the dashboard and the boot log — says "No off-site copy — everything AppCrane knows lives on this host." A stored bucket with the schedule off, and a schedule switched on that has never finished, both count as no copy. That matters because a registry is not a backup:bitnami/*images 404 after their registry change, andmedusajs/medusa,vendureio/vendureandcrater/craterall 404 today. When a pull fails, a deploy now falls back to bytes already on the host instead of refusing to start - A redeploy that would destroy data asks first — every deploy recreates the container, so anything outside
/dataand the app's declared volumes is lost. AppCrane inspects the running container and warns only when state is genuinely at risk, naming what survives and what does not. It finds that state two ways: paths the image declares asVOLUMEthat nothing mounts, and paths the app has actually written (docker diff) that no mount covers — the second matters becauseVOLUMEis a floor, and every Laravel app persisting tostorage/declares none at all. Measured across eight real images, only the app with genuinely unprotected state was flagged; a warning that fires on safe apps just teaches people to click through it. The dashboard requires a second confirmation; the API and MCP require an explicitacknowledge_data_loss, failing closed — most deploys here come from an agent, and a UI-only warning would miss them - Auto-generated Dockerfiles for Node and PHP — an app with no Dockerfile is built from
node:*-alpine, or fromphp:8.3-apachewhen it ships acomposer.json. Both run non-root and honour the port AppCrane assigns. A PHP build is health-checked at its front controller and held to "answers 200" rather than to AppCrane's{status, version}body — AppCrane generates the Dockerfile, not the application, and Laravel and Symfony serve no/api/health. Nixpacks still covers everything else - 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 - TCP (layer-4) ingress — for apps that aren't HTTP at all (a forward/CONNECT proxy hands back a raw tunnel no reverse proxy can express), a platform admin can publish the container's port directly on the host, with Caddy out of the path. No SSO, no identity headers, no TLS from AppCrane — the app owns authentication completely
- Dual-plane apps —
ingress_type: 'dual'for an app that is both: an HTTP control plane still served through Caddy on container port 3000 with every control intact, plus a raw data plane on a different port inside the same container, published at0.0.0.0:<public_port>. The data-plane port may not be 3000 — that would republish the control plane unauthenticated — and health checks keep probing the control plane, the only plane that can actually answer - 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)
- GitHub App authentication for connected repos — instead of a long-lived personal access token, a platform admin creates this instance's own GitHub App from Settings → GitHub (GitHub's App-manifest flow; the App is never shared between installs), app builders install it on only the repositories they choose, and an app is attached to its installation from Applications → gh app. AppCrane then clones, pins, runs the pre-deploy commit check, reads pull requests and checks for updates with a one-hour installation token narrowed to that one repository, cached in memory only and handed to git through its environment — never a URL, config file, log line or command line. The App asks for read-only Contents, Metadata and Pull requests, so it cannot push, open pull requests or register a webhook ("Register on GitHub" is refused for an attached app; paste the payload URL on GitHub instead). If an attached app's token cannot be issued, the operation fails with the reason; it never falls back to the app's stored PAT. Apps with no installation keep using their PAT exactly as before. Webhooks: the App delivers to
POST /api/github-app/webhook, verified only by GitHub'sX-Hub-Signature-256over the raw body with the App's webhook secret (redeliveries of the sameX-GitHub-Deliveryare answered without acting twice). A push to an attached app's deploy branch starts the same auto-deploys as the per-app webhook (same auto-deploy switches and branch filter, audited asgithub-app-push-deploy); tag pushes and branch deletions deploy nothing. An uninstalled or suspended installation, or a repository removed from it, is marked rather than detached, so the attached app fails with that reason instead of silently going back to its PAT; unsuspending or re-adding the repository restores it. Apps created withCRANE_DOMAINset have webhooks active and subscribed to Push from the start; withoutCRANE_DOMAINthey stay off, since GitHub would have no public URL. For an App created before this, press Send webhook settings to GitHub in Settings → GitHub (it sets the URL, JSON content type and secret through GitHub's API), then on GitHub open the App under Settings → Developer settings → GitHub Apps → Edit, tick Active under Webhook, tick Push under Subscribe to events, and Save changes — GitHub's API cannot do those two. The 5-minute PR poller keeps running either way. Not yet covered: AppStudio coding/PR flows, Ask Claude, release notes and the issues mirror still use the PAT, and an instance config export does not carry the App's private key. - Stored GitHub credentials stay out of URLs, files and responses — deploys pass a stored personal access token, the managed-app service-account token or an installation token to git through its environment, never in the clone URL, so it is not in the process list, in error text, or in the
.git/configgit writes into each release. Earlier versions did put it in the URL, which left the token in plain text in every release directory; on boot AppCrane removes those credentials from existing release directories, AppStudio job directories and builder workspaces (only the credential part of the remote URL, never app data or volumes). App responses no longer include any encrypted credential column; the dashboard reads only thehas_*flags. Each app in the list is labelled with where its code comes from: Crane-hosted, Managed (GitHub), GitHub App, GitHub token or Public GitHub. - Zero-downtime deploys (start new, health check, swap, drain old)
- Rollback in seconds (symlink-based, keeps last 5 releases) — the previous release's container image is kept as well, so rolling back one release restarts it instead of rebuilding; change how many older images an app keeps with
image_retention(default 1,appcrane_update_appor the app's settings; 0 keeps only the running image and makes every rollback rebuild) - 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 59appcrane_*tools — agents operate the platform without ever touching curl, gh, or shell
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 bashIt 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 ADMIN_EMAIL=admin@example.com bash install.sh
# flags also work: --domain / --admin-email / --admin-name / --tls-cert / --tls-keyWhat the installer sets up — and why installing by hand isn't recommended
Everything below is done for you, idempotently, by the one command above:
- Node.js 22 + 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.
Apps are created and deployed by an agent over MCP, not from the CLI. The
installer ran crane init, which printed your dhk_admin_* key and wrote it to
the CLI config (crane config --show to read it back; crane regenerate-key on
the box if it is lost). Point Claude Code at the instance once:
claude mcp add --transport http appcrane https://<your-domain>/api/mcp \
--header "X-API-Key: dhk_admin_xxxxxxxxxxxxx" \
--header "X-Github-Token: ghp_your_github_pat"Then ask for the app in a Claude Code session:
Onboard a new app on AppCrane. Start by calling
appcrane_get_guidewithtopic="onboarding"for the playbook. It is MyApp at slugmyapp, from https://github.com/yourorg/myapp. Deploy it to sandbox and give sarah@example.com access.
The agent calls appcrane_create_app, then appcrane_deploy, then
appcrane_grant_app_access — and the app is reachable at
https://<your-domain>/myapp. The dashboard's Add Application button hands
you the same prompt, pre-filled with this instance's URL and your key.
Prefer to drive it yourself? The dashboard at https://<your-domain> creates and
deploys apps through the same routes, and the REST API underneath them
(POST /api/apps, POST /api/apps/:slug/deploy/:env) takes the same
X-API-Key. See Deploying without GitHub for the
repo-less path.
crane is the platform operator's tool, not the app owner's. It installs the
box, terminates TLS, moves instance config, recovers a lost key, reloads Caddy and
repairs drift — nine commands, listed in full below. App operations (create,
deploy, roll back, promote, secrets, logs, access) are not in the CLI: the
agent-facing surface was retired in v2.6.0 and lives on the MCP server and the
REST API. See App operations below.
crane init --email admin@example.com # First run: create the admin directly in the DB
# (--name defaults to "admin"); prints the dhk_admin_* key
crane setup-https --domain crane.example.com # Install Caddy, configure HTTPS, set up the firewall
crane update # Pull latest code from GitHub and restart AppCranecrane status # Server health and all apps
crane me # Show current user info
crane config --show # Show CLI config
crane config --url http://localhost:5001 # Set API URL
crane config --key dhk_admin_xxx # Set API keyRun on the box — it writes the database directly. Defaults to the platform admin; override to target a specific account:
crane regenerate-key # Regenerate the platform owner's key
crane regenerate-key --email you@ex.com # ...for a specific user by email
crane regenerate-key --user-id 1 # ...or by user idcrane caddy --show # Show the current generated Caddyfile
crane caddy --reload # Regenerate and reload Caddy config
crane reconcile --dry-run # Preview orphaned filesystem apps
crane reconcile # Register them into the DB and reload CaddyMove 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.jsonThe 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.
Everything an app owner does runs over MCP — the primary door, documented in
MCP (for AI agents) — or over the REST routes underneath,
which take the same X-API-Key. Both are audited identically and both enforce
the same per-app roles.
| Operation | MCP tool | REST |
|---|---|---|
| Create an app | appcrane_create_app |
POST /api/apps |
| List / inspect | appcrane_list_apps, appcrane_get_app |
GET /api/apps, GET /api/apps/:slug |
| Deploy | appcrane_deploy |
POST /api/apps/:slug/deploy/:env |
| Deploy history / log | appcrane_list_releases, appcrane_get_deploy_log |
GET /api/apps/:slug/deployments/:env, …/:id/log |
| Roll back | appcrane_rollback |
POST /api/apps/:slug/rollback/:env |
| Promote sandbox → production | appcrane_promote |
POST /api/apps/:slug/promote |
| Env vars / secrets | appcrane_set_secret, appcrane_get_secret, appcrane_reveal_secret |
GET / PUT /api/apps/:slug/env/:env, DELETE …/:key |
| Grant access | appcrane_grant_app_access |
PUT /api/apps/:slug/users |
| Health | appcrane_get_health |
GET / PUT /api/apps/:slug/health/:env |
| Backups | appcrane_run_backup_now, appcrane_get_backup_status |
POST /api/apps/:slug/backup/:env, GET /api/apps/:slug/backups |
| Runtime logs | appcrane_get_logs |
GET /api/:slug/logs/:env |
| Audit log | — (read it in the dashboard) | GET /api/audit, GET /api/:slug/audit |
Deploying to production needs the deploy.production permission on the app;
sandbox is the default everywhere. A deploy that would destroy unmounted state
refuses until it is passed acknowledge_data_loss — see the redeploy warning in
Features.
An app does not need a repo. Create it with source_type: "upload" and ship
releases as bundles (.zip, .tar.gz, .tgz):
curl -F file=@dist.zip -F env=sandbox \
-H "X-API-Key: $CRANE_KEY" \
https://<your-domain>/api/apps/myapp/deploy/uploadThe response carries artifact.sha256 — AppCrane computes it over the bytes it
received, before extraction, and records it as the release identity
(commit_hash = sha256:<digest>). Compare it against the digest you computed
locally to confirm what was deployed is what you sent. Any commit_sha you pass
is stored alongside as context and is explicitly not trusted as the identity.
Agents hold personal MCP keys (dhk_mcp_*), which are allow-listed to
/api/mcp and /api/files/staged only, so they take the same path in two
steps: POST /api/files/staged to upload the bytes, then
appcrane_deploy_artifact(slug, env, token). This is also the deploy route that
still works when a repo-based path is broken — an expired service-account PAT
returns 401 on every managed-repo write, and this one never contacts GitHub.
AppCrane is MCP-first. One claude mcp add and the agent gets 59
appcrane_* tools — list apps, deploy, roll back, set/get secrets, read
logs, manage access, scan for vulnerable dependencies, 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.).
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/
Every container is published to loopback only (127.0.0.1:<port>:3000), so
Caddy is the only way in. The exception is an app with ingress_type tcp or
dual, which additionally publishes a port at 0.0.0.0:<public_port> — outside
Caddy, and outside every control Caddy provides. A tcp app publishes container
port 3000 itself; a dual app publishes a different container port and leaves
3000 loopback-only behind Caddy. See
§6 below.
AppCrane believes X-Forwarded-For only from proxies named in TRUST_PROXY (default loopback, i.e. Caddy on the same host), so per-client rate limits, the login throttle and logged addresses see the real client; if your proxy runs on another address, set TRUST_PROXY to that address — true and hop counts are refused because they would let any client choose its own address.
- 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
- Vulnerability scanning of the apps you host, not just the platform — every deploy scans what it just shipped and records the result against that deployment. Source apps are read from their lockfiles and queried against OSV:
package-lock.json,yarn.lock(classic and Berry),pnpm-lock.yaml(9.x),composer.lock,go.sum,Cargo.lock,Gemfile.lock,poetry.lock,Pipfile.lock. Apps deployed from an image are scanned with a pinned Trivy against the resolved digest —name@sha256:…, the bytes actually running, not whatever the tag points at today. The scan reports and never blocks: a scanner that is missing, slow or wrong cannot fail your deploy. Each finding carries the version that fixes it, read from the full OSV advisory — the batch endpoint returns only advisory ids, and an advisory that cannot be fetched records the scan as an error rather than claiming no fix exists. Coverage is reported as arithmetic, not as a word: every report leads withCOVERAGE: <covered> of <rows> app/stage rows have a usable scan result (<percent>%), names how many were skipped, errored or never scanned, and lists the top reasons rows were skipped, counted, from the reason each row recorded.assurancestill reads none / partial / complete and still means exactly that — butpartialspans 1% to 99%, so it never travels without the counts beside it.
Commit verification runs alongside the build, not in front of it — the GitHub head-SHA cross-check starts as soon as the clone finishes and is awaited at the container gate, so its latency (~27 ms on a healthy GitHub, 1.5–4.5 s when it hits the retry backoff) is hidden behind docker build instead of delaying it; a failure still aborts the deploy with the same message before any container is started or stopped, and its lines still appear in the deploy log — now after the build output rather than before it.
Image builds are pinned to BuildKit (DOCKER_BUILDKIT=1) rather than inheriting whatever the host docker CLI defaults to; measured here at ~18.6 s vs ~29.0 s for a cold build of a ~126 MB Node image with identical output, and the classic builder now prints a removal notice on every use — set APPCRANE_DOCKER_BUILDKIT=0 on the AppCrane host to override.
A deployment self-updates straight from git (/api/self-update runs git fetch
git reset --hard origin/main), so the question a reviewer asks is "how do I know the source I pulled is the source you published?" Every tagged release answers it with four attached artifacts:
| Artifact | What it is |
|---|---|
appcrane-<tag>-source.tar.gz |
Reproducible git archive of the tagged tree (tracked files only) |
appcrane-sbom.cdx.json |
CycloneDX SBOM of the production dependency tree |
appcrane-sbom.spdx.json |
Same, SPDX format |
SHA256SUMS.txt |
Checksums for all of the above |
The source archive carries build provenance and an SBOM attestation signed via sigstore keyless (GitHub artifact attestations) — no long-lived signing key exists to be stolen. Verify a downloaded archive with:
gh attestation verify appcrane-<tag>-source.tar.gz --repo gitayg/appCraneDev dependencies are deliberately excluded from the SBOM — they aren't shipped to a deployment, and including them would overstate the real attack surface.
Uploaded releases get the equivalent of a commit SHA rather than being
exempt from the question. AppCrane hashes the bundle server-side, before
extraction, and stores that digest as the release identity — so "is what is
running what was reviewed?" has an answer for an app with no repo. Before
v2.53.0 it did not: commit_hash held whatever the uploader typed, or the
literal string unknown, and two unrelated bundles could claim one SHA.
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:
Maintain an existing open-source app and want to know what deeper integration buys you? SKIFF.md is the maintainer-facing guide — identity, app-defined roles, managed databases, email, health and per-tenant data, level by level, with the contract for each.
Caddy copy_headers the verified identity onto the upstream proxy request. The app reads them directly:
| Header | Value | Notes |
|---|---|---|
X-AppCrane-Auth-Mode |
authenticated | headless | bypass |
Always present on every proxied request, including ones with no identity. Read it first. |
X-AppCrane-User |
Backward-compat single identifier. Set on authenticated requests. |
|
X-AppCrane-User-Id |
numeric id (string) | Set on authenticated requests. |
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 |
Platform-wide tier, raw token. Not a per-app permission. |
X-AppCrane-App-Role |
owner | admin | user | viewer |
Per-app role — the one to gate on. An explicit app_user_roles row wins over the global-admin fallback, so a platform admin who owns the app arrives as owner, not admin. |
X-AppCrane-Is-Admin |
1 | 0 |
1 when the per-app role is admin or owner. Use it instead of comparing role strings. |
X-AppCrane-App-Roles |
comma-separated keys, e.g. approver,auditor |
The roles the app defines for itself — a different system from X-AppCrane-App-Role above. AppCrane stores and issues them; the app enforces them, and no AppCrane authz check ever reads them back. A user may hold several (a union, not a ladder). Omitted entirely when they hold none, so split(',') can't produce a phantom '' role. Section 5 below. |
Trust model: the Caddy generator wraps the request_header -X-AppCrane-* strips and the forward_auth block in a route { … } so they execute in written order — Caddy's own directive sort would otherwise run the strips after forward_auth and delete the identity it had just copied. Caddy zeroes out any client-set X-AppCrane-* headers first, then copy_headers re-injects only what /verify returned. The strips are emitted on every route that proxies an app, including headless apps and auth_bypass_paths prefixes where no forward_auth runs at all — a route that verifies nobody must not accept the caller's own X-AppCrane-Is-Admin. Header smuggling is impossible — what the app receives is guaranteed platform-issued. Caddy also strips the platform's cc_token session cookie out of Cookie before it reaches any container (v2.39.0), so an app can't read a visitor's platform session and act as them — apps must take identity from these headers, never from a cookie.
Identity does not require SSO. /api/identity/verify resolves a session from X-API-Key or from Authorization: Bearer / the cc_token cookie against identity_sessions. SSO is one way to create such a session; local password login and API keys are others. An instance with no IdP still injects the full header set for logged-in users.
Absence semantics: on an authenticated app an unverified visitor never reaches the container at all (Caddy fails closed at forward_auth and redirects to /login), so presence = trusted. Identity legitimately absent means X-AppCrane-Auth-Mode is headless (whole app opted out) or bypass (this path is in auth_bypass_paths, or the app is served on its own custom domain) — in every case the request is served with no verified identity and the app owns its own authn. No X-AppCrane-Auth-Mode at all means the request didn't come through AppCrane's proxy — i.e. direct-to-container. A custom-domain app is proxied and does get X-AppCrane-Auth-Mode: bypass.
Role ordering: none < viewer < user < admin < owner. appRole === 'admin' is a bug — it denies owners.
// Express example
const RANK = { none: 0, viewer: 1, user: 2, admin: 3, owner: 4 }
const atLeast = (appRole, min) => (RANK[appRole] ?? 0) >= RANK[min]
app.use((req, res, next) => {
const mode = req.get('X-AppCrane-Auth-Mode') // 'authenticated' | 'headless' | 'bypass'
const role = req.get('X-AppCrane-User-Role') // platform tier
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 = (mode === 'authenticated' && role)
? { id: req.get('X-AppCrane-User-Id'), email, role, appRole, isAppAdmin: atLeast(appRole, 'admin') }
: null
next()
})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.
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 and copy_headers: no identity headers, no /api/me, no cc_token (that cookie is stripped for every app regardless). The incoming X-AppCrane-* strip is not skipped — a headless route verifies nobody, so it must not let a caller supply its own identity headers either. X-AppCrane-Auth-Mode: headless still arrives, so the app can distinguish "identity is off by design" from a misconfigured proxy. 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.
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.
An app can define roles of its own — approver, auditor, reviewer — and
AppCrane hands each user's set to the app on every request. AppCrane is the
authority, the app is the enforcer: the platform stores who holds which key and
issues it, and has no opinion on what the key permits.
The two are separate systems on purpose, down to separate tables and separate
wire fields. An app-defined role never confers an AppCrane privilege, and no
AppCrane authorization check reads one — otherwise an app owner could invent a
role named admin, assign it to themselves, and author their own escalation from
a settings form. For the same reason owner, admin, user, viewer, none
and platform_admin are rejected as keys, keys must match
/^[a-z][a-z0-9_-]{0,31}$/, and an app may define at most 16 of them (which also
bounds the header's length by design rather than by discovery).
- On the server:
X-AppCrane-App-Roles, comma-separated and sorted, absent when the user holds none. A user may hold several — they are a union, so test set membership rather than equality. It is stripped off the client request and re-issued by/verifylike every other identity header. - In the browser:
GET /api/me?app=<slug>returnsapp_roles: [...]besideapp_role([]when none). - Explicit grants only. A
platform_adminholds no app-defined role unless someone granted it, and neither does the app'sowner— unlikeapp_role, there is no global-admin fallback. Holding an app role while being a plain platformuseris the normal case, not an edge case. - Managed by the app's own owner/admin tier, over
/api/apps/<slug>/app-roles(note: not/roles, which is the platform tier) or theappcrane_list_app_roles/appcrane_create_app_role/appcrane_set_user_app_rolesMCP tools. Deleting a role cascades its grants.
const appRoles = new Set((req.get('X-AppCrane-App-Roles') || '').split(',').filter(Boolean))
if (!appRoles.has('approver')) return res.status(403).json({ error: 'approver role required' })Sections 1–5 all rest on the same assumption: Caddy is in front of the app. Some
apps aren't HTTP and cannot be proxied at all — the motivating case is a
forward/CONNECT proxy, where the client opens a raw TCP connection and gets a
tunnel back, which no HTTP reverse proxy can express. For those, a platform
admin (not the app owner) can set ingress_type: 'tcp' — PUT /api/apps/<slug>
or appcrane_set_app_ingress — and AppCrane publishes the production container's
port on the host at 0.0.0.0:<public_port>, the next time the container is
recreated (a deploy, or the restart route — the publish is a docker run flag, so
nothing changes on a running container). The existing loopback publish stays and
sandbox is unaffected; this adds a door rather than moving one.
Two port ranges, and they are not the same numbers. A host port AppCrane allocates comes from a dedicated band, 31000 through 31999, so an operator firewalls one predictable block. A host port named explicitly may be anything in 1024–65535 — because clients are configured with a port by hand or by MDM, and a fleet already pointing at 8080 is not something the platform gets to overrule. Narrowing the range was never the safety property: the guards that refuse a port apply at every value (WHATWG-blocked ports, AppCrane's own listening port, any port the slot allocator could hand a container, and the partial unique index that gives one host port to one app). A port outside the auto band just needs its own firewall rule.
The container must still answer /api/health over HTTP, whatever its
ingress_type: the deploy gate polls it for 30 s and rolls the release back
without a 200 carrying status and version. So tcp ingress serves apps that
also speak HTTP on the container port — true of the motivating CONNECT proxy —
and an app speaking only a non-HTTP protocol cannot be deployed today. Note
too that the publish covers the whole container port, so every HTTP route on it,
including that health endpoint and any admin route, is exposed alongside the raw
protocol.
That door has none of the controls above, and none of the ones AppCrane
gained in v2.35–v2.41: no forward_auth, no X-AppCrane-* identity headers
(nothing injects or strips them), no per-request audit, no rate limiting, no
security headers, and no TLS terminated by AppCrane. The app owns
authentication completely. It is not auth_mode: 'headless' — a headless app
still goes through Caddy and still gets TLS, security headers and the
X-AppCrane-Auth-Mode: headless stamp.
ingress_type: 'dual' — an app with both planes (v2.45.0). Some apps are
genuinely both: an HTTP control plane (admin UI, REST API) that must keep
everything Caddy gives it, plus a raw data plane whose clients are already
pinned to a specific host port. Under tcp that was inexpressible, because both
publishes targeted the same hardcoded container port — so tcp could only
re-expose the very port Caddy was already serving. A dual app names a second
port inside its container:
control plane Caddy → 127.0.0.1:<slot port> → container:3000
data plane raw → 0.0.0.0:<public_port> → container:<data_plane_port>
The control plane is untouched — same URL, same SSO, same identity headers, same access logs. Only the data plane is undefended, and the loss table above is what it loses. Three rules make that split real:
data_plane_portmay not be 3000, and a request that sets it is refused with a 400. Port 3000 is the container's HTTP control plane, the port Caddy proxies to; publishing it raw would re-expose the app's ordinary HTTP origin with no TLS, noforward_auth, no identity headers and no audit — exactly the surface Caddy is in the path to protect, with no signal to the operator that they had done it.dualwith nodata_plane_portis refused for the same reason: the publish must target some container port, 3000 is the only other one there, and AppCrane will not guess. The runtime refuses such a row too — it emits no public-pat all rather than falling back to 3000.- Health checks follow the control plane. A
tcpapp gets a TCP handshake because it cannot answer an HTTP probe; a dual app can, so it keeps the ordinary HTTP health check on container port 3000 and its data plane is never probed. A handshake on a raw listener succeeds as long as the socket is bound, so probing the data plane would let a wedged control plane — the plane users actually reach — report healthy and a broken release go green. - The host port is unique; the container port deliberately is not. The partial
unique index on
apps(public_port)still gives one host port to one app.data_plane_porthas no such constraint and should not gain one: container network namespaces are separate, so two apps can each run a data plane on container port 8081 without ever meeting.
dual is a third enum value rather than a flag on an http app because
ingress_type is the field an operator, an audit entry and an MCP payload all read
to learn what doors an app has — a row saying http while the app published a raw
port would make that field actively wrong. It also fails safe: code that predates
dual compares === 'tcp', gets false, and takes the HTTP path, which is the
correct one for a dual app. An app that sets nothing is still http and behaves
exactly as before; a pre-v2.45.0 tcp app still publishes container port 3000 and
still gets its handshake health check.
An app on a published port can still authenticate against the platform if it
chooses to: GET /api/me?app=<slug> verifies a Bearer token or X-API-Key: dhk_*
the client supplied, and POST /api/service/* authenticates the app itself with
the APPCRANE_SERVICE_TOKEN injected into every container. Both go over the
docker bridge (CRANE_INTERNAL_URL), not through Caddy.
public_port is allocated and stored per app (never derived from the app's slot,
which can be reassigned), unique across apps, and every change — including
data_plane_port — is written to the audit log as app-ingress-change. Both
numbers are pinned: they survive a flip back to http so that flipping forward
restores the ports a client fleet is already configured for, and they read back as
null while the app is not publishing. On the way back in data_plane_port is
re-validated; public_port is not — a held number is reinstated as-is, so re-pin
it explicitly if the app has been parked on http while the platform grew.
Flipping a dual app to tcp is refused while it still holds a
data_plane_port, because tcp publishes container port 3000 and the flip would
silently repoint the same host port onto the control plane; send
data_plane_port: null in the same request to drop the data plane on purpose.
Treat publishing as the exposing act — do
not assume a host firewall is holding the port shut. A Docker publish is a DNAT
rule evaluated in FORWARD that never traverses INPUT, so a plain ufw deny
does not block it; filter in the DOCKER-USER chain or upstream of the host.
And where the platform runs behind SDP, the boundary that exists is the
perimeter: a published port is reachable by everything inside it from the moment
the container is recreated.
Switching back to http stops the publish but does not close the port: the
running container keeps the binding until it is recreated, so redeploy or restart
the app before treating the exposure as revoked. Because that port is still bound,
AppCrane keeps it reserved to that app rather than returning it to the pool —
no other app can be allocated a number a live container still holds — and releases
it automatically when the container comes back without the publish. Until then the
app reports the number as pending_port_release on every read surface, so nothing
claims the port is closed while it is open.
For a CONNECT proxy specifically: a published port is reachable by everything that
can already reach the host, so on an SDP-fronted deployment that is everyone inside
the perimeter rather than the internet. A gap in the app's proxy authentication is
therefore an unaudited egress path out of the perimeter, and AppCrane logs none
of it because the traffic never touches Caddy. The app's 407 Proxy-Authenticate
path is the security boundary — the ingress isn't.
| 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) |
Node.js 22, Express 5, SQLite, Docker, Caddy 2, SAML/OIDC/SCIM, AES-256-GCM, Commander.js, Ubuntu 22.04+
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.
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.