Actions
35 actions, callable from the panel, the command palette and the API as
POST /api/v1/a/<id>. Internal actions used between modules are not listed.
| Action | What it does | Risk | Preview |
|---|---|---|---|
apps.preset.list | List framework presets (read) | low | No dry run |
apps.runtime.install | Install language runtimes | medium | No dry run |
apps.runtime.list | List installed runtimes (read) | low | No dry run |
apps.app.detect | Detect a repository's framework (read) | low | No dry run |
apps.app.create | Create an app | medium | |
apps.app.update | Change an app | medium | |
apps.app.get | Show an app (read) | low | No dry run |
apps.app.list | List apps (read) | low | No dry run |
apps.app.delete | Delete an app | high | |
apps.env.list | List environment variables (read) | low | No dry run |
apps.env.set | Edit environment variables | medium | No dry run |
apps.deploy.run | Deploy an app | medium | |
apps.deploy.list | List deploys (read) | low | No dry run |
apps.deploy.get | Show a deploy with its log (read) | low | No dry run |
apps.deploy.rollback | Roll back to a previous release | medium | No dry run |
apps.release.list | List releases on the server (read) | low | No dry run |
apps.unit.control | Start, stop or restart a process | low | No dry run |
apps.status | Process status (read) | low | No dry run |
apps.logs.get | Process logs (read) | low | No dry run |
apps.workers.set | Set queue workers | medium | No dry run |
apps.cron.set | Set scheduled jobs | medium | No dry run |
apps.site.link | Serve an app on a domain | medium | No dry run |
apps.webhook.info | Show the webhook secret | high | |
apps.webhook.rotate | Rotate the webhook secret | high | |
apps.webhook.receive | Receive a Git webhook | medium | No dry run |
apps.reconcile | Re-apply every app's units | low | No dry run |
apps.template.list | List app templates (read) | low | No dry run |
apps.template.get | Show an app template (read) | low | No dry run |
apps.template.save | Save an app as a template | low | |
apps.template.delete | Delete an app template | medium | |
apps.template.export | Export a template as YAML (read) | low | No dry run |
apps.template.import | Import a template from YAML | low | |
apps.override.preview | Preview the repository's respirecloud.yaml (read) | low | No dry run |
apps.nginx.get | Show the app's web server snippets (read) | low | No dry run |
apps.nginx.set | Set the app's web server snippets | medium |
Permissions and limits
Permissions
apps.viewView apps, deploys and logsapps.operateStart, stop and restart app processesapps.deployDeploy and roll backapps.manageCreate, change and delete appsapps.runtime.manageInstall language runtimesapps.webhookTrigger a deploy through a webhookapps.adminAdminister apps on every server
Plan limits
apps.appsAppsapps.container_cpuCPU of one app containerapps.container_memory_mbMemory of one app container
Engineering notes
Generated from modules/apps/docs.md at build e0cdf300. These are the notes the engineers keep
next to the code: precise, technical, and honest about what is not done yet.
Declared in module.yaml; agent driver internal/agent/apps*.go; op types sdk/agentop/apps.go; detection table
modules/apps/preset (pure Go, unit-tested; heuristics follow railpack's providers, MIT).
Model
- Runtimes:
apps.runtime.installinstalls Node, Python, Ruby, Go, Java, .NET, Deno, Bun, Elixir, PHP or Rust per account with mise (pinned release, sha256 checked,/usr/local/bin/mise; data in~/.local/share/mise). PHP apps default to the distro PHP + composer (runtime: system) so they match the web module's FPM. - Apps (
appstable): repo URL, branch, preset, build/migrate/start as argv arrays (no shell strings), health path, shared paths, workers, cron, keep-N, port (21000-21999, per node), link to the web site. A generated ed25519 deploy key (private half in the vault, public half returned) and a webhook secret (vault). - Environment:
apps.env.set/list; secret values go to the vault (apps/<id>/<KEY>), never to the table, never returned. On the node they land in/etc/respirecloud/apps/<user>/<app>.env(root:user 0640) read by the units (EnvironmentFile=), by build steps (-E) and, for dotenv readers like Laravel, through a.envsymlink. - Processes:
rc-app-<user>.<app>[.<worker>].service, cron asrc-cron-<user>.<app>.<job>.{service,timer}(cron expressions are converted to OnCalendar), allUser=,Slice=rc-acct-<user>.slice,NoNewPrivileges,PrivateTmp,ProtectSystem=full, restart on failure.${PORT}in argv is expanded by systemd. The dot separator keeps accountsa-b/appcanda/appb-cfrom colliding. - Deploy (
apps.deploy.run, one at a time per app): fetch the bare mirror (~/apps/<app>/repo) with the deploy key,git clonea release dirreleases/<id>, drop.git, link shared paths (first deploy seedsshared/from the repo, e.g. Laravelstorage), run build steps then migrations in the release dir, as the account user, in its slice (systemd-run --uid --slice; root never follows a path in the home), swapcurrentwithln -sfn+mv -T(atomic rename), rewrite units and restart, reload the account'src-fpm-<ver>-<user>for PHP, health-check (HTTP path or "unit stays active"); any failure after the swap flipscurrentback and restarts the previous release. Old releases beyondkeepare deleted.apps.deploy.rollbackswaps to the previous (or a named) release. Log (256 KiB head+tail) is stored indeploysand the final status goes out asapps.deploy.finished. - Presets (
apps.preset.list): Laravel, Symfony, WordPress, Drupal, generic PHP, Next.js, Nuxt, SvelteKit, Remix / React Router, Astro (static or node adapter), NestJS, Express/Fastify/Koa, Node, Vite, Eleventy, Hugo, Jekyll, MkDocs, Django (module derived frommanage.py), Flask, FastAPI, Python, Rails, Spring Boot, ASP.NET Core, Phoenix, Go, Rust, Deno, Bun, static.preset: autoruns detection on the first deploy and stores the answer; every field is editable. - Sites:
apps.site.link(ordomain_idon create) callsweb.sites.create: PHP ->phpsite with docrootapps/<app>/current/<public>, static ->static, process ->proxytohttp://127.0.0.1:<port>(needs the adminproxy.allow_internallayer setting, see modules/web). The web module pre-creates the docroot path as real directories; the first swap replaces an emptycurrent/directory with the symlink. - Webhook (
apps.webhook.receive): verifies HMAC-SHA256 (sha256=<hex>over the raw body, GitHub/Gitea) or the GitLab-style token in constant time, ignores pushes to other branches andping, then deploys in the background.
Not done / needs core
- A public webhook URL. There is no module-owned unauthenticated HTTP route;
PublicRoutesis core. The action takes the raw body (base64) and signature and needs an API token withapps.webhook(admin default). Core should addPOST /api/v1/hooks/apps/{app_id}that forwards body +X-Hub-Signature-256/X-Gitlab-Token/event header toapps.webhook.receiveas the system actor (rate-limited), see the handover. - UI bundle (card w6-05), per-site "deploy status" in the web module.
- Runtimes are per account, not shared (disk); mise compiles Ruby/PHP/Python from source for some versions (needs the allow-listed build packages, installed on demand).
Customisable apps (w9-06)
Presets are starting points: every field of an app is editable (apps.app.update, with dry_run = field diff).
- Fields: runtime + version (any
misespec shaped like a version/alias; the agent refuses what mise cannot resolve), commands, process types (workers, cron, release = migrate), ports, working directory, docroot, shared paths, release retention, health path + timeout, restart policy, per-process resource limits (sub-limits inside the account slice, so never above the plan), deploy mode. Stored inappscolumns plusapps.config(AppConfig). - Env scope: each variable is
both(default),build(build/migrate/hooks only) orruntime(running processes only). A secret showsset: true, never its value; saving it with an empty value keeps the stored one. - Deploy hooks:
pre_build, post_build, pre_release, post_release, post_deploy, argv lists run in the release dir. A failing hook before the switch fails the deploy and removes the release;post_deployfailures are warnings. - Custom preset:
preset: customstarts empty (no framework detection). - Templates (
apps.template.*): save any app (secrets keep their name only), versioned per (scope, owner, name), scopes global (admin) / reseller / account. Export/import is YAML (apiVersion: respirecloud/app-template/v1, keys are the JSON field names, strict: unknown keys refused).apps.app.createtakestemplate: {id|name,scope,version}; fields in the request win over the template. Store packages: the Store verifies the signature and calls the internalapps.template.install_signed {yaml, store_ref}(becomes a global template,source: store). - respirecloud.yaml / Procfile in the repository (read after checkout, strict schema, argv only, nothing executes that
is not declared):
runtime: node@22,build,release,start,health,health_timeout,restart,workdir,docroot,shared,env,build_env,hooks.*,workers: {name: cmd},cron: {name: {schedule, command}}. Procfile:web= start,release= migrate, other types = workers. It overrides the app for that deploy (the deploy row lists the fields:override_file,overrides); panel variables win over the file'senv(a repo cannot clobber a secret); limits and secrets are not overridable.apps.override.previewshows the changes before deploying; the app can opt out (config.no_repo_file). Known limit: a later panel-side unit rewrite (env change without redeploy) uses the panel's values until the next deploy. - Per-app nginx snippets:
apps.nginx.get/setgo throughweb.layers.get/seton the app site's layer (allow-listed, validated with line numbers, drift rules and dry-run nginx diff apply). - Deploy rows carry
auto_reverted(failed after the switch and went back by itself).
Container apps: Dockerfile and Compose modes (w9-15)
mode: dockerfile | compose (config dockerfile / compose_file paths, container_port, default 8080). Needs Docker on
the node (an administrator installs it with containers.runtime.install). Code: internal/agent/apps_container.go.
- Release = a checkout of the repository (release list, marker,
currentandkeepwork as before) + one local image per built service,rc-app-<account>-<app>-<service>:<release>, labelledrc.account/rc.app/rc.release. - Dockerfile mode: the agent generates a one-service compose file (
build:the repository, publishes the container port on127.0.0.1:<app port>, runtime variables as${KEY}references,PORT=<container port>, the app's limits). Compose mode: the repository's compose file (compose.yaml, ...). Both go through the containers module's strict validator and renderer (modules/containers/compose), extended only withbuild:for the app's own repository: context/dockerfile must be repository-relative (no absolute path,.., URL), args literal,targetallowed;ssh,secrets,additional_contexts,network, cache and platforms are refused. Everything else stays refused exactly as in the containers module: privileged, host network/pid/ipc, devices,cap_addoutside the allow-list, host-path mounts, non-loopback ports. An app publishes one port (the site proxies to it); other ports stay internal. The rendered compose adds the account slice as cgroup parent, caps, no-new-privileges,pull_policy: neverfor built images. - Build:
docker buildwith BuildKit on the node's Docker daemon, streamed to the job log. Secret build variables (env scopebuild/both+ secret) are BuildKit secrets (RUN --mount=type=secret,id=KEY): written to a root-only temp dir, removed after the build, never--build-arg, sodocker history/ image config do not show them; their values are redacted from the job log and the stored log. Non-secret build variables are--build-arg. - Run: the stack
app-<name>(so app names are at most 27 characters in these modes) is applied through the containers driver; the plan's per-service ceilings come from the limitsapps.container_cpu/apps.container_memory_mb. Healthy = the containers' healthchecks plus, when the app has a health path, an HTTP check on its port. - Failure and rollback: a failure after the new containers started re-applies the previous release's compose (the
previous images are still there) and deletes the new release and image.
apps.deploy.rollbackre-applies an earlier release's stored definition (rel-<id>.inin the root-only stack directory) with its image; if that image was pruned the rollback says so.keepprunes old release directories, their images and stored definitions. - Operate: status lists the compose services (the app service = unit "", others
worker:<service>), logs and start/stop/restart go throughdocker compose; an env change (apps.units.apply) re-applies the current release. Removing the app removes the stack, its images and (with purge) its volumes. - Limits of this version: BuildKit runs build steps in the Docker daemon's cgroup, so a build is bounded by a timeout
(25 min) and the node, not by the account's slice (a per-build slice would need a daemon change; see the handover).
Workers/cron of buildpack mode do not apply (use compose services). The container stack name
app-<name>is also reachable for a user throughcontainers.stack.*(same account); do not reuse it there.
Store app templates
The Store publishes app-template items (signed channel metadata with k-of-n threshold, expiry and a monotonic version;
the package's release-role attestation covers template.yaml only; per-template rollback floor). store.template.list
shows them with version and publisher; store.template.install verifies and calls the internal
apps.template.install_signed {yaml, store_ref} as the system actor (becomes a global template, source: store).
Build isolation (w11-30, proven on a real Docker node in w11-56)
An account's image builds run in the account's own slice. Before, docker build ran in the Docker daemon's cgroup: the
account's memory / CPU limits never saw the build. Now the first Dockerfile or compose build of an account starts a
BuildKit daemon for it, a container rc-buildkit-<user> created by the agent with --cgroup-parent rc-acct-<user>.slice,
and every build goes through the buildx "remote" builder rc-build-<user> that talks to it through docker exec (no socket on the host). The builder container is capped at 75 % of the slice's MemoryMax (no swap), below the slice's
MemoryHigh, so a step that wants more is OOM-killed inside the builder at once and the customer reads "the build ran out of
memory and was stopped: a build may use up to N MB of your plan's memory" (not a stall until the 25-minute build limit, and
not the account's running apps starved by throttling). CPU is the slice's CPUQuota (throttled, cpu.stat). The cache lives
in the account's own BuildKit daemon, so another account cannot read or poison it.
- The builder image is pinned by digest in the version catalogue (
catalog/versions.json, componentbuildkit,binaries.image.sha256; a signed catalogue overrides it): the agent runsmoby/buildkit@sha256:<digest>, never a tag. - Why not buildx's own
--driver docker-container --driver-opt cgroup-parent=...(w11-30's first design): measured on a node, buildx records the option but the container stays insystem.slice(CgroupParent empty): that path never bounded anything. The agent creates the container itself. - The builder is rootless (w11-67). BuildKit runs in its rootless mode (
moby/buildkit:<v>-rootless, pinned by digest asbinaries.rootlessin the catalogue): the container has no--privilegedand no--userns host; BuildKit makes its own user namespace inside it. Proven on a real Docker 29.9 node (userns-remap on): a build step's root has uid 0 inside, and read from the node/proc/<pid>/uid_mapof the step is0 101000 1/1 102000 60000,Uid: 101000; the step is inrc-acct-<user>.slice/docker-<id>.scope/...; a step that allocates a GB under a 192 MB cap is killed inside the builder (memory.eventsmax 198, oom_kill 1, exit 137) while Docker and the builder stay up. To get there the container is started with--security-opt no-new-privileges=false(the daemon sets it for every container and that stops the setuidnewuidmap: "Could not set caps"),seccomp,apparmorandsystempathsunconfined (BuildKit needsunshareandmountin its own namespace; it holds no capability in the node's), and/etc/subuid//etc/subgidreplaced by a root-owned file (/run/rc-buildkit/subid,user:2000:60000: container ids, which the daemon's remap places at host 102000+). That range is only safe when the daemon remaps, so the agent refuses the rootless builder on a daemon that does not (docker infomust showuserns). The builder is reached through buildxdocker-container://rc-buildkit-<user>(docker exec ... dial-stdio): there is no socket on the host any more. - What rootless costs. Root in a step is an unprivileged uid of the node, with 60001 ids (not all 65535): a Dockerfile that
needs uid/gid above 61999,
RUN --security=insecure, mounting a device or a filesystem, or loading kernel modules no longer works (it never did without an insecure entitlement).chown 4242:4242works (measured). Only the BuildKit process sandbox is off (--oci-worker-no-process-sandbox: a step can see the other processes of its own builder, nothing else). The first build of an account takes a few seconds longer to start the daemon (rootlesskit sets up its namespace). - Kernel settings. Rootless needs unprivileged user namespaces:
user.max_user_namespacesabove 0 and, where the kernel has them,kernel.unprivileged_userns_clone=1andkernel.apparmor_restrict_unprivileged_userns=0(Ubuntu 24.04+ sets it to 1, which may stop the builder; the lab host has it at 1 and the builder still ran inside the lab node's container). The installer's preflight reports the setting by name (install/lib/preflight.sh,userns_blocker) and the agent refuses the build with the same name whenmax_user_namespacesis 0 orunprivileged_userns_cloneis 0; it appends the setting to the error when the builder cannot start. - The privileged builder, only behind an administrator's switch.
RC_APPS_BUILDER_PRIVILEGED_ACCEPT_ROOT_RISK=1in the agent's environment (a systemd drop-in, root only) brings back the w11-56 builder:--privileged --userns host, the socket in/run/rc-buildkit/<user>/. Build steps then run as the node's real root behind Docker's default sandbox. The build log says so on the first build. It is never chosen on its own; a builder of the other mode is replaced (its cache is lost). - Idle builders stop. A builder sits idle at memory nobody uses: after 15 minutes without a build
(
RC_APPS_BUILDER_IDLE_MINUTES, 1 to 1440) the agent runsdocker stopon it (checked once a minute; never while a build runs); the cache stays in the container. The next build starts it again and the build log says "starting the builder". - Lifetime. The builder is removed (container, buildx instance, cache) when the account is deleted
(
userRemove) and when the account's last container app is removed. A changed plan updates the cap on the next build. - Base images (
FROM x) are pulled by BuildKit itself, not taken from the daemon's local images. - Podman / no Docker: container apps (dockerfile / compose modes) need Docker; there is no Podman path in the agent, before or after this change, so there is nothing to keep. If the builder cannot be created or started the build is refused with the reason; it never falls back to the daemon.
Address at create time
App creation can include address and https. Clear Address to create a private app. The app-domain name needs no
separate ownership TXT record.