Apps

Runtimes, framework presets, Git deploy with rollback, workers and cron for apps in any language.

In preview apps version 1.0.0 Part of Apps and runtimes

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.

ActionWhat it doesRiskPreview
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.view View apps, deploys and logs
  • apps.operate Start, stop and restart app processes
  • apps.deploy Deploy and roll back
  • apps.manage Create, change and delete apps
  • apps.runtime.manage Install language runtimes
  • apps.webhook Trigger a deploy through a webhook
  • apps.admin Administer apps on every server

Plan limits

  • apps.apps Apps
  • apps.container_cpu CPU of one app container
  • apps.container_memory_mb Memory 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.install installs 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 (apps table): 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 .env symlink.
  • Processes: rc-app-<user>.<app>[.<worker>].service, cron as rc-cron-<user>.<app>.<job>.{service,timer} (cron expressions are converted to OnCalendar), all User=, Slice=rc-acct-<user>.slice, NoNewPrivileges, PrivateTmp, ProtectSystem=full, restart on failure. ${PORT} in argv is expanded by systemd. The dot separator keeps accounts a-b/app c and a/app b-c from colliding.
  • Deploy (apps.deploy.run, one at a time per app): fetch the bare mirror (~/apps/<app>/repo) with the deploy key, git clone a release dir releases/<id>, drop .git, link shared paths (first deploy seeds shared/ from the repo, e.g. Laravel storage), 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), swap current with ln -sfn + mv -T (atomic rename), rewrite units and restart, reload the account's rc-fpm-<ver>-<user> for PHP, health-check (HTTP path or "unit stays active"); any failure after the swap flips current back and restarts the previous release. Old releases beyond keep are deleted. apps.deploy.rollback swaps to the previous (or a named) release. Log (256 KiB head+tail) is stored in deploys and the final status goes out as apps.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 from manage.py), Flask, FastAPI, Python, Rails, Spring Boot, ASP.NET Core, Phoenix, Go, Rust, Deno, Bun, static. preset: auto runs detection on the first deploy and stores the answer; every field is editable.
  • Sites: apps.site.link (or domain_id on create) calls web.sites.create: PHP -> php site with docroot apps/<app>/current/<public>, static -> static, process -> proxy to http://127.0.0.1:<port> (needs the admin proxy.allow_internal layer setting, see modules/web). The web module pre-creates the docroot path as real directories; the first swap replaces an empty current/ 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 and ping, then deploys in the background.

Not done / needs core

  • A public webhook URL. There is no module-owned unauthenticated HTTP route; PublicRoutes is core. The action takes the raw body (base64) and signature and needs an API token with apps.webhook (admin default). Core should add POST /api/v1/hooks/apps/{app_id} that forwards body + X-Hub-Signature-256/X-Gitlab-Token/event header to apps.webhook.receive as 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 mise spec 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 in apps columns plus apps.config (AppConfig).
  • Env scope: each variable is both (default), build (build/migrate/hooks only) or runtime (running processes only). A secret shows set: 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_deploy failures are warnings.
  • Custom preset: preset: custom starts 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.create takes template: {id|name,scope,version}; fields in the request win over the template. Store packages: the Store verifies the signature and calls the internal apps.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's env (a repo cannot clobber a secret); limits and secrets are not overridable. apps.override.preview shows 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/set go through web.layers.get/set on 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, current and keep work as before) + one local image per built service, rc-app-<account>-<app>-<service>:<release>, labelled rc.account/rc.app/rc.release.
  • Dockerfile mode: the agent generates a one-service compose file (build: the repository, publishes the container port on 127.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 with build: for the app's own repository: context/dockerfile must be repository-relative (no absolute path, .., URL), args literal, target allowed; 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_add outside 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: never for built images.
  • Build: docker build with BuildKit on the node's Docker daemon, streamed to the job log. Secret build variables (env scope build/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, so docker 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 limits apps.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.rollback re-applies an earlier release's stored definition (rel-<id>.in in the root-only stack directory) with its image; if that image was pruned the rollback says so. keep prunes 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 through docker 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 through containers.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, component buildkit, binaries.image.sha256; a signed catalogue overrides it): the agent runs moby/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 in system.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 as binaries.rootless in the catalogue): the container has no --privileged and 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_map of the step is 0 101000 1 / 1 102000 60000, Uid: 101000; the step is in rc-acct-<user>.slice/docker-<id>.scope/...; a step that allocates a GB under a 192 MB cap is killed inside the builder (memory.events max 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 setuid newuidmap: "Could not set caps"), seccomp, apparmor and systempaths unconfined (BuildKit needs unshare and mount in its own namespace; it holds no capability in the node's), and /etc/subuid / /etc/subgid replaced 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 info must show userns). The builder is reached through buildx docker-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:4242 works (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_namespaces above 0 and, where the kernel has them, kernel.unprivileged_userns_clone=1 and kernel.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 when max_user_namespaces is 0 or unprivileged_userns_clone is 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=1 in 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 runs docker stop on 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.