AppCrane
AppCrane
Self-hosted PaaS where the agent is the operator and the platform keeps the receipts.
appcrane.dev · How it compares · Deploy from Claude · MCP connector · Changelog
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 68 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?
What is actually uncommon here
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.
Against self-hosted PaaS
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 | 62 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.
Related MCP server: mcpgate
Features
Security fixes from a second Defending Code harness pass — a repository can no longer reach host files through a committed symbolic link: the deploy-time icon pickup, the generated Dockerfile, the coder and AppStudio workspace files and the codebase summary all refuse to follow one (
public/icon.pnglinked to a host file was published at the unauthenticated icon URL; a danglingDockerfilelink had the generated file written wherever it pointed). A declared volume whose host path passes through a link is refused, checked again after the old container stops. That check alone still left a window, so the host no longer walks a declared volume's path at all: its directory is created, seeded and chowned inside throwaway containers of a digest-pinned busybox (no network, only the CHOWN, DAC_OVERRIDE and FOWNER capabilities) that mount only the app'svolumes/directory; the seed content is read out of the app's image without running it (docker create+docker cpto a tar stream) and every archive entry is checked before it reachestar(absolute or..names, hard links out, and writes through a symlink the archive made are refused), and the app container gets it through one Docker volume bound to that directory withvolume-subpath, which Docker resolves itself and refuses when it leads outside. Measured on Docker 29.6.1 against a container swapping a directory for a link: before, an app's/var/lib/odoowas mounted from a host directory outside its tree 5 times in 120 deploys; after, 0 in 60 (twice). Data stays where it was and container paths are unchanged. Needs Docker Engine 26+ (API 1.45); an older daemon keeps the host-path bind and logs a warning. Images with no shell (distroless,FROM scratch) deploy with declared volumes, and a declared volume is chowned to the image's own non-rootUSER(read from the image, else 1000:1000 as before). Deleting or renaming an app removes these volumes; the data on disk stays. Seeding/datanow copies into a staging directory and renames entries in, so a link the app plants in/datais not followed. Settingcontainer_command,volume_pathsorcatalog_slugnow takes the Modify repo settings permission (owner by default) and restarting production takes Deploy to production — before, any member could set a command and restart production with its secrets. Over MCP, an app admin can no longer grant, change or remove the owner role,appcrane_rename_appis platform-admin only (an unknown tool role now denies instead of allowing), andappcrane_set_secrethonours Write production env vars. Health probes follow a redirect only on the app's own port, skill fetch-by-URL uses the shared private-address guard (https only, no redirects), runtime logs accept onlyproductionorsandbox(/api/a/logs/b-productionread appa-b's logs),frame_ancestorsis validated on MCP and again when the Caddyfile is written, and the Ask Claude audit row records who asked.Crew: people in the loop, with standing rules — when an agent needs a person, it sets a task to
input_requiredand says whose decision it is (ask:sender_owner, the default, ormy_owner); over A2A that isTASK_STATE_INPUT_REQUIREDwithmessage.metadata.ask. The question goes to that one person in the dashboard only: a Crew log entry with Approve, Approve and always allow, Reply and Decline, and a count on the Crew nav item. No e-mail: e-mail stays for first contact. Nobody else can answer it, platform admins included. A reply reaches the agent as a message from a person (actor_kind: "human", A2AROLE_USER); Approve or Reply resumes the task, Decline ends itrejected. A sender can also declare a task'sactionandscope(MCP args, A2Amessage.metadata), and the receiving person is then asked before their agent starts. Standing rules ("db-steward may migrate on sandbox", optional expiry) answer such questions without asking; they match only the exact agent, action and scope, are created from an approval or by hand on Crew → Rules & blocks, are revoked in one click, and are ended by blocking the agent. Rules apply only after blocks and first contact. Production and deletions always ask: a rule never covers a scope mentioning production or an action that deletes, nor a task whose title or question says so, and a task that leaves out action or scope matches no rule. Tasks that ran under a rule are marked with it in the Crew log. New read-only MCP toolappcrane_crew_rules; REST/api/crew/rules,/api/crew/decisions/:id,/api/crew/waiting. Every rule create, revoke and use and every decision is audited, without message bodies.Coder commits work when AppCrane runs as a non-root user. The workspace's builder branch was created after the workspace was handed to the agent, so on a non-root install its ref directory stayed owned by AppCrane and every commit the agent or the release made failed with "cannot lock ref 'HEAD'". The branch is now created first. Root installs were not affected.
Production secret values are the app owner's to reveal — revealing a production secret (
GET /api/apps/:slug/env/production?reveal=trueorappcrane_reveal_secret) now needs the app owner or a platform admin. App admins and members still manage keys and see the masked list and fingerprints; sandbox reveals are unchanged. Stored production.envfiles were already owner-only.Self-update no longer needs a C++ compiler on the server — better-sqlite3 13.x publishes no prebuilt binaries, so
npm installcompiled it from source with node-gyp and a server without a C++ toolchain could not update at all. AppCrane is back on better-sqlite3 12.11.1, which downloads a ready-made binary for Node 22 on Linux; Dependabot no longer proposes the 13.x major (no known advisories either way).Crew and the dashboard checked behind the real proxy and on a phone — Crew's A2A endpoint, streaming and first-contact e-mail were verified through AppCrane's own Caddy config and a real mail catcher. Fixes from that pass: an agent's own progress updates no longer wait out the 30-second blocking timeout; the root-path fallback only trusts a Referer from the same host; app members no longer see backup file paths on the server; and the Crew pages, the update dialog and the database cards fit a 375 px screen and read well in the light theme.
Crew: a held task stays held, and a block or refusal ends every open task — three ways past first contact are closed. Cancelling a held task no longer releases it (the receiver's agents could then read a title and body nobody approved); while a task is held the sender can only cancel it, not add messages that "Allow once" would then release unseen; and Block or Refuse now reject every open task from that agent, not only held ones, so an earlier Allow cannot outlive them.
Crew speaks A2A, and draws who talks to whom — AppCrane now runs one A2A 1.0 endpoint for the platform,
POST /a2a(JSON-RPC 2.0, SSE for streaming):SendMessage,SendStreamingMessage,GetTask,ListTasks,CancelTask,SubscribeToTask, the four push-notification config methods andGetExtendedAgentCard. It is another door into the same Crew rules — first contact held with one e-mail, blocks, rate limits, the secret scan, audit rows tagged agent, 30-day history. A hosted agent gets an agent token when it is registered (shown once, stored hashed, rotate or revoke it under Crew → My agents); it is sent asAuthorization: Bearerand acts only as that one agent, through its owner. The public card is at/.well-known/agent-card.json; each agent is addressed with the spec'stenantfield. Push notifications go only tohttpsURLs, are refused for private, loopback, link-local and metadata addresses checked at connect time (so DNS rebinding cannot slip past), never follow redirects, time out after 10 s and keep no response body. A new Map tab in Crew draws agents (coloured by owner) and the tasks between them over the last 30 days as counts only, never titles or bodies; a block appears only to the person who placed it. Security fixes to Crew: a block or refusal now survives the blocked person deleting and re-registering the agent; first-contact e-mail is capped at one per sending person per receiving person per day; an app-scoped MCP key and an agent identity get no Crew; a read-only key's inbox read no longer writes. Mutual TLS for agents is not offered yet. An app can no longer be nameda2a(ora2).Crew: agents find each other and hand off work, with their owners in the loop — a new top-level Crew page and six MCP tools (
appcrane_crew_register,_roster,_who_is_on,_send,_inbox,_reply). An agent registers once, owned by the person whose key it uses, and can never act as anyone else's agent (platform admins included). Agents of the same person talk freely. The first time another person's agent sends work to yours, the task is held and you get one e-mail; you decide in Crew → Crew log: Allow, Allow once, Refuse or Block. After that, everything is in the dashboard, never e-mail. Blocked and refused agents are rejected silently. Messages are scanned for credentials and refused if they carry one; agents are limited to 60 tasks and 300 messages an hour; task history is deleted after 30 days, while audit rows (tagged agent or human, never holding a title or message) stay. Hosted agents are registered from a pasted Agent Card; AppCrane does not fetch card URLs. Standing rules and approvals routed to people come in a later release. An app can no longer be namedcrew(orcr,cre): the dashboard owns/crew.Managed Postgres and MariaDB: a DML-only login per stage —
"database": { "engine": "postgres", "app_login": true }in deployhub.json adds<login>_appin production and in the separate sandbox database, injected asAPP_DATABASE_URL(rename it withapp_url_env). It can connect,SELECT/INSERT/UPDATE/DELETEevery table and sequence inpublic, including tables the owner creates later (ALTER DEFAULT PRIVILEGES), and cannotCREATE,ALTERorDROPanything: Postgres answerspermission denied for schema public, MariaDBCREATE command denied.DATABASE_URLstays the owner login for migrations. Same TLS and client-certificate rules as the owner (its own certificate, mounted read-only at/run/appcrane/db-app), same connection and statement limits, and backups re-apply its grants after a restore. Turning it off disables the login rather than dropping it, so older dumps still restore.Production can reach its sandbox database, never the reverse —
"sandbox_url_env": "SANDBOX_DATABASE_URL"(Postgres or MariaDB, deployhub.json databases only) gives the PRODUCTION container the sandbox database's owner URL, over TLS with a copy of the sandbox login's client certificate at/run/appcrane/db-sandbox, for pushing a masked copy of production's data down. The sandbox container gets nothing of production's: not the URL, not the login, not the certificate.pg_stat_statements, scoped to your own database — measured on Postgres 16: the extension's own view shows any login every other database's statements (query text hidden, but database, login, call counts, timings and row counts visible), and an app login cannot install it anyway (
Must be superuser). So AppCrane installs it for an app that asks ("extensions": ["pg_stat_statements"]) in anappcrane_statsschema, revokes the raw view from everyone, and exposesappcrane_stats.app_stat_statements(): this database's statements, run by this stage's own logins, nothing else. The module itself is a platform switch (Managed databases → Database limits, off by default); turning it on or off recreates the shared Postgres once (data kept, open connections drop and reconnect). AppCrane's own role statements are kept out of it, so no generated password lands in its query-text file.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, 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 workPer-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 beManaged-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. A push can also delete files:appcrane_push_to_managed_apptakes adeletionslist of repo-relative paths applied to the same commit as the writes, so one call is still one commit (sendfiles: []for a pure deletion, and read the removed paths back from the response'sdeleted). Deleting a path the branch does not have is refused by name instead of committed as a no-op — at the git plumbing level it is silent — no path may be written and deleted in the same push, and a push that would leave the repository with no files at all is refused. The.env*rule below covers deletions as well as writes. GitHub-backed managed apps do not get this: GitHub's contents API cannot express a deletion, so one is refused rather than quietly dropped. 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. A coder session works for these apps and ONLY these apps: the workspace is cloned from the local repository with no credential of any kind, and a GitHub-backed app is refused with a named reason rather than given a session whose work it could never release. Nothing in AppCrane pushes a coder workspace to a GitHub remote any more. The session has a UI: a Coder button appears in an app's top bar on the Sandbox tab (the coder's work is reviewed and released there; switching to Production closes it), opening a chat docked beside the running app rather than over it, with the element picker feeding what you point at into the prompt. On apps where it cannot run it is muted and explains what it would take. Its Changes tab lists what the agent touched, file by file with diffs, and you release only what you tick. Right-clicking an environment pill offers Redeploy, and Promote to Production on sandbox — both confirm first, and both appear only for the people the server would accept: the app's owner, or a platform admin. Each message picks its own model from a list the server serves (so the picker cannot drift from what is allowed), and you can keep typing while a turn runs: follow-ups queue server-side, show as pending, survive a reload, and can be cancelled — stopping a turn clears them rather than applying instructions written for work that was abandoned. Releasing is a choice, not a sweep —GET /api/coder/:slug/session/:id/changeslists what the agent changed, with a diff per file, andPOST .../releasetakes only the paths you name, pushes them to the local repository as one commit, and lets the ordinary auto-deploy carry them to sandbox. A file the agent deleted is released as a deletion, so a rename is one commit rather than a stale file left behind. Releasing is gated on app admin, the same bar as any other write to a managed repository, so a person who may talk to the agent does not automatically ship its work. When the idle container is evicted, anything not yet released is committed toagent/<userId>/<sessionId>first — a safety net, never a release: that branch is refused if it matches the branch a push would deploy, so unreviewed work cannot reach sandbox by being walked away from. The agent's conversation is kept too, so returning to a session resumes it instead of starting over, across idle evictions and AppCrane restarts alike. 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 platform 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 startA 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 themThe global admin role is retired — a user with the platform-wide
adminrole was admin on every app without anyone assigning them, which contradicts the rule that app access is granted per app. Existing holders are now plainusers: they keep exactly the apps they were explicitly assigned to, at that app role, and lose the implicit access to every other app. Nobody is promoted. The role can no longer be set:PUT /api/users/:id/roleandPOST /api/usersanswer 400, and the database refuses it too (migration 105). Platform admin is unchanged, and so is the per-appadminrole — app admins keep everything they had. Anything that used to be open to "admin or platform admin" is now platform admin only.Choose whether users see Sandbox — each app has a "Show Sandbox to users" setting (on by default, so nothing changes until someone turns it off). Off, members and viewers are no longer offered Sandbox in the launcher, the app frame's Production/Sandbox switch or the Applications page links; the owner, app admins and platform admins still get it, marked "hidden from users". It is visibility only: sandbox still deploys and the sandbox URL still works for anyone with access. Only the app owner or a platform admin can change it, from the app page or with
show_sandboxonPUT /api/apps/:slugandappcrane_update_app; the app list and detail responses carryshow_sandboxand a per-callersandbox_visiblecomputed on the server.AppCrane no longer runs git or npm inside a workspace the agent can write — v2.97.8 checked a coder workspace's
.git/configagainst an allowlist and then ran host git, but the agent's container keeps running between those two steps, so a loop swapping the config (afilter.<name>.cleandriver plus* filter=<name>in.gitattributes) could slip in after the check and have the host run a command as the AppCrane user. Measured against v2.103.1: a planted command reached a host-only path within 4 to 40 attempts in each of six runs. Every workspace git now runs inside a container:docker execinto the session's own container as itsstudiouser (uid 100) when it is running, otherwise a throwaway helper from the studio image with no network, every capability dropped, no-new-privileges, a read-only root filesystem, pid/memory/CPU limits and only the workspace mounted. A command the agent plants therefore runs as the agent, in its own sandbox. The allowlist and the hardened-coptions stay as a second layer. The eviction rescue no longer pushes from the workspace: the helper writes a git bundle of the new commits, and AppCrane unbundles it into the managed repository on the host (a bundle carries objects and refs, never config or hooks) and movesagent/<user>/<session>only by fast-forward. The Changes tab costs two container round trips however many files changed (50 changed files: 240 ms by exec, 490 ms in a helper; 700 ms with the old host git). AppStudio had the same problem with no check at all: after the coding container exited, the host rangit add/commit, a frontendnpm install/npm run build(the agent'spackage.jsonscripts) andgit push originwith the GitHub token. Those now run in helpers too (npm install with registry access, the build without network, a per-app npm cache underDATA_DIR/appstudio-npm-cache), thedeployhub.jsonversion sync runs in a helper, and the push leaves as a bundle that AppCrane loads into a fresh repository it owns and pushes to the app's GitHub URL, so the token never enters a container or meets a config file the agent wrote. The codebase summary's git reads of a coder workspace run in the container too.Times in the dashboard are right in every timezone — the server stores times as UTC without a zone marker, and the browser read them as local time, so a deploy made a moment ago showed as "4h ago" for someone four hours behind UTC (reported through the request queue). The admin pages now read them as UTC everywhere.
The audit log shows whether an agent or a person acted — each entry's actor carries an AGENT or HUMAN tag, from the actor kind AppCrane has recorded since v2.28.0.
AppCrane no longer reads a workspace file on the host — releasing a coder session's changes read each selected file on the host (check that it is a regular file, then read it), and so did the codebase summary for its key files. The agent's container keeps running between those two steps, so swapping the file — or a parent directory — for a symlink in between made AppCrane read any file it can reach, such as
DATA_DIR/.envordeployhub.db, and commit it into the app's repository or put it in the summary prompt. Measured against v2.104.0 with a container swapping a file and a symlink to a host-only file: 199 of 253,317 release reads in 20 s returned the host file, the first after 6,071 attempts. File contents now leave a workspace only through a fixed reader that runs inside the session's container (or a confined helper), one container round trip per release or summary: it opens files without following a final symlink, checks that the opened file really is<workspace>/<path>(which catches a swapped parent directory), and reports a symlink as a symlink. A release that names a symlink is refused, as before (releases never carried symlinks). Per-file and per-release limits: 5 MiB per file (unchanged) and 48 MiB per release; a larger selection is refused with a message to release it in smaller groups. The summary now reads key files from the commit it describes (git's object store), not from uncommitted edits. Against real Docker with the same swap loop, 240 reads by exec and through a helper never returned the host file.A stuck deployment no longer blocks AppCrane updates — self-update and restart still refuse while a deploy is in progress, but a deployment that has been pending, building or deploying for more than 60 minutes (
SELF_UPDATE_STALE_DEPLOY_MINUTES) is treated as stale and no longer blocks; nothing is changed on those rows. The refusal now lists each deployment it is waiting on (app, environment, status, start time, age) and the stale ones it ignored. In the dashboard, the update shows an "Update waiting on N deployment(s)" dialog: Wait retries every 30 s and starts the update once they finish, Force update interrupts them (those apps will need redeploying). A connection dropped while the update runs now shows "updating — reconnecting" and waits for the new version instead of reporting a failure. Rows left in flight by a previous process are still marked failed at boot, as before.Encrypt MariaDB data at rest (platform setting, off by default) — a platform admin can turn on MariaDB's own data-at-rest encryption for the shared managed MariaDB (Dashboard → Database limits, or
PUT /api/managed-db/encryption). AppCrane generates one 256-bit key, stores it only encrypted withENCRYPTION_KEYindeployhub.db, and writes the key file for thefile_key_managementplugin underDATA_DIR/managed-db/_tde/mariadb(0700, outside the database's data volume, mounted read-only). The key file is itself encrypted with a secret derived fromENCRYPTION_KEYthat reaches the container only through its start environment, so a copy ofDATA_DIRalone cannot decrypt the data. The server restarts once (connections drop once) with InnoDB tables, the redo log, temporary tablespaces and files, and new Aria tables encrypted (AES-CTR), and four background threads encrypt existing tables; the card shows progress frominformation_schema.INNODB_TABLESPACES_ENCRYPTION. Turning it off needs confirmation, restarts with encryption off and decrypts in the background with the key still loaded; the key can be unloaded (and optionally deleted) only when the running server reports no encrypted InnoDB tablespace andaria_chkfinds no encrypted Aria table — AppCrane rebuilds encrypted Aria tables itself, including the system tables of a server that started encrypted, and if MariaDB will not start without the key, it loads the key again. The key travels in every backup that carriesdeployhub.dband is rewritten to disk at boot and before MariaDB starts.ENCRYPTION_KEYis now required to read MariaDB data: lose it and the encrypted data is lost. The key is on the same server as the data, so this protects copied database files, dumps of the data volume and stray snapshots, not someone holding the whole disk. Postgres has no community equivalent. Pre-existing Aria tables in MariaDB's ownmysql.*schema stay unencrypted; app Aria/MyISAM tables are converted to InnoDB (see below). The Aria transaction log cannot be encrypted by MariaDB; it is kept short (see below) but its current file stays plaintext.With MariaDB encryption on, app tables leave Aria and MyISAM, and the Aria log is kept short — MariaDB cannot encrypt its Aria transaction log (
aria_log.*, MDEV-8587), and Aria/MyISAM app tables were left outside the encrypted InnoDB tablespaces. While encryption is on, every ensure of the shared MariaDB (each deploy or restart that uses it, and turning encryption on) now converts each Aria or MyISAM table in an AppCrane-provisioned database to InnoDB withALTER TABLE ... ENGINE=InnoDB, one table at a time, never touchingmysql,sys,performance_schemaorinformation_schema. Each converted table is logged; a table InnoDB refuses (for exampleRow size too large) is left as it is, logged with the engine's error and listed on the encryption card, and is tried again on the next ensure. A table whose name needs quoting is listed rather than converted. The server is also started witharia_log_purge_type=immediate,aria_checkpoint_interval=30andaria_log_file_size=8388608(8 MiB, the minimum; the default is 1 GiB), so old Aria log files are deleted at the next checkpoint once no longer needed; an already-encrypted server is recreated once to pick these up. The card saysAria log: not encryptable by MariaDB; kept short (purged at each checkpoint, every 30 s). What remains plaintext: the current Aria log file (up to 8 MiB) and what it holds since it was opened — mainly changes to MariaDB's own system tables (logins, password hashes, grants), and rows written to an Aria table before it was converted, until the file rolls over.Requests filed from inside an app reach AppCrane — an app's own "send feedback" button posts to
/api/enhancementsfrom its page, and the root-path fallback (which sends an app page's/api/...calls into that app) redirected it into the app, which answered 404./api/enhancementsnow passes through to AppCrane like/api/meand/api/notices.The local nightly backup includes managed databases —
managed-db/<engine>/<db>.dumpfor every app database, kept with the other 7 nightly copies, so a host without off-site backup still has a scheduled copy of every app's database. The free-space check still refuses a run that would not fit.Managed databases are backed up, and an app's owner can restore them — nothing copied a managed database before: the config export, the local and off-site backups and per-app backups all skipped
DATA_DIR/managed-db. Each database is now dumped logically inside its engine container with AppCrane's admin path (no password in any command line):pg_dump --format=custom,mariadb-dump --single-transaction --routines --triggers,mongodump --archive --gzip, and the Redis RDB after aBGSAVE, one database per dump and streamed to disk. A per-app backup (app page → Backups, orPOST /api/apps/:slug/backup/:env) now includes the databases that environment uses, listed with their size; restoring it restores them too unless you untick the box (include_database: false). Restoring production — and a sandbox backup whose database is the one production shares — is the app owner's or a platform admin's call. The load runs as the app's own database login, never as the superuser, so code inside a dump (a function in a CHECK constraint, a trigger, a DEFINER) can do no more than the app could already do; afterwards the login's TLS/certificate requirement, connection limit and grants are put back. A backup can only be restored into the app and database it was taken from. The full export and the nightly off-site upload carry every database undermanaged-db/<engine>/<db>.dump, and a platform admin can restore one app's database from such an archive (POST /api/settings/backup/restore-database). The local nightly archive still holds onlydeployhub.dband.env. There is no point-in-time recovery.Managed databases speak TLS, and a custom app's database needs that app's own certificate — apps cannot be told apart by network address (every connection to a managed database arrives from the Docker bridge gateway), so a sibling app that learned a password could log in with it. Every managed server now speaks TLS with a certificate from AppCrane's own CA (generated once, key stored encrypted). A database declared in
deployhub.json(production and its sandbox) now requires TLS and the app's own client certificate (CN = its login, renewed automatically before expiry, mounted read-only into that one container at/run/appcrane/db/) and the password; plain-text and wrong-certificate connections are refused by the engine. Existing ones are converted on the app's next deploy or restart — update the connection code then (node-postgres and the mongodb driver read the injected URL as is; mysql2 and ioredis need the three files passed explicitly, see the onboarding guide). Catalogue apps keep working in plain text; TLS is offered to them. Mongo logins of this kind authenticate by certificate alone, and Redis checks that a certificate is AppCrane-issued rather than which app it names. Every Postgres and MariaDB login is also capped at 25 connections with a 5-minute statement timeout, Postgresmax_connectionsgoes from 100 to 300 and its container's memory cap from 512 MB to 1024 MB to match (measured: ~2.4 MB per open session) — the limits are platform-admin settings under Dashboard → Managed Databases —, and a login that fails more than 5 times in 10 minutes is written to the audit log and e-mailed to platform admins, naming the login and the database it tried. The first deploy or restart after upgrading recreates each shared database server once, on its existing data, to turn TLS on.Each app's Redis accepts only that app's certificate, and failed Redis logins are alerted — every TLS Redis trusted AppCrane's one CA, so app B's client certificate plus app A's Redis password opened app A's Redis (Redis checks that a client certificate chains to its CA, not which login it names). Each per-app Redis now has its own small CA (generated once, key stored encrypted) and trusts only that: it signs that app's Redis certificate and AppCrane's own admin certificate, so another app's certificate fails the TLS handshake (measured:
certificate verify failed). The app getsredis-client.crt/.key/.pemnext to its existing files in/run/appcrane/db/, withREDIS_TLS_CA_FILE,REDIS_TLS_CERT_FILEandREDIS_TLS_KEY_FILE(andDB_*_FILEpointing at them for a Redis database); Postgres, MariaDB and Mongo keepclient.*. Existing TLS Redis instances are converted on the app's next deploy or restart: the instance restarts trusting only its own CA, on its existing data. Failed Redis logins, which Redis records only in itsACL LOG, are now read every 3 minutes by entry id and count (nothing counted twice; the log is not reset) and go through the same threshold, audit-log row and admin e-mail as the other engines, naming the username and the target instance.Dropping one of an app's databases no longer breaks its others — an app's client certificates for every engine live in one directory, and deprovisioning any one engine's database deleted the whole directory, so dropping an app's Redis removed its Postgres/MariaDB/Mongo
client.*files (and vice versa) and the remaining database refused the app until its next deploy or restart. Deprovisioning now removes only that database's files:redis-client.*for a Redis,client.*for Postgres/MariaDB/Mongo (one certificate per login, removed only when the login's last non-Redis database goes). The sharedca.crtand the directory go with the last certificate; nothing is followed through a symlink, and a file AppCrane did not write keeps the directory in place.Sandbox never receives production's database credentials — in v2.100.0 a database declared in
deployhub.jsonwas shared by sandbox and production unless Separate sandbox database was on, and members who may not deploy production can still deploy sandbox code, so one of them could ship a few lines that read or rewrote production's data. A deployhub.json database is now always separate per stage: sandbox gets its owncrane_a<id>_sandboxdatabase on its next deploy or restart. The setting still decides for catalogue apps, whose code members do not write.Custom apps can declare a managed database in
deployhub.json— only catalogue apps had database credentials injected, and since no API returns the password, a custom app could have a database provisioned and no way to connect to it. Add"database": { "engine": "postgres" }(ormariadb,mongo,redis) and the deploy provisions the app's database if it does not exist yet, then injectsDATABASE_URL(MONGODB_URIfor mongo,REDIS_URLfor redis).url_envrenames the URL variable (nulldrops it) andenvmapshost,port,name,userandpasswordonto discrete variables. The block is validated before the build: an unknown engine or key, an invalid variable name, or a name AppCrane sets itself (PORT,NODE_ENV,DATA_DIR,FE_PORT,APP_BASE_PATH,CRANE_URL,CRANE_INTERNAL_URL,APPCRANE_*) fails the deploy naming the field. An env var the app sets itself still wins, a catalogue entry's ownneedswin over the block (the deploy log says it was ignored), a restart re-injects from the live release's manifest, and the log shows variable names only. By default the database is per app, shared by sandbox and production. A per-app Separate sandbox database setting (off by default; migration 107) gives sandbox its own: production keeps its existing database, login and password untouched, and the next sandbox deploy or restart provisions an emptycrane_a<id>_sandboxdatabase with its own login for each engine the app needs (catalogue apps too) and injects it under the same variable names. Turning it off sends sandbox back to the shared database on its next deploy or restart and does not drop the sandbox database; the owner or a platform admin drops it from the app's new Managed databases card (DELETE /api/apps/:slug/database?engine=…&env=sandbox&confirm=<slug>). The owner, an app admin or a platform admin changes it from that card,separate_sandbox_dbonPUT /api/apps/:slug, orappcrane_update_app;GET /api/apps/:slug/databaseandappcrane_list_databasesreport each database'senv,by_envand the setting, never a password.The request-completed email says what was done — whoever marks a request shipped, done or won't-do can add a short note (optional, plain text, up to 2000 characters): the Mark shipped button in Requests asks for it,
PUT /api/enhancements/:id/bucketandPOST /api/enhancements/:id/set-statustakenote, andappcrane_set_request_statushas anoteargument. The note is stored on the request (migration 106,done_note), added to the requester's email under "What was done:" ("Note:" for won't-do) and to the platform-admin copy, and shown to the requester in My Requests. Control characters other than newlines are stripped; it never goes into the subject. No note, and the email reads as before.Security fixes from a scan with Anthropic's open-source Defending Code Reference Harness — Host git in a coder workspace no longer runs commands the agent planted: the workspace (its
.git/included) is the agent's to rewrite, andsafe.directorymade the host honourcore.fsmonitor, filter drivers,include.pathand.git/commondir. Every host git call there now checks the workspace config against an allowlist first and refuses anything else, and runs with hooks, fsmonitor and network transports switched off; the rescue push goes to the managed repo by path, not to the agent-writableorigin. Coder sessions take messages, attachments, resumes and follow-up edits only from their owner: every turn runs on the owner's personal Claude token, and any app member could spend it. App admins may still stop a colleague's turn. App slugs that a platform path starts with (a,api,login,mcp…) or that end in-sandboxare refused on create and rename: Caddy sorts the platform's catch-all route last, so an app namedareceived every/api/...request, cookies and API keys included. Existing apps are not re-checked. Issuing an MCP key for another user is platform-admin only, like resetting their password; the key authenticates as that user, so a global admin could become a platform admin.Old images no longer pile up after app deletes and failed deploys — images were pruned only after a deploy passed its health check, for apps that still exist. Deleting an app now removes its images: the ones it built, and the pulled digests only it deployed (no
-f; a digest another app deployed or names is kept, so a shared repository likeodoo:19keeps the other apps' rollback images — v2.97.7, the first cut swept the whole repository; v2.97.8 compares image IDs rather than ref text, sinceodoo@sha256:xanddocker.io/library/odoo@sha256:xare one image, and finds built images by theirappcrane-<slug>-<env>repository as well as labels, which any pulled image can carry; the same release moves the admin SPA's build-timesource-map-jsto 1.2.2, GHSA-68fv-2mgg-jv7q), and a failed deploy removes the image it built, without-f, so Docker refuses if any container still uses it. A cached image the attempt reused, such as a rollback target, is never removed. Before, the failed image was the newest, so the next keep-N prune could keep it and delete the last good one.Saving resource limits on the app page works — the Resource Limits card sent the values nested (
resource_limits: {…}) whilePUT /api/apps/:slugread only top-levelmax_ram_mb/max_cpu_percent, so nothing was saved, the card said "saved", and a non-platform-admin was never told limits are platform-admin only. The card now sends the fields the server reads, the server also accepts the nested form, and the button says Save (limits apply on the next deploy or restart, as before).Sign-ins are audited, and so are Ask Claude, coder messages and file uploads — no login of any kind was written to the audit log. Password, OIDC and SAML sign-ins now record
loginorlogin-failedwith the method, the reason (unknown_user,bad_password,deactivated,no_account, an SSO-link refusal) and the client IP, never a password or token. Ask Claude (ask), coder dispatch (coder-dispatch) and file staging (file-stage) are audited too; a question or prompt is recorded by its length only, since it can contain anything.proxy-addris 2.0.8 (GHSA-jqcg-44mw-7w3h: a trust subnet written as a short IPv4-mapped IPv6 prefix trusted every client's X-Forwarded-For).Security audit fixes: passwords, production data, logs, tenant purge and SSO linking — the medium findings of the same audit. Resetting another user's password (
PUT /api/users/:id/password) is now platform-admin only; a global admin could reset a platform admin's password and sign in as them. Destructive production operations are the app owner's or a platform admin's call: deleting a production env var, dropping the app's managed database (shared by sandbox and production), restoring a production backup, and copying production data into sandbox. Runtime logs (REST/api/<slug>/logs/<env>and MCPappcrane_get_logs, which skipped the check entirely) are visible to the app owner or a platform admin only. Tenant purge can no longer be redirected into another app by a symbolic link planted in one app'stenants/: when the app has a container, the delete runs inside a throwaway helper container (no network,no-new-privileges) that mounts only that app's data, so a link resolves inside the helper; with no container, the host path is walked withlstatand nothing is deleted if any part is a link (v2.97.3: the first fix checked and then deleted on the host, which a running app could race). SSO linking (OIDC and SAML, one shared rule inservices/ssoLink.js): an account already linked to one SSO identity is never moved to another, and OIDC links an existing account by e-mail, or updates its stored e-mail, only when the IdP sendsemail_verified: true. For an IdP that never sends the claim (e.g. Microsoft Entra), setOIDC_ALLOW_UNVERIFIED_EMAIL_LINK=1to allow first-time linking by e-mail.Security audit fixes: production deploys, member lists, and writes into /data — from a full audit (squash plus a unit515 review) of v2.97.0. Production deploys now require the
deploy.productionpermission on every path, not onlyPOST /deploy/:env: the upload deploy,appcrane_deploy_artifact,appcrane_push_staged_file, and the webhook'sauto_deploy_prodswitch (turning it on, or changingbranch_filterwhile it is on, which picks the code production follows) let any app member put code into production. Turning it off stays open to members. Replacing an app's member list (PUT /api/apps/:slug/users) needs the owner or a platform admin, asPUT /:slug/rolesdoes, and refuses to remove every owner; any member could evict the owners.appcrane_cpno longer follows symbolic links the app planted in its/data: whenever the app has a container, running or stopped, it streams the file in withdocker cp(links resolve inside the container); only for an app with no container at all does it write on the host, walking the path and refusing any link; if Docker cannot say which, nothing is written. Before, an app admin got a file write anywhere the AppCrane process could write. Path segments starting with-are refused, andtargets--before the path.A restart keeps a multitenant app's tenant settings —
POST /api/apps/:slug/restart/:envrebuilds the container's environment from scratch and never re-addedAPPCRANE_TENANT_ROOTorAPPCRANE_TENANT_QUOTA_BYTES, which a deploy derives fromdeployhub.json, so a restarted multitenant app lost its per-user quota until the next deploy. Restart now carries exactly those two over from the container it replaces (falling back to the tenant root for an app marked multitenant), and gives the app its identity-signing secret like a deploy does.Identity headers are signed, so an app can reject identity that did not come through AppCrane — the proxy strips client-sent
X-AppCrane-*headers, but two routes skip the proxy: a rawtcp/dualport, and on Docker Desktop (measured; blocked on Linux) a sibling container reaching an app's loopback port viahost.docker.internal, which would let one compromised app act as any user of another./api/identity/verifynow addsX-AppCrane-Identity-TsandX-AppCrane-Identity-Sig(HMAC-SHA256, a secret per app and environment, stored encrypted, injected asAPPCRANE_IDENTITY_SECRETon every deploy and restart, so a sandbox cannot sign identity its production believes), andappcrane-tenant1.2.0 addsverifyIdentity(req);tenantKey()/tenantDb()verify automatically when the secret is set. Apps pick up the secret on their next deploy, and apps that do not verify behave as before.A UDP data plane survives a round trip through http — an app switched away from
dualkeeps its UDP setting, and reads now report it (data_plane_protocol: 'udp') instead of the effectivetcp. Before, a client that switched the app back todualand sent back what it had read silently turned a UDP relay into a TCP one. What a non-dual app publishes is unchanged: TCP.A dual app can publish its data plane over UDP —
data_plane_protocol: 'udp'(RESTPUT /api/apps/:slugorappcrane_set_app_ingress, platform admins only) publishes adualapp's raw data plane as<host>:<port>/udp, for things like a WireGuard relay. The HTTP control plane and its health check stay on TCP through Caddy. Onlydualcan be UDP: atcpapp's published port is its HTTP origin. Every app reportsdata_plane_protocol, and sending back the reported value is accepted rather than refused. The exposure warning and the onboarding guide give a-p udpDOCKER-USER rule for a UDP data plane, since a TCP rule does not cover it. Ingress drift detection compares the protocol too.Dependencies brought current, six CVEs closed — multer 2.4.0 (CVE-2026-88932) and nodemailer 10 (five CVEs in 9.1.1), plus chalk 6, better-sqlite3 13 (SQLite 3.53.4) and newer pinned versions of the CI's OSV-Scanner, SBOM and SARIF-upload actions. nodemailer 10 needs Node 20+, which AppCrane already requires; its ES-module build keeps the default export AppCrane calls. better-sqlite3 13 opens an existing WAL database at
synchronous = NORMALexactly as 11 did, so the durability behaviour documented in v2.93.2 is unchanged.npm audit --omit=dev: 0 vulnerabilities.Per-tenant databases upgrade themselves, one file at a time — with one SQLite file per user, the trap is schema drift across thousands of files.
appcrane-tenant1.1.0 takestenantDb(req, { migrations: [step1, step2, ...] })and upgrades each user's file lazily, the first time it is opened after a deploy, keyed onPRAGMA user_version: every pending step runs in one IMMEDIATE transaction, so a failing step leaves the file on its old version, and two requests opening the same file at once upgrade it once. A file already ahead of the list (code rolled back) is opened unchanged. The multitenant-notes example uses it.A clean app no longer asks for a data-loss acknowledgement on a busy host — the redeploy guard gave
docker diff10 seconds. On a host with the containerd snapshotter and two dozen containers it took 4-10 s on containers with zero changed paths, so the slow runs were killed, reported as "Command failed", and every redeploy asked for an acknowledgement. The limit is now 60 seconds, and a real timeout says "docker diff did not finish within 60 s". An unreadable container is still reported as at-risk.Being an app's owner always means having access to it — access used to be recorded twice: membership in
app_users, the tier inapp_user_roles. They drifted. Older upgrade steps (migrations 042/048) and the boot-time owner backfill made app creators owners without a membership row, so an owner, shown as owner in the Users dialog, was refused their own app's env vars and data with "Admin access does not include app data/env". Access now lives in one place,app_user_roles: migration 101 gives every bare member the roleuser, drops leftovernonerows, and turnsapp_usersinto a read-only view of the role table, so every reader keeps working and any write to it fails. The MCP access check no longer counts a storednoneas access, and replacing an app's member list (PUT /api/apps/:slug/users) keeps each remaining member's tier and deletes a removed member's tenant data. Platform admins still need to be assigned to an app to read its secrets.The Request button is on every app — in both places an app opens: a Launch or sidebar tab, and Manage → "open" (where owners and admins open apps; there Jobs and Bug were hidden too). It was shown only for apps with a GitHub URL, a leftover from when every request was also opened as a GitHub issue. Apps whose code AppCrane keeps itself have no GitHub URL, so the button was missing on exactly the apps that have the Coder. Requests are stored by AppCrane either way and mirrored to GitHub only when the app has a repository there.
AppCrane's own What's New lists every release again — the upgrade preview and the post-login dialog took their notes from
CHANGELOG.md, which stopped at 2.78.1, so any newer release showed only "A new version of AppCrane is available." Notes now come from the release commit subjects (vX.Y.Z: ...) in the install's own git checkout, aftergit fetch origin: the remote self-update already uses, with no call to api.github.com and no limit on how far back. An install that is not a git checkout falls back to the GitHub commits API.CHANGELOG.mdis no longer read.Removing someone from a multitenant app deletes their data, whichever way they were removed — per-tenant data (the user's SQLite database and storage folder, production and sandbox) was purged only when access was revoked through the MCP tool. Removing the user in the dashboard, deleting the user, and removal through a SCIM group all took access away and left the data on disk, although this README promised purge-on-revoke. All four paths now purge (
purgeRevokedTenantsinserver/services/tenants.js), with a test per path intest/tenant-purge-on-revoke.test.js. The managed-databases entry also no longer says databases are provisioned per tenant: the schema has the dimension, but nothing provisions one yet.What's New shows when you open an app from Launch — opening an app from Launch or the sidebar (the tabbed view) now asks whether production shipped versions you have not seen, and shows them in the What's New dialog, for every user with access to the app, not only owners. Before this only the older full-screen frame asked, so most users never saw it. A first visit to an app still records the current version silently, and only production releases count.
A coder release is followed to the end — after Release, the Changes tab and the chat report how the sandbox deploy it started ended: live (with the version), rolled back, or failed. A failed deploy is handed back to the coder automatically with the failing log lines, asking it to reproduce and fix the build without releasing; you review the fix and release it. Once per released commit, so a build the coder cannot fix never loops. Before this a failed deploy was silent, and the panel said "Deploying" indefinitely.
Anonymous daily install check-in (opt-out) — once a day each AppCrane server sends
https://ping.appcrane.dev/v1/checkinone small JSON document: a random install id (generated on first boot, stored inDATA_DIR/install-id, tied to nothing), the AppCrane version, Node version, OS and CPU architecture, and three counts: registered apps, apps with a live deploy, and active users. No names, domains, emails or app contents, and the receiver does not store IP addresses. It exists to learn how many installs there are and which versions and runtimes they run. Turn it off withAPPCRANE_CHECKIN=off(orDO_NOT_TRACK=1); every boot logs whether it is on and what it sends.APPCRANE_CHECKIN_URLpoints it elsewhere.The database no longer stalls AppCrane when the disk is busy — SQLite's WAL checkpoint used to run inside whichever request's commit crossed the threshold, fsync included, on the event loop. Under a throttled, contended disk that meant 3.5-5.9 s stalls every few seconds; switching to
synchronous = NORMALdid not help. The server now checkpoints from a worker thread every 2 s (PASSIVE, so writes are never locked out), and the worst commit measured 1.9-2.4 ms. The durability setting is unchanged: AppCrane runs better-sqlite3's WAL default,synchronous = NORMAL, and measured no difference atFULL. ThecraneCLI keeps SQLite's default. MariaDB setup also checks that the root password on record opens the server before using it, and refuses a server it did not start with a message naming the container, the likely cause and the fix, instead of a bare "Access denied" later.The Coder button lives on the Sandbox tab — it is shown while you review the sandbox, not on production, and switching to Production closes the panel (a running turn carries on and is there when you return). Five tests that failed now and then on CI were traced and fixed in the tests themselves; timing checks that must not share a disk with the parallel suite live in
test/serial/and run as their own CI step (npm run test:serial).Self-update brings its own Node when it cannot install one — a host below the Node floor used to refuse every update unless AppCrane ran as root, because upgrading Node meant
apt-get. Now, when the system package manager is out of reach (not root, no apt, or an nvm/asdf Node), self-update downloads the official Node build from nodejs.org into<appcrane>/.runtime/, checks the GPG signature on the release'sSHASUMS256.txtwithgpgvagainst Node's release keys (shipped asinfra/node-release-keys.gpg, refreshed byscripts/update-node-release-keys.shfromnodejs/release-keys), checks the tarball's SHA-256 against that signed list, runs it to confirm the major and that it works on this host's libc, and uses it for the rest of the update. A host with nogpgv(it comes withapt) is refused unlessAPPCRANE_NODE_ALLOW_UNSIGNED=1accepts the checksum alone.scripts/safe-boot.shputs it first on PATH at every start while the system Node is below the floor, and steps aside once the system Node meets it. A host already stuck below the floor is repaired the same way at its next boot, without root. It needs the service to start throughscripts/safe-boot.sh(install.sh's unit does) and Linux on x64 or arm64; otherwise the update still refuses, and says which of the two is missing.Coder modes, and files in the chat — each message picks a mode beside the model: Auto (reads, edits and runs commands without asking — what the coder always did), Edits only (edits files, shell commands are refused) or Plan (reads the code and proposes a plan, changes nothing). Each maps to one Claude Code
--permission-mode; the list is served byGET /api/coder/modelsand enforced as exact strings at the route and again where the command is built. A message can also carry files: paste a screenshot or a copied file, drop it on the composer, or use Attach (10 MB each, 10 per message). They are uploaded toPOST /api/coder/:slug/session/:id/attachments, stored per session on the host under an opaque id — never in the workspace, so they are never listed as changes or released — copied into the container when the turn runs, and named to the agent by path, where Claude Code's Read tool reads text, code, images and PDFs. A queued follow-up keeps its own mode and files. Per-action approval from the panel (a Manual mode) is not built yet.The coder can run shell commands and keep its conversation — inside its container the coder runs as the
studiouser, but~/.claudewas created by Docker as root (the transcripts are mounted beneath it) and every container start handed the saved conversation to a different user. Claude could not create the state its Bash tool needs, so the agent reported "Bash is blocked by an environment permission issue", and it could neither read nor extend its own conversation after a restart. The studio image now creates~/.claudeforstudiowith a pinned uid/gid, and the transcripts are owned by that user, repaired on the next start. The image version is bumped, so each host rebuilds it once, on the next coder start.Messages from the element picker reach the coder — a message that starts with a dash, which every picker message does (
--- Pointed element ---), was parsed by the Claude CLI as a command-line option and failed withunknown option. The prompt is now passed after--, so no message can be read as a flag. The codebase summary built before a first message now reads the coder's workspace (host git had refused it as owned by another user, leaving the summary with no commit and an empty file tree), runs on the same credential as your turn, and when it fails says what Claude said rather than only an exit code.A message sent to a paused coder runs instead of vanishing — if the coder's container was stopped (an eviction, or an AppCrane restart the open page never heard about), sending a message used to fail with an error while the panel still said idle. The panel now resumes the session and runs the message; a failed resume says the message was not run and why. When Claude rejects the credential, the turn stops at the first 401 instead of waiting out about three minutes of retries, and the error names the credential to replace: your subscription token, the app's uploaded credentials, or the platform's
ANTHROPIC_API_KEY. The codebase summary built before a first message now says it is working, says so if it fails, and is skipped for a conversation that is only being resumed.A coder that is starting says so, and one that failed says why — resuming a paused session recreates its container, and the first time after an upgrade it rebuilds the agent image, which takes a minute or more. That used to show nothing at all, so a slow resume looked exactly like a broken button. It now shows a running counter and what is happening, gives up visibly after ten minutes rather than waiting forever, and when the image build fails the message carries the last lines of build output — an unreachable npm registry and a full disk no longer read identically as
exit 1The Coder button tells you what it would take — it appears on every app, not only the ones it works on. Where it cannot run yet it is muted, and opening it lists every reason at once with its fix: the app's code lives on GitHub, it is still an uploaded archive, it runs from an image, its repository was not migrated, or you have not connected a Claude account (with a link to where you do). The list comes from the same checks that would refuse a session, so it cannot promise something the server will then deny, and no session is started only to be refused
What you teach one agent, the next one knows — an app carries operator notes that are prepended to every coder dispatch. Your local Claude Code can now write them over MCP (
appcrane_append_app_context), so a constraint or a gotcha discovered on your machine reaches the in-platform coder instead of dying with the session. Append is the default because it cannot destroy what a human wrote; the replace tool reports how much it discarded, and the notes are capped, because anything prepended to every dispatch silently eats the context window it was meant to helpThe coder actually has a shell — the agent image is Alpine-based, which ships busybox
shand no bash, and Claude Code's Bash tool spawns bash specifically. Every shell call the agent made failed at spawn, so it could read and edit files but reported having "no shell" and handed the user commands to run by hand — a session that looked healthy while builds, tests and git were quietly unavailable. The image installs bash, and its version is bumped so existing hosts rebuildBuilds survive a Docker install without buildx — BuildKit needs the
docker buildxplugin, and Ubuntu'sdocker.iopackage ships the daemon without it, so forcing BuildKit on made every deploy fail at the build step with a message that pointed at Docker's docs rather than the fix. AppCrane now probes once, uses BuildKit when it is there and the classic builder when it is not, saying so in the log.APPCRANE_DOCKER_BUILDKITpins the choice in either directionAuto-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 elseEnterprise 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 servicesTCP (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 answerAppStudio — turns a feature request into a plan you approve. For a GitHub-backed app it then writes the code and puts it on sandbox for your review before anything reaches production; for a Crane-hosted app it analyzes the repository and files the result as a new request, and the coder makes the change
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
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 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-keyEverything below is done for you, idempotently, by the one command above:
Node.js 22 + AppCrane, with the
craneCLI linked globallyCaddy — 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 deployDocker + 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.
AI sessions (optional). A coder session runs on whichever credential is available, in this order: the signed-in user's own Claude subscription, then the app's stored credentials, then a platform API key. Exactly one is ever sent to the container — Anthropic ranks an API key above a subscription token, so sending both would silently bill the wrong account.
For the first, each person generates their own one-year token with
claude setup-token(Pro, Max, Team or Enterprise plan) and saves it under Settings → Account, which strips the whitespace a terminal copy picks up and says what it removed; nobody else, platform admins included, can read, replace or clear it. The token does not refresh — it expires a year after it is generated and regenerating needs a browser, so AppCrane stores the expiry and shows it.For a platform-wide key instead:
systemctl edit appcrane --force, addEnvironment="ANTHROPIC_API_KEY=sk-ant-..."under[Service], thensystemctl daemon-reload && systemctl restart appcrane.
Deploy your first app
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.
CLI Reference
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.
Install and first run
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 AppCraneConnection and identity
crane 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 keyRecover a lost API key
Run 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 idProxy and drift repair
crane 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 Caddyregenerate-key and reconcile open the database without migrating it —
the server owns migrations and applies them on boot. If the code on disk is
newer than the schema, both refuse with the number of pending migrations rather
than altering the database under a running server. crane init is the one
command that does migrate: it is the bootstrap, and there is no server yet.
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.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.
App operations (MCP or REST)
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 |
|
|
List / inspect |
|
|
Deploy |
|
|
Deploy history / log |
|
|
Roll back |
|
|
Promote sandbox → production |
|
|
Env vars / secrets |
|
|
Grant access |
|
|
Health |
|
|
Backups |
|
|
Runtime logs |
|
|
Audit log | — (read it in the dashboard) |
|
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.
Deploying without GitHub
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.
MCP (for AI agents)
AppCrane is MCP-first. One claude mcp add and the agent gets 68
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.).
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 (request → plan → code) + coder
│ └── 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.
Security
Init locked to localhost — admin setup only from the server itself
API key auth — all requests require
X-API-KeyheaderAdmin isolation — admin cannot read env vars or
/data/; enforced at middleware levelAES-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.
Supply chain — SBOM + build provenance
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 |
| Reproducible |
| CycloneDX SBOM of the production dependency tree |
| Same, SPDX format |
| 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.
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:
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.
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 |
|
| Always present on every proxied request, including ones with no identity. Read it first. |
| Backward-compat single identifier. Set on | |
| numeric id (string) | Set on |
| Granular. May be absent if the user has no email. | |
| display name, |
|
|
| Platform-wide tier, raw token. Not a per-app permission. (The global |
|
| Per-app role — the one to gate on. An explicit |
|
|
|
| comma-separated keys, e.g. | The roles the app defines for itself — a different system from |
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. What arrives through the proxy is therefore platform-issued. Two routes skip the proxy: a raw tcp/dual data-plane port, and, on Docker Desktop only (measured; blocked on Linux), another app's container reaching this app's loopback port via host.docker.internal. For those, v2.97.0 signs the identity: /verify adds X-AppCrane-Identity-Ts and X-AppCrane-Identity-Sig, an HMAC-SHA256 over the identity headers with a secret per app and environment (sandbox and production differ), injected into the app as APPCRANE_IDENTITY_SECRET. An app calls verifyIdentity(req) from appcrane-tenant (1.2.0+) to reject anything AppCrane did not sign for it, and tenantKey()/tenantDb() do so automatically when the secret is set. A compromised app holds only its own secret, so it can neither forge another app's identity nor replay its own elsewhere. 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()
})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 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.
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.
Pass tenantDb(req, { migrations: [step1, step2, ...] }) and each tenant's file is
upgraded lazily, the first time it is opened after a deploy, keyed on
PRAGMA user_version, in one transaction (so no file is upgraded twice or left
half-upgraded, even under concurrent requests). Append steps; never edit a shipped one.
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, by any path (the dashboard, deleting
the user, a SCIM group, or the MCP revoke tool), AppCrane purges that tenant's dir
automatically — using this very helper's orgFromEmail, which the platform
imports rather than copies, so the path it deletes is the path your app wrote.
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.
5. App-defined roles — the app's own vocabulary
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 platform-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' })6. TCP (layer-4) ingress — no proxy, no identity
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.
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 22, 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Hosting for AI agents: your AI client deploys Docker apps to live HTTPS URLs over MCP.
AI-native git hosting — repos, PRs, issues, CI gates, and AI code review over MCP (60 tools).
Build, validate, deploy — HTTP APIs, cron jobs, webhooks and MCP tools — from your AI client.
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Related MCP Servers
FlicenseNot gradedqualityCmaintenanceAgentic cloud platform with 45+ MCP tools. Deploy any containerized stack, debug live pods (shell, file editing, DB queries), manage custom domains & TLS, push to built-in container registry, scale pods, and manage GPU workloads. The infrastructure layer where AI agents ship software to production.9-
mcpgateofficial
FlicenseNot gradedqualityAmaintenanceSelf-hosted MCP gateway that connects Claude, ChatGPT, and other AI agents to 20+ enterprise tools (GitLab, Jira, Notion, Google Workspace, Slack, Grafana, …) with OAuth, audit logs, and zero data leaving your infrastructure1-- AlicenseNot gradedqualityDmaintenanceGives AI coding agents (Claude Code, Cursor, etc.) unified, secure access to dev infrastructure (Vercel, GitHub, Supabase, Cloudflare, GCP) via a single MCP token.MIT
- AlicenseAqualityAmaintenanceGoverned Kubernetes operations for AI agents with 15 MCP tools, audit logging, policy engine, and safety features.55MIT