Skip to main content

What this covers

An app is a container (or a set of them) that Levelrail keeps running for you. You describe it once, from the dashboard, the CLI, or an app.yaml in your repo. Levelrail builds it if needed, starts it, waits until it is healthy, switches traffic to it, and keeps every previous image so you can go back. This page follows an app from creation through deploys, rollbacks, configuration, promotion, and day to day operation. Levelrail app overview page with health status, setup checklist, and recent activity

Common tasks

Domains and TLS, managed databases, and git provider setup have their own pages: Domains and ingress, Managing databases, and Git integrations.

The model in one minute

  • An app is desired state. It is a record: an image, a port, and the settings around them. Saving it changes the record. A level-triggered reconciler then converges the running containers to match. Nothing is edge-triggered, so a missed event never leaves an app out of sync.
  • One mutation changes the image. POST /api/v1/apps/{name}/deploys points the app at a tag and returns immediately. A rollback is the same call with an older tag. apps rollback, the dashboard rollback button, and automatic rollback on a crashloop all use it, so there is no second code path that can drift.
  • Traffic only moves once the new container is ready. The readiness probe has to pass before the old container is drained. If it never passes, the old container keeps serving and the deploy fails with a reason.
  • Backend: internal/api/apps.go, apps_multi.go, apps_compose.go, deploys.go, promote.go, exec.go, resources_live_apply.go, scheduled_tasks.go
  • CLI: cmd/levelrail-cli/apps*.go
  • Dashboard: web/src/routes/apps/$name/*.tsx
  • An app is one or more store.DesiredService rows under one store.App. Redeploy goes through setDesiredImage, restart through RestartService, stop and start through UpdateServiceSuspended.

Create an app

There are four ways to create one. All four accept inline secret values (--secret KEY=VALUE on the CLI) that are encrypted as part of the same call, so a { secret: true } variable declared in the spec needs no follow-up call. The dashboard’s New wizard offers an existing image, a git repository, and Docker Compose as step-one cards, plus templates. The multi-service app.yaml route starts from an existing app’s Services tab.

From an existing Docker image

Skips the build step. Send image and port to POST /api/v1/apps, or:
Scale it later with levelrail-cli apps scale NAME --replicas N. In an app.yaml, a prebuilt-image service can be written as image: traefik/whoami:v1.10 next to replicas and strategy. In the dashboard, the Docker image card has an image picker that browses the built-in registry, a connected registry credential, or public Docker Hub (GET /api/v1/dockerhub/search and GET /api/v1/dockerhub/repositories/{namespace}/{repo}/tags). The plain text field always works too.

From a git repository

Levelrail Git source settings: provider tabs, build pack, and deploy trigger An app’s Source tab connects a repository, picks the build pack, and sets the deploy trigger. BuildKit builds a Dockerfile, or Railpack builds it with no Dockerfile at all.
Over the API this is POST /api/v1/apps with a :pending placeholder image, then POST /api/v1/apps/{name}/builds, which replaces the placeholder with the real tag when the build succeeds. levelrail-cli apps builds trigger does the second step.
One build per app at a time. A second POST .../builds while one runs returns 409 Conflict (“a deploy for this app is already running”). If the control plane restarts mid-build, the orphaned attempt is marked failed on the next startup, so it never blocks new builds.
Disk preflight. Before BuildKit starts, the control plane checks free space on the build context directory (and the local cache directory when one is configured). Below the floor, the build fails fast with “N bytes free, need at least M bytes”. The floor is 1 GiB by default, set in MiB with APP_MIN_BUILD_DISK_MB. A path that cannot be read counts as unknown, not as a failure.

Railpack and framework detection

When build.type is railpack (no Dockerfile), Railpack detects the framework from the repository. Levelrail builds only providers it has verified end to end: Anything else Railpack can detect (Ruby, PHP, Rust, Deno, .NET, and so on) is rejected with a clear error rather than attempted. A Django app needs nothing beyond what django-admin startproject generates plus a requirements.txt that lists Django and a production server such as gunicorn. Railpack runs python manage.py migrate on start and serves with gunicorn bound to $PORT, never the Django dev server.
The Deploy from git wizard checks what would be detected before you pick a build type, without building anything:
The control plane makes a shallow single-branch clone (depth 1) into a temporary directory, with a 20 second timeout and a checkout size cap. It runs Railpack’s provider detection, which only reads files such as package.json, go.mod, or pom.xml, then discards the checkout. No repository code is executed. A repository that matches none of the providers above, or cannot be cloned, answers {"detected": false} rather than an error, and the wizard falls back to its manual build-type tabs. levelrail-cli build detect --repo-url URL runs the same check.The detected name is stored on the deploy attempt (detected_framework). It appears on the deploy detail page, in a FRAMEWORK column of levelrail-cli apps deploys list, and in --output json. apps create on the git path and apps builds trigger run the same detection first.

From a Docker Compose file

Each Compose service becomes its own DesiredService under one app, in one synchronous call. Each service gets its own deploy attempt.
In the dashboard, use the Docker Compose card. A multi-service app lists every sibling service, with its depends_on, on the Services panel.
  • Magic variables. SERVICE_<KIND>_<KEY> variables that need generated secrets are resolved and stored automatically. A compose file that bind-mounts a host directory needs the root ability on top of deploy.
  • Ports and volumes accept the short form ("8080:80", web-data:/data) and the long mapping form, so an upstream docker-compose.yml usually pastes in unchanged. Port ranges, UDP, and tmpfs or npipe mounts have no equivalent and are rejected.
  • depends_on (list or map form) enforces start order. A dependent container is not created until every service it names has a running container. This matches Compose’s own default (service_started): it waits for the container to exist and run, not for its health check. A dependency must name a real sibling, and a cycle fails validation rather than deadlocking the reconciler.
  • pull_policy: always forces a fresh pull on every deploy, which suits mutable tags like :latest. The default is pull if absent.
  • restart: and networks: parse but have no effect, because the reconciler alone decides whether a container runs and every service in an app shares one network. They come back as non-blocking notices, as does anything else that cannot be translated, such as a health check with no readiness-probe equivalent.
Validation fails, naming exactly what to remove, for:
  • deploy: subkeys other than replicas and resources.reservations.devices (GPU reservations). The rest of the Swarm-specific deploy: surface (mode, placement, restart_policy, update_config, and so on) has no meaning outside a Swarm cluster.
  • Top-level secrets: and configs:, and a service’s own secrets: or configs: references. Move the value into the app’s env vars or Levelrail secrets.

From a multi-service app.yaml

POST /api/v1/apps/{name}/deploy-spec builds and deploys each service in services: in key order, under one app. It is synchronous per service, and one service’s build failure does not stop the others. The response is 207 Multi-Status when at least one service failed. Unlike Compose, it records no per-service deploy attempt yet, so the response is the only record of what happened. The dashboard form is on an existing app’s Services tab. Every field of the spec is in the app.yaml reference.

Deploy and watch a rollout

apps wait exits 5 when the deploy failed and 6 when it timed out, which makes it a CI gate. See the CLI reference for every exit code.

What a rollout does

An app’s Deploy settings tab sets the strategy (rolling, recreate, or blue-green, with blue-green the default) and the replica count: Levelrail deploy settings with a blue-green strategy and two replicas Rolling and blue-green follow the same core sequence: start the new container, wait for readiness, cut ingress to the new container, then drain and stop the old one. The reconciler tracks both containers until the old one exits. If the new container never becomes ready, the old one keeps serving and the deploy fails with a specific reason: OOMKilledDuringReadiness, ExitedDuringReadiness, or ReadinessFailed. The first two are reported the moment the container dies, instead of after the full readiness budget has been spent probing a dead address. The budget is 60 seconds by default and health.readyTimeout in app.yaml overrides it per service. The reason shows in the deploy history and in levelrail-cli apps deploys list. Guarantees around stale deploys, freeze windows, and a short hold of the previous release are in Deploy safety.

Live deploy view

Once a build starts, the deploy detail page (/apps/{name}/deploys/{deployId}/logs) shows a checklist of named steps above the raw build log:
It is fed by a server-sent events stream, GET /api/v1/apps/{name}/deploys/{deployId}/steps. Each event is { "step": string, "status": "running" | "done" | "failed", "timestamp": string }. The log underneath still shows every build line. Reconnects are handled by EventSource, and receiving a step event twice is harmless. The stream only reports on the build flow. It never feeds back into the reconciler.

When a deploy fails

A failed attempt opens with a What went wrong card: the failing stage, the recorded error, a likely cause with a suggested fix, and three actions: View full logs, Retry deploy, and Roll back to last good (shown only when an earlier attempt succeeded). The cause is a heuristic match against a fixed rule table, not a diagnosis. It never changes the attempt’s real status. The rules cover a missing environment variable, a port mismatch, a container killed for memory (OOMKilled or exit 137), registry auth, an image or tag that cannot be found, a health check timeout, a wrong Dockerfile path, a failed dependency install, and a few older npm, pip, heap, disk, and permission patterns. For the structured failure object that the API, CLI, and MCP tools return, see Deploy failures. levelrail-cli apps diagnose NAME explains a failed deploy or crashloop and can apply a fix.

Deploys across all apps

Levelrail deploy history view with one-click rollback
GET /api/v1/deployments lists deploy attempts across every app the caller can read, newest first. It is cursor paginated (limit defaults to 50, maximum 200, pass next_cursor back as cursor). An IAM Deny on app:web hides web’s deployments from the list, the summary, and the stream. The dashboard page is described in Deployments page.
  • Filters: status (queued, held, awaiting_approval, building, ready, failed, canceled, rolled_back, superseded; comma separated or repeated), app, branch, trigger (git push, manual, rollback, api, preview, schedule, pipeline), environment (name or ID), since and until (RFC3339 or a duration such as 24h, 7d), q (commit message, SHA prefix, app name), live=true (only the release currently serving each app), and pr (previews of one pull request).
  • How fields are derived: rolled_back is a succeeded deploy that a later rollback replaced (rolled_back_by). A rollback carries rollback_of, the attempt whose image it re-deployed. held is a deploy parked by a freeze window. canceled is a failed attempt whose error is a canceled context. image_ref pins the tag to a digest only when the digest is registry verified, and digest_reason says why when it is not. Commit message, author, and branch are recorded for git push deploys only. steps and the failing step are known only while an attempt runs, because step history is not persisted.
  • Summary: GET /api/v1/deployments/summary?window=24h returns counts by status (window up to 30 days), in_progress, needs_attention (held plus digest mismatch), failure_rate_24h, median and p95 duration, and per_day for 14 days.
  • Stream: GET /api/v1/deployments/stream is server-sent events of {type: created|step|finished, step?, deployment}. Only deploys that run through the build recorder emit events. A plain image redeploy appears in the list but not on the stream.
From the CLI:
The MCP tools list_deployments and deployments_summary are read-only.

Roll back

Open the deploy history and press Rollback on the row you want, one click per row.
Prior images are pinned, so garbage collection cannot remove a rollback target. See also Rollback. Two related options:
  • Auto-rollback on crashloop is opt-in per app and computes the last known-good tag itself: levelrail-cli apps auto-rollback enable NAME. An SLO burn variant is apps auto-rollback-slo-burn set. Both are covered in Observability.
  • Deploy freeze windows hold automatic deploys on a cron schedule: levelrail-cli apps freeze set|show|clear NAME.

Change configuration

Save semantics

PUT /api/v1/apps/{name} is a full replace, not a patch. It overwrites every field with what the request body carries, the same as applying an app.yaml.
A save keeps the stored settings a body cannot express: volumes, bind mounts, entrypoint, database env, registry credential, pull policy, and the pinned image ID. secret_env and vault_env change only when the body carries them. Omit them to keep the stored set, send [] to clear. Secret values are never accepted here (a body with secrets is rejected). Use PUT /api/v1/apps/{name}/secrets/{key}. Fields with their own endpoints cannot be moved by an ordinary edit: node_id, project_id, environment_id, storage_target_id, suspended, database_attachment, and log_drain. An edit can never silently move an app between nodes or projects.

Pending changes

Changing env vars or secrets does not touch the running container until it restarts.
Levelrail records what each container was created with (short hashes of env and secret values, plus port, command, entrypoint, and labels) and flags the difference until a restart lands. The dashboard shows an amber Environment changes pending restart banner on the Overview page with a one-click restart. GET /api/v1/apps/{name}/pending-changes returns { pending, changes: [{ kind: env | secret | config, keys, since }], apply_action }, with key names only and never values. Rotating a secret counts as a pending secret change. Resources and health checks apply live and are not listed. POST /api/v1/apps/{name}/apply-pending restarts the app and returns 202. For a container created before this tracking existed, only the env_dirty flag is known. From the CLI, apps status NAME prints a “pending changes” line, and apps env import and apps secrets set print “N changes pending”. Apply them with levelrail-cli apps apply NAME, or pass --apply to the command that made the change. The MCP tool is get_app_pending_changes.

App timeline

GET /api/v1/apps/{name}/timeline?limit=50&before=<cursor> merges recorded events (restart, env_change, secret_change, config_change, scale, suspend, resume, freeze_override) with deploy attempts (deploy, rollback, and in_progress for a running one), newest first. Each item has id, at, kind, status, actor (a user, token:<name>, or system), title, an optional detail, and for deploys ref: { type: "deploy_attempt", id }. Events carry env and secret key names and non-secret scalar from and to values only. Pass the previous page’s next_cursor as before. CLI: levelrail-cli apps timeline NAME. MCP: get_app_timeline.

Environment variables and secrets

Levelrail app Environment tab with plain variables and secrets Plain variables can be bulk loaded from a .env file and exported again:
  • The parser handles comments, an export prefix, single and double quotes, multiline quoted values, inline # comments on unquoted values, = inside values, empty values, and Windows line endings. When a key appears twice, the last one wins.
  • Import prints each key as new (+), changed (~), unchanged (=), or skipped (!). Keys already set as secrets are skipped. Pass --keep-existing to leave keys that already have a different value alone. Changes apply on the next restart, or now with --apply.
  • Export writes secret keys empty with a comment. Secret values are never exported.
Secrets are envelope encrypted, never returned in plaintext by the API, and injected into the container only when it is created:
  • Setting declares the key as secret-backed on the app (the list secret_env shows). Deleting undeclares it. The value reaches the container on its next creation, so run apps apply NAME or pass --apply. The API is PUT and DELETE /api/v1/apps/{name}/secrets/{key}, with ?force=true for a locked key. There is no batch endpoint, so a bulk import makes one call per key.
  • Locks guard against accidental overwrites: a locked secret cannot be changed from the dashboard or CLI until unlocked (POST /api/v1/apps/{name}/secrets/{key}/lock with { "locked": true }). A lock does not change how the value is stored.
  • Age. Every secret records when it was last set, and is flagged stale at 90 days by default. APP_SECRET_ROTATION_WARN_DAYS changes the threshold cluster-wide. The dashboard shows “Set N days ago” and a Needs rotation badge, apps secrets list has AGE and STALE columns, and levelrail-cli shared-env list --scope project|organization|environment --id ID shows the same for shared variables. GET /api/v1/system/doctor includes a stale_secrets check across all apps and scopes.

External secrets from HashiCorp Vault

A variable can resolve live from Vault instead of being stored in Levelrail:
path is the secret’s path in Vault’s KV v2 engine and key is the field inside it. vault is mutually exclusive with from and secret on the same variable. The field table is in the app.yaml reference. Configure the connection once for the instance, under Settings, Vault, with levelrail-cli vault set, or with PUT /api/v1/settings/vault. You need the Vault address, an auth method (token or approle), and a token or a role ID plus secret ID. The credential is envelope encrypted, write-only over the API, and never logged. The reconciler reads the value from Vault immediately before it creates the container, with the same resolved-at-create, never-persisted trust model as { secret: true }. If Vault is unreachable, not configured, or disabled, or the secret or field does not exist, the deploy fails with a clear error. The container never starts with the variable empty or missing. An app created directly can add or remove a Vault variable at any time, from the Environment tab, with levelrail-cli apps vault-env set NAME KEY --path PATH --key FIELD (or clear), or with PUT and DELETE /api/v1/apps/{name}/vault-env/{key}. This touches no other field.

Health checks

An app has up to two probes, readiness and liveness. Readiness gates the cutover of a deploy. Liveness runs on every reconcile pass against a running container and restarts it after consecutive failures. A probe is an HTTP(S) request (path) or a command run in the container (exec). The full field table and the Compose healthcheck: translation are in the app.yaml reference.
Or edit the Health tab, or set health: in app.yaml. The dedicated endpoints are GET, PUT, and DELETE /api/v1/apps/{name}/health. On the wire, intervals and timeouts are time.Duration JSON (nanoseconds), and the dashboard converts to whole seconds. A probe is either absent or fully specified with a path or command, and the dashboard fills sensible defaults for blank fields.

Resource limits

Four optional dimensions, each independent. A dimension you leave off runs unbounded: Set them on the Resources tab, in app.yaml, or with PUT /api/v1/apps/{name}. New limits are applied live: right after the save, they are pushed to every running replica through the Docker Engine API’s ContainerUpdate, with no recreate. The response’s resources_applied_live says whether the push reached at least one replica. With no container running, the values are saved and take effect on the next create, and the dashboard shows a “restart required” toast. Levelrail app resources tab GET /api/v1/apps/{name}/resource-recommendation is a read-only, deterministic suggestion (internal/rightsizing, no LLM, never applied automatically). It looks at memory and CPU samples over a lookback window (7 days by default), computes p95 and p99 against the current limit, and checks recent logs for OOM-kill signatures. The result has a sample count, a data_sufficient and confidence pair, and a suggested limit with a plain-English reason. See it in the Optimise panel on the Resources tab, or run levelrail-cli apps resource-recommendation NAME. With the resource advisor beta on, the same suggestion arrives as an advisor finding you can apply after a preview.

Outbound network (egress) allowlist

By default an app has unrestricted outbound access. Opting a service into an allowlist restricts its container to the declared host:port pairs. A reconciled sidecar enforces it and re-resolves each host on an interval instead of pinning an IP at deploy time.
Or without editing the spec: levelrail-cli apps egress set NAME --allow api.example.com:443, then apps egress get NAME, and apps egress clear NAME to go back to unrestricted. The dashboard has an Outbound network card on the app’s Network tab. DNS lookups and loopback traffic always stay open. After a deploy or restart there is a brief window before the sidecar finishes attaching where outbound traffic is unrestricted.

Promote between environments

An environment is a label inside a project. Promote moves one app’s current image onto a sibling app in another environment of the same project:
POST /api/v1/apps/{name}/promote looks for an app tagged with the destination environment in the source app’s project. There is no declared one-to-one link between apps in different environments, since apps are independently named. If exactly one candidate exists it is used. With more than one, pass --target. GET .../promote/preview?to=ENV_ID[&target=NAME] shows the diff first. By default only the image changes. Env vars, ports, domains, volumes, node placement, and resource limits stay the target’s own. --include-env also applies added and removed plain env keys (values of keys both apps have are never overwritten). --force promotes even when the source is unhealthy or its last deploy failed. --override-freeze with --override-reason promotes through an active freeze window.

Protected environments

Deploying to or promoting into a protected environment needs a second person to approve it.
1

Request

The requester sends the deploy or promote with confirm: true. Without it the request fails with a 409. The CLI asks for an interactive “yes” when --confirm is missing. With it, nothing is applied yet: the call creates a pending approval and returns 202 Accepted with pending_approval set instead of app.
2

Approve

A different user or token holding the deploy ability approves it with POST /api/v1/deploy-approvals/{id}/approve. The requester cannot approve or reject its own request, and the server answers 403, comparing both the principal type (user or token) and its ID. A CI token’s request must be approved by another token or a human.
3

Run

Approving runs the same path an unprotected deploy uses: desired state changes, an attempt is recorded, and the reconciler picks it up.
A pending request can also end without being approved:
  • Rejecting (POST .../reject, optional {"reason": "..."}) leaves desired state untouched.
  • A request nobody decides expires after 24 hours (APP_DEPLOY_APPROVAL_TTL, a Go duration) and can no longer be decided. Expiry is applied lazily on read, and a background sweep marks expired rows for the UI.
There is no dedicated approver role. The existing ability model decides, and deploy, directly or through the operator and admin roles, is enough.
A pending request shows as a banner on the app’s detail page with inline Approve and Reject, and the cross-app queue is at /approvals in the sidebar, badged with the pending count. The list endpoint defaults to status=pending, and status=all includes decided and expired requests. Approvals can also be decided from chat, see Chat deploy approvals.

Clone an environment

Cloning copies a whole environment, with all its apps and their configuration, into a new environment in the same project. Use it for staging, per-pull-request copies, or onboarding a tenant. It copies configuration, not data. Preview first, then clone:
For each app in the source environment the clone creates a new app with the same image and port, env vars (app level and shared environment level), resources, health checks, volumes and bind mounts, labels, command and entrypoint, pull policy, scheduled tasks (with fresh run history), registry credential, auto-rollback, exec-enabled and log drain settings, egress policy, hooks, storage target, replicas, and strategy. After the clone, the apps start deploying through the normal reconcile path. Check them with levelrail-cli apps status NAME, add domains with apps domains add NAME DOMAIN, set secrets with apps secrets set NAME KEY VALUE, and attach databases with apps database set NAME --database-name DB. The API is GET /api/v1/environments/{id}/clone/preview?new_environment_name=NAME and POST /api/v1/environments/{id}/clone:
The apps array is optional. Omit it for auto-suggested names and no domains, or list only the apps you want to customize.

Troubleshooting a clone

The environment exists. Check levelrail-cli apps list, finish the rest by hand, or delete the partial environment with levelrail-cli apps environments delete ENV_ID and retry.
Another app owns it. Pick a different domain or remove it from the other app first.
Only secrets that had a value in the source are copied. Set the rest by hand on the clone.
Read levelrail-cli apps deploys list CLONE for the error. Usual causes are limits that are too tight, a failed image pull, or a readiness probe that times out.
To duplicate a single app instead, use levelrail-cli apps clone NAME NEW_NAME.

Operate a running app

Lifecycle actions

Restart gets a fresh restart_nonce folded into the container name hash, so “same image, new nonce” looks like a change to the cutover logic and the usual readiness-gated rollout applies. In the dashboard, each row of the Apps list has an actions menu with Restart, Stop or Start, Redeploy, View logs, View deploys, and Open domain (only when the app has a domain). Stop asks for confirmation. Redeploy re-triggers the current image tag, and lands in the approvals queue when the environment requires approval. The app header has the same Restart, Stop or Start, and Redeploy buttons, plus Promote, Clone, and Delete. The empty Apps list and the welcome screen offer Create app, Browse templates, and Connect Git.

One-off commands and the terminal

POST /api/v1/apps/{name}/exec runs one command and returns its result.
  • Input: command is required. args is optional and never shell interpreted (raw argv). timeout_seconds may shorten but never extend the 30 second server ceiling.
  • Output: stdout, stderr, and exit_code, capped at 1 MiB (truncated: true past that). A nonzero exit code still returns HTTP 200, because the exec worked and the command failed.
  • Permission: the root ability, not deploy. Exec can be switched off per app (it is on by default) with levelrail-cli apps exec-access disable NAME, and a disabled app answers 403.
Secrets are injected as plaintext env vars when the container is created, so env inside a shell reads them back. Exec sits at the same trust tier as node management, not deploy.
The interactive terminal is GET /api/v1/apps/{name}/terminal over WebSocket: a full PTY with resize, a shell kept alive across requests, arrow keys and Ctrl-C working, behind the same root gate. In the dashboard, the Exec tab shows the terminal above the one-shot runner. The one-shot endpoint is deliberately not a shell. A remote command stuck on a container-side read is not guaranteed to stop the instant the timeout fires, only the HTTP handler is guaranteed to return on time.

Scheduled tasks

A scheduled task runs an argv command, with no shell, inside an app’s currently running container on a standard 5-field cron expression. It is cron inside a container.
The dashboard’s Jobs tab does the same, and adds fresh-container runs, run history, retries and catch-up: see Scheduled jobs. update is a full replace: resupply command, schedule, and concurrency_policy, not only the field you are changing. Each task tracks last_run_at, last_run_status, last_run_output, and a consecutive_failures counter (the input for scheduled_task_failure alert rules). That is the whole run history: there is no log of every past run. POST .../scheduled-tasks/{id}/run (CLI run) uses the exact code path of a real cron tick. It dispatches from a background goroutine and returns 202 Accepted right away, not when the command finishes. concurrency_policy decides what happens when a run is due while the previous one is still going:

Delete and clean up

Deleting something never relies on a single best-effort call. Every path that removes containers (delete an app, move it to another node, prune a stale compose service, tear down a preview, drain a node) first records a tombstone and clears it only when the containers are confirmed gone, so a failed stop retries on every reconcile pass and shows up as teardown_pending. A tombstone for a node that was since removed from the fleet is dropped instead of retrying forever.

Projects, environments and organizations

By default deleting a project, environment or organization only removes the label: its apps and databases keep running, detached. To tear everything inside down too, pass cascade=true.
The dashboard delete dialogs have an Also delete everything inside checkbox. The API is DELETE /api/v1/{projects|environments|organizations}/{id}?cascade=true. Apps go first, then databases, then the container itself is removed last. If anything cannot be removed (a database still used by an app outside the scope, a database error) the response is 207 with the per-resource result, the container is kept, and repeating the same request resumes where it stopped. Database data volumes and app volumes are kept, they show up under leftovers below.

Leftovers and the orphan reaper

A reaper compares what exists on every node with desired state and removes what nothing accounts for, so a crash between “delete desired state” and “stop the container” cannot leave something running forever. It looks at three kinds of leftovers: A resource is removed only after it has stayed orphaned for the grace period, counted from the first pass that saw it. Each removal writes an audit log entry (REAP, actor orphan-reaper). A pass removes at most APP_ORPHAN_MAX_REMOVALS resources, and removes nothing at all while no app or database exists, so an empty database read cannot wipe a node.
The same view is in Settings, Containers in the dashboard. The reaper runs on the reconcile loop (at most every APP_ORPHAN_INTERVAL). Remote nodes are scanned through their agent for containers and networks; volumes are only scanned on the control plane’s own node. An unreachable node is reported and skipped, never treated as empty. Changing or removing an app’s domain drops its route on the next ingress pass, and its certificate is removed by the reaper after the certificate grace period.

Removing a node

DELETE /api/v1/nodes/{id} is refused (409) while apps or databases are placed on the node. Drain it first (POST /api/v1/nodes/{id}/drain), which moves every placement. Draining an unreachable node still works: the placements move, and the leftover containers on the dead node are dropped from the retry list when the node is deleted. If the node later re-enrols, the reaper removes any copy of an app that now lives elsewhere. Volumes are not moved by a drain: an app with data on a dead node needs apps set-node NAME NODE --with-volumes while the node is still reachable, or a restore from backup.

API and CLI quick reference

The CLI groups below cover an app’s own lifecycle. Run levelrail-cli apps SUBCOMMAND -h for any command’s flags, and see the CLI reference for the rest.

Known limits

  • apps rollback needs a tag. Use apps deploys rollback-to with a deploy ID to roll back without knowing the tag.
  • No per-service history for deploy-spec. It writes no deploy attempt per service, only the synchronous response. A per-service log is a known, deferred schema change.
  • GET /apps/{name}/deploys is current reconcile status, not a log. It keeps only the latest condition per controller and type. GET .../deploy-attempts is the append-only history.
  • Compose and deploy-spec are synchronous. Neither streams build progress the way a single-service build does, so a large multi-service spec is a slow HTTP request.
  • Scheduled task history is last run only, and replace cancellation is best effort.

See also

Rollback

One-click and automatic rollback in depth.

Deploy safety

Freeze windows, stale deploys, and what protects a rollout.

Deploy failures

How failures are structured and explained.

app.yaml reference

Every field of the deployment spec.

Git integrations

Trigger deploys from git push and pull requests.

Domains and ingress

Custom domains and HTTPS certificates.

Managing databases

Attach Postgres, MySQL, Redis, and others.

CLI reference

Every command, flag, and exit code.
Last modified on October 12, 2026