Someone in finance built a reconciliation tool with Claude Code. Someone in support built a triage dashboard. They work, they are in use, and nobody asked you first — but you are the one who has to answer where they run, who can open them, what is inside them and what happens when one breaks. AppCrane is a self-hosted platform that puts those apps on one Ubuntu server you own, behind one sign-on, with a role model and an audit trail that were written for exactly this position.
The apps your business builds with an agent were not written with your identity provider in mind, and asking each one to integrate is how shadow accounts happen. AppCrane settles identity before the request reaches the container. Caddy runs a forward_auth check against AppCrane on every proxied request; AppCrane resolves the caller's role on that specific app and refuses with 403 if they hold none, so an unauthorised visitor never touches the app at all.
Once the answer is yes, the authenticated identity is handed to the container as request headers. Caddy strips any client-supplied copy of those headers before the auth step runs and then writes the verified values itself, so a visitor cannot forge them:
X-AppCrane-User-Email: someone@example.com X-AppCrane-User-Role: admin X-AppCrane-App-Role: owner X-AppCrane-Is-Admin: 1
The practical effect is that an internal app your team never reviewed still cannot be opened by someone who has no business opening it — and it did not have to be modified to get there.
SAML 2.0, OIDC and SCIM 2.0 provisioning all ship in the AGPL-3.0 build with nothing to activate, configured by a platform admin. SCIM covers users and groups, which is the half that matters on the day someone leaves: deprovisioning happens in your directory and arrives here, rather than being a task somebody remembers. AppCrane is also offered under a separate commercial licence for organisations that need to run private modifications as a service — that changes the licence terms, not which features are present.
Identity does not require your IdP, though. Local password accounts and API keys create sessions too, which is what keeps a break-glass path open when the directory is the thing that is down.
There are two layers, and it is worth keeping them apart. Platform-wide, a user is platform_admin, admin or user. On a specific app, a user holds owner, admin, user or none. The per-app role is the one the proxy checks on every request.
A third layer exists and is deliberately inert: an application can declare its own role vocabulary — approver, auditor, whatever its domain needs — and AppCrane stores who holds them and delivers them in a header. It never interprets them. An app-defined role can never confer an AppCrane privilege, which means letting a team manage its own authorisation vocabulary costs you nothing on the platform.
platform_admin, the role that installs and updates the platform. Reveals are throttled and audited across both doors, the HTTP API and the MCP endpoint, so switching transport does not buy a fresh allowance. If your reason for hosting internally is that a model API key must not be readable by whoever administers the box, that is the specific property to check.
Every mutating action is written to the audit log against the user who took it, and every row carries an actor_kind of human or agent. That second column is the one that matters now: most deploys on a box like this come from Claude Code or Cursor rather than from a browser, and "what did the agents change here last week" should be one query rather than a reconstruction from timestamps.
MCP tool calls are audited the same way, with their arguments redacted before the row is written. There is a stricter mode for the MCP door — set APPCRANE_AUDIT_REQUIRED=1 and a tool call is refused outright if its audit row cannot be written, because the check runs before the action. The REST middleware cannot make the same promise and says so rather than pretending: it runs after the handler, so under the same flag it reports the failure loudly instead of claiming nothing happened.
Every deploy scans what it just shipped and records the result against that deployment. Apps built from source are read from their lockfiles and queried against OSV; apps deployed from an image are scanned with a pinned Trivy against the resolved digest — the bytes actually running, not whatever the tag points at today.
It reports and never blocks. A scanner that is missing, slow or wrong cannot fail your deploy, which is a deliberate trade and worth knowing before you plan a control around it.
The fleet report leads with arithmetic rather than with a word. Every report opens with how many app-and-stage rows have a usable scan result, out of how many, as a percentage — then names how many were skipped, errored or never scanned, and lists the most common reasons, counted. There is still a one-word assurance level, but it never travels without those counts beside it, because "partial" spans one per cent to ninety-nine.
A daily digest goes out per recipient and is scoped to what that person is entitled to see: fleet-wide to a platform admin, own-apps-only to an app owner, so the digest cannot quietly tell someone which other apps are exposed.
Each app carries a memory ceiling in megabytes and a CPU share — 512 MB and half a core by default — applied as Docker's --memory and --cpus when the container is created. Changing them is platform-admin only. Memory and swap are pinned equal, so a container cannot quietly spill into swap instead of hitting its limit.
Those are creation-time flags, which produces the failure mode worth naming: a limit changed after a container was created is recorded in the database and not in force until that container is recreated, and every other surface will happily report the configured number. appcrane_check_resource_limits exists to find exactly those containers, and reports "unknown" rather than guessing when Docker cannot be read. appcrane_memory_budget answers the adjacent question — whether the sum of every configured ceiling would fit in host RAM if all the containers asked at once — and is careful to say that those are configured ceilings, not measured usage.
Containers also run with no-new-privileges, raw-socket capabilities dropped, a process-count limit, and inter-container connectivity disabled on the shared network, so one internal app cannot reach another's port by default.
On by default: the database and .env to DATA_DIR/backups/local at 04:00, newest seven kept. It covers a corrupted or deleted database and nothing else — not icons, per-app data, volumes, repositories or images.
The off-site S3 copy is optional and does nothing until a bucket and credentials are entered. Until an upload has actually completed, every surface — settings, MCP, dashboard, boot log — says "No off-site copy — everything AppCrane knows lives on this host."
Caddy provisions certificates on first request. An organisation with its own CA can supply a certificate and key instead and skip ACME entirely.
Production and sandbox, always on, separate ports. Promotion refuses unless sandbox is live and healthy, and the promoted production release is health-checked with automatic revert.
PostgreSQL, MariaDB, MongoDB and Redis provisioned per app, with credentials injected under the variable names the app actually reads. No shared database where one team's mistake is another team's outage.
Each app declares a health endpoint; AppCrane probes it, restarts on failure and notifies by email.
Someone who lands on an app they cannot open gets a Request access button rather than a dead end. The request queues against that app, and an owner, app admin or global admin approves it at a chosen role or denies it — from the dashboard or from an agent session. The grant is an ordinary audited action, so the answer to "why does this person have access" is a row rather than a memory.
A new managed app's code lives in a bare git repository under DATA_DIR/repos on the AppCrane host itself — no GitHub organisation, no service account, no third party holding the source. Deploys clone from the local repository, a push can trigger a deploy on its own, and the repositories travel in the backup. For teams whose reason to self-host is that the code is the sensitive part, that removes the last hop off the server.
Apps that are connected to GitHub can authenticate through this instance's own GitHub App instead of a long-lived personal access token: one-hour installation tokens narrowed to a single repository, cached in memory, handed to git through its environment rather than a URL, a config file or a command line.
At the reverse proxy. Caddy runs forward_auth against AppCrane on every request, AppCrane resolves the caller's role on that app, and a caller with no role gets 403 without ever reaching the container. The app is then handed the verified identity as headers it can read without a callback.
No. SAML 2.0, OIDC and SCIM provisioning are in the AGPL-3.0 build with nothing to activate. The separate commercial licence is about running private modifications as a service, not about unlocking features.
Not for an app they are not assigned to. Env vars are AES-256-GCM encrypted and access follows app assignment, including for platform_admin. Reveals are throttled and audited across both the HTTP API and MCP.
No. Scanning reports and never blocks, by design. What you get instead is a result recorded against every deployment and a fleet report that opens with how many rows actually have a usable scan result, as a fraction and a percentage.
The database and .env, nightly at 04:00, newest seven kept, on the same host. Not icons, per-app data, volumes, repositories or images — and no off-site copy until you enter a bucket and credentials, which every backup surface states plainly until an upload has completed.
Per-app memory and CPU ceilings, applied as Docker flags at container creation. Because they are creation-time flags, use appcrane_check_resource_limits to find containers still running without a limit that was set after they started.