# CLI reference

Every deplane command with its flags, a human example, and its `--json` output.

Run `deplane help` for a short list. Commands that act on an app use the app in `./deplane.toml`, or the one you pass with `--app NAME`.

## Global flags

These flags work with every command, anywhere on the command line.

| Flag | What it does |
|---|---|
| `--json` | Print exactly one JSON document on stdout. Streams print one JSON object per line. Progress messages still go to stderr. |
| `--yes` | Answer yes to every confirmation. |
| `--debug` | Log each HTTP request and response to stderr. Tokens, cookies, `Authorization` headers, and secret values are replaced with `[REDACTED]`. |
| `--app NAME` | The app to act on. Default: `app` in `./deplane.toml`. |
| `--help` | Show the command list. |

Without a terminal, the CLI never asks a question. A command that needs confirmation fails with [confirmation_required](errors.md#confirmation_required) and tells you to pass `--yes`.

Flags that take a value accept `--flag VALUE` or `--flag=VALUE`.

## Environment variables

| Variable | What it does |
|---|---|
| `DEPLANE_TOKEN` | Use this token instead of the stored login. |
| `DEPLANE_API_URL` | The API to use. Default `https://api.deplane.io`. |
| `DEPLANE_ID_TOKEN` | In a GitLab CI job: the ID token (`id_tokens:`, audience your API URL). Used when `DEPLANE_TOKEN` is not set, exchanged for a 15-minute pipeline token, never printed. |
| `BITBUCKET_STEP_OIDC_TOKEN` | In a Bitbucket Pipelines step with `oidc: true`: used like `DEPLANE_ID_TOKEN`. |
| `DEPLANE_TEAM` | In a pipeline job: the team's slug, when one repository is trusted by apps of several teams. |
| `HEROKU_API_KEY` | The Heroku token for [`deplane import heroku`](#deplane-import-heroku) when none is piped in. Sent only to Heroku. |
| `RENDER_API_KEY` | The Render API key for [`deplane import render`](#deplane-import-render) when none is piped in. Sent only to Render. |
| `RAILWAY_TOKEN` | The Railway token for [`deplane import railway`](#deplane-import-railway) when none is piped in. Sent only to Railway. |
| `CLOUDFLARE_API_TOKEN` | The Cloudflare API token for [`deplane domains switch`](#deplane-domains-switch) when none is piped in. Sent only to Cloudflare. |
| `FLY_API_TOKEN` | The Fly.io token for [`deplane import fly`](#deplane-import-fly) when none is piped in. Sent only to Fly.io. |
| `VERCEL_TOKEN` | The Vercel token for [`deplane import vercel`](#deplane-import-vercel) when none is piped in. Sent only to Vercel. `VERCEL_TEAM_ID` names the project's team when the argument does not. |
| `NETLIFY_AUTH_TOKEN` | The Netlify token for [`deplane import netlify`](#deplane-import-netlify) when none is piped in. Sent only to Netlify. |
| `CPANEL_API_TOKEN` | The cPanel API token for [`deplane import cpanel`](#deplane-import-cpanel) when none is piped in. Sent only to cPanel. |

## Exit codes

The exit codes are stable. Scripts and agents can rely on them.

| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | User error: a bad flag, a bad value, a missing app, a cancelled confirmation, a suspended app (`app_suspended`), and any other refusal. |
| 2 | Authentication or permission error: not logged in, login expired, a fresh passkey confirmation is needed, or your role or token may not do this. |
| 3 | The build failed. |
| 4 | The deploy failed: the new release did not become healthy. |
| 5 | Platform error: the API could not be reached, or something failed on our side. |
| 6 | Quota exceeded: every error code that starts with `limit_` exits with 6. |

## Errors

Every error says what happened and how to fix it. Without `--json` it goes to stderr:

```sh
$ deplane scale 9
Error: This needs 9 instances in total and your plan allows 3.
Fix: Use fewer instances, scale down another app, or email support@deplane.io to raise the limit.
```

With `--json` the error is the one JSON document on stdout:

```json
{
  "error": {
    "code": "limit_instances",
    "message": "This needs 9 instances in total and your plan allows 3.",
    "fix": "Use fewer instances, scale down another app, or email support@deplane.io to raise the limit.",
    "exit_code": 6
  }
}
```

Every code is listed in [Troubleshooting and error codes](errors.md).

## Account

### deplane login

Log in through your browser with your passkey. The CLI shows a code, opens https://app.deplane.io/device, and waits until you approve. The token is stored in your OS keychain, or in a file only you can read if there is no keychain. It lasts 30 days. See [Install](install.md#log-in) for the full flow.

No flags besides the global ones. It refuses to run while `DEPLANE_TOKEN` is set.

```sh
$ deplane login

To log in, open https://app.deplane.io/device and type this code:

    WDJB-MJHT

Only approve it if you just ran this command yourself. The code expires in 10 minutes.

Logged in as you@example.com (organization you-example).
```

```json
{
  "email": "you@example.com",
  "logged_in": true,
  "org": "you-example",
  "stored_in": "keychain"
}
```

`stored_in` is `keychain` or `file`.

### deplane logout

Revoke this computer's token and remove it.

```sh
$ deplane logout
Logged out. The token was revoked and removed from this computer.
```

```json
{
  "logged_out": true,
  "was_logged_in": true
}
```

### deplane whoami

Show who you are logged in as, your organization, and its plan limits.

```sh
$ deplane whoami
Logged in as you@example.com
Organization: You (you-example)
```

```json
{
  "entitlements": {
    "memory_mb": 1024,
    "apps": 1,
    "instances": 2,
    "sizes": ["small"],
    "build_minutes_month": 500,
    "concurrent_builds": 1,
    "log_retention_days": 7,
    "log_streams": 2,
    "assistant_enabled": true,
    "assistant_requests_month": 100,
    "backup_retention_days": 1,
    "disk_gb": 5,
    "redis": 1,
    "mysql": 0,
    "clickhouse": 0,
    "databases": 1,
    "members": 1,
    "custom_domains": 3,
    "previews": false,
    "always_on": false
  },
  "org": {
    "id": "org_2b7k9m4x1q8w5e3r6t0y",
    "name": "You",
    "plan_id": "pln_free",
    "plan_name": "Free",
    "slug": "you-example"
  },
  "user": {
    "email": "you@example.com",
    "id": "usr_8c3v6b1n9m4k2j7h5g0f",
    "is_admin": false
  }
}
```

### deplane version

Print the CLI version.

```sh
$ deplane version
deplane 0.1.0
```

```json
{
  "version": "0.1.0"
}
```

### deplane doctor

Check that the API answers, that you are logged in, whether this folder is a project Deplane can build, and whether `deplane.toml` is valid. Each failed check has a fix. The exit code is the code of the first failed check, or 0.

```sh
$ deplane doctor
ok   api      The Deplane API at https://api.deplane.io answers.
FAIL login    You are not logged in, or the login expired.
              Fix: Run `deplane login`, or set DEPLANE_TOKEN.
ok   project  Detected Flask 3, Python 3.12, port 8000.
ok   config   deplane.toml is valid (app my-api).
```

```json
{
  "ok": false,
  "checks": [
    {"name": "api", "ok": true, "detail": "The Deplane API at https://api.deplane.io answers."},
    {"name": "login", "ok": false, "detail": "You are not logged in, or the login expired.", "fix": "Run `deplane login`, or set DEPLANE_TOKEN."},
    {"name": "project", "ok": true, "detail": "Detected Flask 3, Python 3.12, port 8000."},
    {"name": "config", "ok": true, "detail": "deplane.toml is valid (app my-api)."}
  ]
}
```

## Tokens

### deplane tokens list

List your API tokens. `kind` is `cli` for logins, `ci` for tokens made with `tokens create`, and `agent` for tokens from `agent login`.

```sh
$ deplane tokens list
ID                         NAME            KIND  EXPIRES     LAST USED
tok_5h2j8k1l4z7x9c3v6b0n   macbook         cli   2026-11-12  2026-10-13
tok_1q9w3e7r5t2y8u4i6o0p   github-actions  ci    2027-01-11  never
```

```json
{
  "tokens": [
    {
      "id": "tok_1q9w3e7r5t2y8u4i6o0p",
      "name": "github-actions",
      "kind": "ci",
      "expires_at": "2027-01-11T09:00:00Z",
      "last_used_at": null,
      "created_at": "2026-10-13T09:00:00Z"
    }
  ]
}
```

### deplane tokens create

Create a CI token for CI, agents, and scripts: `deplane tokens create NAME`. The token is shown once. Store it as `DEPLANE_TOKEN` in your CI secrets.

Creating a token needs a fresh confirmation with your passkey from the last 5 minutes. In a terminal, the CLI opens your browser for it and then continues. CI tokens cannot create tokens.

| Flag | What it does |
|---|---|
| `--name NAME` | The token's name, instead of the positional argument. |
| `--expires-in-days N` | Lifetime from 1 to 365 days. Default 90. |

```sh
$ deplane tokens create github-actions --expires-in-days 90
Token "github-actions" (tok_1q9w3e7r5t2y8u4i6o0p) created; it expires on 2027-01-11. It is shown only once: store it in your CI secrets as DEPLANE_TOKEN.
dpl_4Fh7Kq2Lm9Np3Rs6Tv8Wx1Yz5Ab0Cd
```

The message goes to stderr and only the token goes to stdout, so `deplane tokens create ci > token.txt` saves just the token.

```json
{
  "token": "dpl_4Fh7Kq2Lm9Np3Rs6Tv8Wx1Yz5Ab0Cd",
  "id": "tok_1q9w3e7r5t2y8u4i6o0p",
  "name": "github-actions",
  "kind": "ci",
  "expires_at": "2027-01-11T09:00:00Z"
}
```

### deplane tokens revoke

Revoke a token at once: `deplane tokens revoke ID`. Asks for confirmation; pass `--yes` to skip it.

```sh
$ deplane tokens revoke tok_1q9w3e7r5t2y8u4i6o0p --yes
Revoked tok_1q9w3e7r5t2y8u4i6o0p.
```

```json
{
  "revoked": "tok_1q9w3e7r5t2y8u4i6o0p"
}
```

### deplane agent login

Get a scoped token for an AI agent: `deplane agent login --role ROLE`. You approve it in your browser, which shows the role, the apps and how long it lasts. The token is printed once, for the agent's `DEPLANE_TOKEN`, and never saved. Roles and limits: [Agent tokens](agents.md#agent-tokens).

| Flag | What it does |
|---|---|
| `--role ROLE` | `viewer`, `deployer` or `operator`. Required. |
| `--app A,B` | Limit the token to these apps. Default: all the team's apps. |
| `--name NAME` | The agent's name, shown on the approval page, in the email and in the activity log. Default `agent`. |
| `--ttl D` | Lifetime: `12h`, `7d`, up to `30d`. Default 7 days. |

```sh
$ deplane agent login --role deployer --app web --name claude-code
Approved: a deployer token for dev@example.com (organization acme). It is shown only once; use it as DEPLANE_TOKEN. Revoke it with `deplane tokens revoke`.
dpl_4Fh7Kq2Lm9Np3Rs6Tv8Wx1Yz5Ab0Cd
```

```json
{
  "token": "dpl_4Fh7Kq2Lm9Np3Rs6Tv8Wx1Yz5Ab0Cd",
  "role": "deployer",
  "email": "dev@example.com",
  "org": "acme"
}
```

## Apps and deploys

### deplane launch

Detect how to build this folder, create the app, write `deplane.toml`, build, deploy, and print the URL. See the [Quickstart](quickstart.md) for what it detects.

| Flag | What it does |
|---|---|
| `--name NAME` | The new app's name. Default: the folder name, with a random suffix if that name is taken. |
| `--app NAME` | Deploy this folder to an app you already have (the same as `deplane deploy --app NAME`). |
| `--repo OWNER/REPO[@REF]` | Deploy a public GitHub repository that has a `deplane.template.json`, instead of the folder. See below. |
| `--image REF` | Deploy a public Docker image instead of the folder. See below. |
| `--port PORT` | With `--image`: the port the image listens on. It goes with the build and becomes the app's port when that build is released, so an image that is refused or fails to build leaves the port as it was; once released, the port stays even if that release fails its health check or is rolled back. Default 8080. |

`deplane launch --image REF [--name APP] [--port PORT]` creates the app from a public Docker image, with no source code. `REF` is `[registry/]name[:tag][@sha256:DIGEST]`, for example `nginxinc/nginx-unprivileged:1.27`; the app's name defaults to the image's name. Deplane builds the image into a release like any deploy, so the [release command](release-command.md) runs too. Only public images work, and Deplane runs them as user 10001, not root; see [Deploy a Docker image](quickstart.md#deploy-a-docker-image). No `deplane.toml` is written, because an image has no folder: pass `--app NAME` to later commands. To update the app to a new image, run `deplane deploy --image REF --app NAME`. `--image` cannot be combined with a folder, a path or `--template`; it fails with [image_with_source](errors.md#image_with_source). A bad reference fails with [invalid_image](errors.md#invalid_image), and a private registry with [private_registry_unsupported](errors.md#private_registry_unsupported).

```sh
$ deplane launch --image nginxinc/nginx-unprivileged:1.27 --port 8080
Created app nginx-unprivileged.
Build bld_7c3x9k2m4q8w1e5r6t0y queued for nginxinc/nginx-unprivileged:1.27.
...
Deployed nginx-unprivileged v1: https://nginx-unprivileged.deplane.app
```

`deplane launch --template NAME [--name APP]` deploys a template instead of the folder: Deplane creates the app (and a database and disk if the template needs them), sets its secrets, builds it from Deplane's own copy of the template and rolls it out, while the command shows each step. It ends with the app's URL and what to do next, for example "Open the URL and create the first admin account". If a step fails, everything made so far is kept and the command exits with code 4 and tells you how to retry or delete it. `--no-wait` returns at once. See [Templates](templates.md).

`deplane launch --repo OWNER/REPO[@REF] [--name APP]` deploys a public GitHub repository that carries a `deplane.template.json` at its root, pinned to a commit: the command first prints what the file asks for (the app, its database, Redis, MySQL, ClickHouse database or disk, the variables it sets, its warning) and a reminder that Deplane has not reviewed the repository, then deploys and follows the steps exactly like `--template`. `REF` is a branch, a tag or a commit (default: the default branch); `OWNER/REPO` may also be written as `github.com/owner/repo` or the repository's URL. It cannot be combined with `--template` or `--image`. See [Templates](templates.md#deploy-from-your-own-repository).

Run again in the same folder, `launch` deploys that folder's app again. It never deploys a folder over a different existing app: `--name` with the name of an app you already have stops with an error, and a folder whose name matches an existing app gets a new app with a suffix.

```sh
$ deplane launch
Detected Next.js 15, Node 22, port 3000
Created app my-shop.
Wrote deplane.toml.
Uploading 214 files (1.3 MB)...
Build bld_4m8q2k7x9c1v5b3n6z0a queued.
Building...
...
Built 4f1c2a9e8b7d. Rolling out release v1...
Running: 1 of 1 instances ready.
Deployed my-shop v1: https://my-shop.deplane.app
```

With `--json`, progress and the build log still go to stderr, and stdout gets one document:

```json
{
  "app": "my-shop",
  "build": "bld_4m8q2k7x9c1v5b3n6z0a",
  "created": true,
  "image_digest": "sha256:4f1c2a9e8b7d...",
  "release": 1,
  "status": "running",
  "url": "https://my-shop.deplane.app"
}
```

A failed build exits with 3. A release that does not become healthy exits with 4.

### deplane deploy

Build and deploy this folder's app again. It applies `deplane.toml` first. It does not create apps: without a `deplane.toml` and without `--app`, it fails with [no_app](errors.md#no_app).

| Flag | What it does |
|---|---|
| `--app NAME` | Deploy this folder to this app. |
| `--image REF` | Deploy a public Docker image to the app instead of the folder, for example `deplane deploy --image ghcr.io/acme/web:2.4 --app web`. The image replaces the app's current release when it is healthy. |
| `--port PORT` | With `--image`: the port the image listens on; it becomes the app's port when the build is released. With a `deplane.toml`, its other `[service]` settings (health check, release command) are still applied before the build. |

`deplane.toml`'s `release_command` is sent with the other `[service]` settings, and a deploy that has a [release command](release-command.md) prints "Running the release command..." while it runs, waits up to 15 minutes more, and on failure prints the command's output and exits with code 4. `--image` takes no folder or path: see [`deplane launch`](#deplane-launch) for the image rules.

```sh
$ deplane deploy
Detected Next.js 15, Node 22, port 3000
Uploading 214 files (1.3 MB)...
Build bld_9t2w6r1y8u3i5o7p4a0s queued.
Building...
...
Built 0b9e7d61a2c3. Rolling out release v2...
Running: 1 of 1 instances ready.
Deployed my-shop v2: https://my-shop.deplane.app
```

```json
{
  "app": "my-shop",
  "build": "bld_9t2w6r1y8u3i5o7p4a0s",
  "created": false,
  "image_digest": "sha256:0b9e7d61a2c3...",
  "release": 2,
  "status": "running",
  "url": "https://my-shop.deplane.app"
}
```

### deplane redeploy

Build the app's last source again (an upload or a GitHub commit) and release it, for example after changing a build variable with `deplane secrets set --build`. It counts toward the 10 deploys per hour.

```sh
$ deplane redeploy
Rebuilding shop from its last source. Build bld_… queued.
Deployed shop v8: https://shop.deplane.app
```

### deplane apps

List your organization's apps. An autoscaled app shows its range in `INSTANCES` (for example `1-5`), and an app that is asleep shows `sleeping` in `STATUS`.

```sh
$ deplane apps
NAME     STATUS  RELEASE  INSTANCES  SIZE   URL
my-shop  active  v2       1          small  https://my-shop.deplane.app
my-api   active  v5       2          small  https://my-api.deplane.app
```

```json
{
  "apps": [
    {
      "id": "app_7k2m9x4q1w8e5r3t6y0u",
      "name": "my-shop",
      "region": "fra",
      "status": "active",
      "url": "https://my-shop.deplane.app",
      "current_release": 2,
      "instances": 1,
      "size": "small",
      "port": 3000,
      "health_check": {"type": "tcp"},
      "created_at": "2026-10-13T09:12:00Z"
    }
  ]
}
```

`status` is `active` or `suspended`. `health_check` is `{"type": "tcp"}` or `{"type": "http", "path": "/healthz"}`.

### deplane apps destroy

Destroy any app by name: `deplane apps destroy NAME`. Its instances, releases, and secrets are deleted, and its [disk](disks.md) if it has one (the question says so), and the name becomes available to others. Asks for confirmation; pass `--yes` to skip it. It also needs a confirmation in your browser: the CLI prints a link and a code (and opens the link), you approve with your passkey, and the command continues. `--yes` does not skip this, and CI tokens cannot do it at all.

```sh
$ deplane apps destroy my-old-app --yes
Destroyed my-old-app.
```

```json
{
  "destroyed": "my-old-app"
}
```

### deplane destroy

Destroy this folder's app, or the one named by `--app`. Same as `deplane apps destroy`, including the browser confirmation.

```sh
$ deplane destroy --app my-shop --yes
Destroyed my-shop.
```

```json
{
  "destroyed": "my-shop"
}
```

### deplane status

Show the app's URL, release, instances, health, the last build, and the last error with its fix.

```sh
$ deplane status
App:       my-shop (active)
URL:       https://my-shop.deplane.app
Release:   v3
Instances: 0 of 1 ready, size small
State:     Failed
Problem:   HealthCheckFailed: The app started but did not pass its health check on port 3000. Make sure it listens on $PORT.
Fix:       Make the app listen on 0.0.0.0 at the port in $PORT, and make health_check (if set) answer 200. Run `deplane logs` to see why it failed.
Last build: bld_9t2w6r1y8u3i5o7p4a0s, succeeded
```

`State` is `Pending` (no release yet), `Deploying`, `Running`, or `Failed`.

```json
{
  "app": "my-shop",
  "url": "https://my-shop.deplane.app",
  "status": "active",
  "release": 2,
  "phase": "Running",
  "reason": "",
  "message": "1 of 1 instances ready.",
  "fix": "",
  "instances": 1,
  "ready_instances": 1,
  "size": "small",
  "last_build": {
    "id": "bld_9t2w6r1y8u3i5o7p4a0s",
    "status": "succeeded",
    "builder": "railpack",
    "position": 0,
    "image_digest": "sha256:0b9e7d61a2c3...",
    "release": 2,
    "created_at": "2026-10-13T09:40:00Z",
    "finished_at": "2026-10-13T09:41:10Z",
    "seconds": 70,
    "failure": null
  }
}
```

`reason` is empty, or one of `Unschedulable`, `ImagePullFailed`, `HealthCheckFailed`, `Crashing`, `Timeout`, and `ReleaseCommandFailed`. `last_build` is `null` before the first build. A failed build has `failure` set to `{"code", "message", "fix"}`.

### deplane scale

Change the number of instances, the size, autoscaling, or when the app sleeps: `deplane scale [N] [--size small|medium|large] [--max M] [--sleep 5m|15m|60m|off]`. `deplane scale 0` stops the app without deleting it. The change starts at once; it does not create a release.

| Flag | What it does |
|---|---|
| `--size SIZE` | `small`, `medium`, or `large`. See [Limits](limits.md#instance-sizes). |
| `--max M` | Autoscale between `N` and `M` instances. `--max 0` turns autoscaling off. |
| `--sleep TIME` | Put the app to sleep after `5m`, `15m`, or `60m` without requests; the next request wakes it. `off` turns sleeping off. On Free, only `5m` or `15m` ([limit_sleep](errors.md#limit_sleep)). |

`deplane status` shows the autoscale range, and `Sleeping since HH:MM UTC` while the app sleeps. The JSON for `scale`, `apps`, and `status` includes `max_instances` (`null` when off), `sleep_after_minutes`, `sleep_state`, and `slept_at`.

```sh
$ deplane scale 2 --size medium
my-shop now runs 2 medium instance(s).
```

Autoscale between 1 and 5 instances and sleep after 15 idle minutes (see [Scaling](scaling.md)):

```sh
$ deplane scale 1 --max 5 --sleep 15m
```

The JSON is the app, as in `deplane apps`:

```json
{
  "id": "app_7k2m9x4q1w8e5r3t6y0u",
  "name": "my-shop",
  "region": "fra",
  "status": "active",
  "url": "https://my-shop.deplane.app",
  "current_release": 2,
  "instances": 2,
  "size": "medium",
  "port": 3000,
  "health_check": {"type": "tcp"},
  "created_at": "2026-10-13T09:12:00Z"
}
```

### deplane releases

List the app's releases, newest first (up to 50). A release is a build plus its settings and secret names. `STATUS` is `pending`, `active`, `superseded` (it was live and a newer release took over), `replaced` (a newer release took over before it went live), or `failed`.

```sh
$ deplane releases
VERSION  STATUS      IMAGE         CREATED           DESCRIPTION
v3       failed      7a1b2c3d4e5f  2026-10-13 10:02  Deploy (HealthCheckFailed)
v2       active      0b9e7d61a2c3  2026-10-13 09:41  Deploy
v1       superseded  4f1c2a9e8b7d  2026-10-13 09:12  Deploy
```

```json
{
  "releases": [
    {
      "version": 2,
      "status": "active",
      "image_digest": "sha256:0b9e7d61a2c3...",
      "description": "Deploy",
      "failure_reason": "",
      "created_at": "2026-10-13T09:41:00Z"
    }
  ]
}
```

When any release has a [release command](release-command.md), a `RELEASE COMMAND` column shows its state: `pending`, `running`, `succeeded`, `failed`, `skipped`, or `-` for none. With `--json`, each release has `release_command_status` and, when it failed, `release_command_output` (its last lines).

Descriptions are `Deploy`, `Set secrets NAMES`, `Unset secrets NAMES`, `Rollback to vN`, or `Automatic rollback to vN: X% of requests failed after vM` (see [Automatic rollback](#automatic-rollback)). In `--json`, `automatic_rollback` is `true` for a release Deplane made by rolling back automatically, and `released_at` is when a release went live.

### deplane rollback

Roll back to the previous release that ran successfully, or to a version: `deplane rollback [VERSION]`. Both `3` and `v3` work. A rollback creates a new release from the earlier release's build and settings. Asks for confirmation; pass `--yes` to skip it.

```sh
$ deplane rollback 1 --yes
Created release v4 (Rollback to v1). Run `deplane status` to follow the rollout.
```

```json
{
  "version": 4,
  "status": "pending",
  "image_digest": "sha256:4f1c2a9e8b7d...",
  "description": "Rollback to v1",
  "failure_reason": "",
  "created_at": "2026-10-13T10:05:00Z"
}
```

### Automatic rollback

When a new release starts failing right after it goes live, Deplane rolls it back for you. For the first 10 minutes after a release goes live, Deplane checks the errors your app returns every minute and rolls back to the previous good release when all of these hold:

- at least 50 requests and at least 10 errors from the app over at least two minutes of its own traffic (the minute it went live and the next one are left out: they can still hold the previous release's requests);
- more of them failed than your team's error-rate threshold (5% unless you changed it on **Alerts**);
- the rate is at least 3 times the previous release's over its last 30 minutes (or the previous release had no errors);
- the errors reached visitors on at least 3 different networks (a /24 for IPv4, a /48 for IPv6).

Only 5xx responses your app returned count. 502, 503 and 504 do not: Deplane's router sends those itself when no instance answers in time, which a flood of requests or a rollout can cause. Requiring several networks means one visitor who keeps opening a page that fails cannot roll your app back on their own. To count networks, Deplane keeps a keyed hash of each network that got an error, never the address, for one day. The errors alert still counts every 5xx.

The rollback is a new release, exactly like `deplane rollback`, described as `Automatic rollback to v1: 42% of requests failed after v2`. Your team's alert channels get "Rolled back my-shop to v1", and it shows on **Alerts**. If the GitHub commit's status said success, it becomes a failure. Deplane never rolls back the same release twice, and never rolls back an automatic rollback; `deplane rollback` without a version skips a release that was rolled back automatically. If the rollback's own release does not start, the release before it keeps serving and your team gets "Automatic rollback of my-shop failed: still on v2" with the reason. It is on for every app; turn it off with `deplane config --auto-rollback off` or in the app's Settings tab.

### deplane config

Show the app's port, health check, automatic rollback, release command and private network. Three settings can be changed here, and they apply right away (the release command from the next deploy). The port and health check come from `deplane.toml` and apply from the next deploy.

| Flag | What it does |
|---|---|
| `--auto-rollback on` or `off` | Turn [automatic rollback](#automatic-rollback) on or off. |
| `--release-command "CMD"` | Set the [release command](release-command.md) that runs before each new build serves. `--release-command ""` removes it. It is one line, and it takes effect with the next deploy. |
| `--private-network on` or `off` | Let the team's other apps reach this app at `http://APP`: see [Private networking](private-networking.md). It cannot be on while the app sleeps ([private_network_sleep](errors.md#private_network_sleep)), and a pull request preview cannot use it. |

The flags can be combined in one command. Another value than `on` or `off` fails with [invalid_auto_rollback](errors.md#invalid_auto_rollback) or [invalid_private_network](errors.md#invalid_private_network).

```sh
$ deplane config
Port             8080
Health check     HTTP /healthz
Auto rollback    on
Release command  python manage.py migrate
Private network  on, at http://my-shop

$ deplane config --auto-rollback off
Automatic rollback is off for my-shop. Roll back by hand with `deplane rollback`.

$ deplane config --release-command "npm run migrate"
Release command for my-shop: npm run migrate
It runs before each new build's release starts serving, beginning with your next deploy.

$ deplane config --private-network on
Private network is on for my-shop: your team's other apps reach it at http://my-shop.
```

With `--json`, all of them print the app, including `"auto_rollback": false`, `"release_command": "npm run migrate"`, `"private_network": true`, and `"private_url": "http://my-shop"` (empty when the private network is off).

### deplane open

Open the app's URL in your browser and print it. Without a terminal it only prints it.

```sh
$ deplane open
https://my-shop.deplane.app
```

```json
{
  "url": "https://my-shop.deplane.app"
}
```

### deplane logs

Show the app's recent log lines, or follow them live.

| Flag | What it does |
|---|---|
| `-f`, `--follow` | Keep streaming new lines until you press Ctrl-C. |
| `--tail N` | Lines per instance, from 0 to 1000. Default 100. |
| `--process NAME` | Only the lines of one process: `web`, a worker or cron job's name, or `release` for the [release command](release-command.md). With a run's ID (`run_...`), only that [run](#deplane-run)'s output. |

```sh
$ deplane logs --tail 2
x7k2p | ready - started server on 0.0.0.0:3000
x7k2p | GET / 200 in 12ms
```

Each line starts with the short name of the instance that wrote it. With `--json`, each line is its own JSON object:

```json
{"instance": "x7k2p", "line": "ready - started server on 0.0.0.0:3000"}
{"instance": "x7k2p", "line": "GET / 200 in 12ms"}
```

Your organization can follow at most 3 live streams at once; see [limit_log_streams](errors.md#limit_log_streams).

### deplane run

Run a command once in the app's live release and show its output: `deplane run [--app NAME] [--size SIZE] [--no-wait] -- COMMAND ARGUMENTS`.

| Flag | What it does |
|---|---|
| `--size SIZE` | The size to run at: `small` (default), `medium` or `large`, up to your plan's largest. |
| `--no-wait` | Start the run and return at once, printing its ID. |

Everything after `--` is the command, and each word reaches it as you typed it: spaces, quotes, `$` and `;` are kept, not interpreted. To use a pipe, `&&` or variables, run a shell yourself: `deplane run -- sh -c "cmd1 && cmd2"`.

The command runs in the release that was live when the run started, with the app's variables and database access, and no disk. It does not take traffic, and it has no input and no terminal: an interactive shell is not available. Its output goes to stdout, and the progress to stderr. The exit code of `deplane run` is the command's. A run that ended without one, because it was cancelled, ran past its hour, or found no room to start, exits with 1 and says why. When the team's slots are busy, a run waits up to an hour for a free one before it starts. Only the first 10 MB of output is shown; past that, `deplane run` says so and waits for the command to finish. Press Ctrl-C to cancel the run; it exits with 130.

```sh
$ deplane run -- python manage.py seed
Started run run_4m8q2k7x9c1v5b3n6z0a in my-shop (release v3).
seeded 3 rows

$ deplane run --size medium -- sh -c "python backfill.py && echo done"
$ echo $?
0

$ deplane run --no-wait -- node cleanup.js
Started run run_9t2w6r1y8u3i5o7p4a0s in my-shop (release v3).
See its output while it runs with `deplane logs -f --process run_9t2w6r1y8u3i5o7p4a0s --app my-shop`.
```

With `--json`, the output goes to stderr and stdout gets one document, the finished run (with `--no-wait`, the started one):

```json
{
  "id": "run_9t2w6r1y8u3i5o7p4a0s",
  "app": "my-shop",
  "release": 3,
  "command": "python manage.py seed",
  "size": "small",
  "status": "succeeded",
  "exit_code": 0,
  "message": ""
}
```

`status` is `pending`, `running`, `succeeded`, `failed` or `cancelled`. The output is shown while the run exists and for an hour after it ends; it is not archived. A run lasts at most an hour. An app can have 2 runs at once and a team 5, a team can start 30 an hour, and a team's runs and release commands share 2 slots at once. The refusals are [run_busy](errors.md#run_busy), [run_limit](errors.md#run_limit), [run_rate_limited](errors.md#run_rate_limited), [run_no_release](errors.md#run_no_release) and [run_on_preview](errors.md#run_on_preview). You and CI tokens can run commands; AI tools cannot.

## Secrets

See [Secrets](secrets.md) for how secrets work.

### deplane secrets set

Set one or more secrets: `deplane secrets set NAME=VALUE [NAME=VALUE...]`. Changing a runtime secret on an app that has a release creates a new release.

| Flag | What it does |
|---|---|
| `--build` | Set build secrets, used only while building. |

```sh
$ deplane secrets set DATABASE_URL=postgres://u:p@db.example.com/shop API_KEY=sk_live_123
Set runtime secret(s) API_KEY, DATABASE_URL.
Created release v5 with the new secrets.
```

```json
{
  "names": ["API_KEY", "DATABASE_URL"],
  "scope": "runtime",
  "release": 5
}
```

`release` is `null` when no release was created: for build secrets, and for apps that have not been deployed yet.

### deplane secrets unset

Remove secrets: `deplane secrets unset NAME [NAME...]`. Pass `--build` to remove build secrets.

```sh
$ deplane secrets unset API_KEY
Removed runtime secret(s) API_KEY.
Created release v6 without them.
```

```json
{
  "names": ["API_KEY"],
  "scope": "runtime",
  "release": 6
}
```

### deplane secrets list

List secret names and when they changed. Values are never shown.

```sh
$ deplane secrets list
NAME          SCOPE    UPDATED
NPM_TOKEN     build    2026-10-13 09:30
DATABASE_URL  runtime  2026-10-13 09:50
```

```json
{
  "secrets": [
    {"name": "DATABASE_URL", "scope": "runtime", "updated_at": "2026-10-13T09:50:00Z"},
    {"name": "NPM_TOKEN", "scope": "build", "updated_at": "2026-10-13T09:30:00Z"}
  ]
}
```

## GitHub

Connect a repository in the dashboard (an app's Settings, or Import from GitHub on New app). See [GitHub](github.md).

### deplane github

Show the app's connected repository, its branch and root folder, whether pull requests get previews (and their own databases), the open previews with their databases, and the last problem deploying from GitHub, if any.

```sh
$ deplane github
Repository: acme/shop
Branch:     main (every push deploys)
Root:       /
Previews:   on, for pull requests from the repository
  #12 Faster checkout  https://shop-pr-12.deplane.app  Running
```

### deplane github deploy

Deploy the latest commit of the connected branch now, without a push, and follow it like `deplane deploy`.

### deplane previews

List the app's live pull request previews and the pull requests waiting for one, with why each waits. See [Choose which pull requests get previews](github.md#choose-which-pull-requests-get-previews).

```sh
$ deplane previews
Previews (3 of 3):
  #12 Faster checkout  https://shop-pr-12.deplane.app  Running
  #15 New footer  https://shop-pr-15.deplane.app  Running
  #18 Fix tax  https://shop-pr-18.deplane.app  Running
Waiting for a preview:
  #21 Dark mode  (the app's previews were full)
The app has 3 previews: build one with `deplane previews build PR --replace PR`.
```

With `--json`: `{"previews": [...], "waiting": [{"pull_request", "title", "author", "branch", "sha", "reason", "refused_at"}], "max_previews": 3}`. `reason` is `slots_full`, `plan_apps`, `plan_resources`, `capacity_full`, `replaced` or `removed`.

### deplane previews build

Build a waiting pull request's preview from its latest commit: `deplane previews build 21`. When the app already has 3 previews, add `--replace PR` to delete one first, in the same step: `deplane previews build 21 --replace 12`. Replacing asks for confirmation (`--yes` without a terminal), and says when the replaced preview's database is deleted with it. If the new preview is still refused, nothing changes. Errors: [preview_slots_full](errors.md#preview_slots_full), [preview_not_waiting](errors.md#preview_not_waiting), [preview_not_found](errors.md#preview_not_found), [previews_off](errors.md#previews_off).

```json
{"pull_request": 21, "replaced": 12, "preview": {"name": "shop-pr-21", "url": "https://shop-pr-21.deplane.app"}}
```

### deplane previews remove

Delete a pull request's preview: `deplane previews remove 12`. The pull request waits for a preview again. Asks for confirmation (`--yes` without a terminal). With `--json`: `{"removed": 12}`.
In a pipeline's stop job (with `DEPLANE_ID_TOKEN` or `BITBUCKET_STEP_OIDC_TOKEN`) it removes the preview of that merge or pull request without asking; a preview already gone is not an error.

## GitLab

An app can be connected to one GitLab project with your own fine-grained personal access token, in the dashboard (the app's Settings, GitLab): every push to its branch deploys, and merge requests get previews. The token is sealed, renewed every 7 days, and revoked when you disconnect.

### deplane gitlab

Show the app's connected GitLab project, its branch, the token's account and expiry, whether the webhook is set up, and the merge request previews. With `--json`, the API's answer.

### deplane gitlab deploy

Deploy the head of the connected branch now and follow the build.

## Bitbucket

An app can be connected to one Bitbucket repository with a repository access token with only Repositories: Read and Pull requests: Read, in the dashboard (the app's Settings, Bitbucket). Deplane makes the webhook's secret and shows it once. Bitbucket cannot revoke a token from outside: when you disconnect, delete the token and the webhook on Bitbucket.

### deplane bitbucket

Show the app's connected Bitbucket repository, its branch, whether the webhook is set up, and the pull request previews. With `--json`, the API's answer.

### deplane bitbucket deploy

Deploy the head of the connected branch now and follow the build.

## GitLab CI and Bitbucket Pipelines

A job deploys with its CI host's signed ID token: set `DEPLANE_ID_TOKEN` (GitLab) or use `BITBUCKET_STEP_OIDC_TOKEN` (Bitbucket), leave `DEPLANE_TOKEN` unset, and run `deplane deploy --app NAME [--path DIR]`. From the trust's deploy branch it deploys the app; from other branches `deplane deploy --preview N` deploys the preview of merge or pull request N. A pipeline only uploads: it never creates the app or changes its settings.

### deplane pipelines trust

Trust a repository's jobs to deploy the app: `deplane pipelines trust gitlab --app web --project 123456 --branch main [--path group/project] [--no-previews]`, or `deplane pipelines trust bitbucket --app web --workspace '{uuid}' --repository '{uuid}' --name workspace/repo --branch main [--no-previews | --preview-database]`. The repository is named by its IDs, never its path. Previews from other branches are on unless `--no-previews`; `--preview-database` gives each its own empty database, as GitHub previews can. At most 3 per app. Needs your passkey.

### deplane pipelines list

List the app's pipeline trusts with their ID, repository, branch, previews and last use.

### deplane pipelines remove

Remove a trust: `deplane pipelines remove plt_...`. Its pipelines stop deploying at once. Asks for confirmation (`--yes` without a terminal).

## Assistant

### deplane ask

Ask the assistant a question about your apps. In a folder with a `deplane.toml`, or with `--app`, that app is the one it may change; without one it only reads and explains. It makes ordinary changes itself; before one that takes something away it asks `Run this? [y/N]` (a delete: type the name), and `--yes` never skips that. Without a terminal it changes nothing and exits 1 if the answer needs a change, listing the commands. It never sees variable values or commands. See [Assistant](assistant.md).

```sh
$ deplane ask "why did my last deploy fail?"
The build failed in `npm install`: package.json asks for react@19.4.0, which does not exist yet.
Change it to ^19.2.0 and deploy again.
```

## Alerts

Rules and channels (Slack, Discord, webhooks; email is not delivered yet) are set in the dashboard. See [Alerts](alerts.md).

### deplane alerts

List your team's firing alerts, then recent ones.

```sh
$ deplane alerts
1 firing:
STATE     ALERT                                                    STARTED
FIRING    shop is down: 0 of 2 instances ready: CrashLoopBackOff  Oct 14 12:03
resolved  shop is slow: p95 response time above 2000 ms for 5 minutes  Oct 13 18:40
```

## Usage

### deplane usage

Show what your team used: requests, bandwidth, CPU, memory, build minutes, database disk and app disks, then each app, database and [disk](disks.md). Pick the period with `--range`: `24h`, `7d`, `30d`, `month` (the default, since the 1st) or `prev_month`. Dates are in UTC. Build minutes are always this month's, counted against your plan. Deleted apps still show what they used, marked with `*`. Databases and disks have no requests, bandwidth or builds, so those columns show `–`; disks have no CPU or memory either. DISK GB-H is the storage a database or disk held. Add `--json` for the full data, including per-day or per-hour series.

```sh
$ deplane usage
Usage this month (Oct 1 – Oct 18)
  Requests        12,345
  Bandwidth out   1.2 GB
  CPU             3.4 hours
  Memory          120.5 GB-hours reserved (31.0 used)
  Build minutes   12.5 of 300
  Database disk   4,200 GB-hours
  App disks       120 GB-hours

APP        REQUESTS  BANDWIDTH  CPU H  MEMORY GB-H  BUILD MIN  DISK GB-H
shop       12,000    1.1 GB     3.0    100.0        10.0       –
old-api*   345       100.0 MB   0.4    20.5         2.5        –
shopdb     –         –          0.2    4.0          –          4200.0
shop disk  –         –          –      –            –          120.0
* deleted
```

## Custom domains

Serve an app at your own domain, with HTTPS. Deplane routes a domain only after DNS proves it points at the app, then gets its certificate automatically.

### deplane domains

List the app's custom domains: `waiting for DNS`, `issuing certificate`, or `active`.

```sh
$ deplane domains
DOMAIN            STATE
www.example.com   active
example.com       waiting for DNS
```

### deplane domains add

Connect a domain and show the DNS records to set. For a subdomain such as `www.example.com`, add a CNAME to `<app>.deplane.app`. A root domain such as `example.com` cannot have a CNAME: add an A record to the address shown instead. Either way, add the TXT record `_deplane-challenge.<domain>` with the value shown, which proves the domain is yours. An app can have up to 10 domains.

```sh
$ deplane domains add www.example.com
www.example.com is waiting for DNS. No DNS record for www.example.com yet. Add a CNAME record pointing to shop.deplane.app and a TXT record _deplane-challenge.www.example.com with the value dpl-… (DNS changes can take a few minutes).

Set these records at your DNS provider (the TXT record proves the domain is yours):
  CNAME  www.example.com                     shop.deplane.app
  TXT    _deplane-challenge.www.example.com  dpl-…
```

### deplane domains check

Check a domain's DNS and certificate now. Deplane also checks every 2 minutes for 48 hours after you add it.

### deplane domains remove

Disconnect a domain. It stops serving the app right away. Asks for confirmation; pass `--yes` in scripts.

### deplane domains switch

Set a domain's DNS records on Cloudflare for you: `deplane domains switch DOMAIN [--provider cloudflare] [--check] [--yes] [--no-wait] [--rollback FILE]`. It points the domain at the app (a CNAME, or A records to Deplane where a CNAME cannot go) and adds the TXT record that proves it is yours, adding the domain to the app first if needed. `--check` shows the records now and each change, with conflicting and proxied records flagged, and changes nothing. Without it, after a confirmation, it saves the current records to `deplane-dns-DOMAIN-DATE.json`, adds the TXT record and waits until public DNS shows it, changes the records at the domain all together (Cloudflare's batch API), and waits until Deplane verifies the domain; `--no-wait` skips the waits. `--rollback FILE` puts the records back exactly. MX records and every other name in the zone are never changed. See [Switch DNS on Cloudflare](domains.md#switch-dns-on-cloudflare).

The Cloudflare API token (Edit zone DNS, for this zone only) is read from stdin or `CLOUDFLARE_API_TOKEN`, never a flag. The CLI makes the Cloudflare calls itself: the token is never sent to Deplane, printed or written, and `--debug` shows Cloudflare requests as method, path and status only.

```sh
$ export CLOUDFLARE_API_TOKEN=...
$ deplane domains switch www.example.com --check
$ deplane domains switch www.example.com --yes
Switched www.example.com on Cloudflare (3 changes). To undo: deplane domains switch www.example.com --rollback deplane-dns-www.example.com-20261031-140501.json
```

## Forms

Forms marked `data-auto-form` in your HTML, or listed under `[[forms]]` in `deplane.toml`. See [Forms](forms.md). AI tools can only list forms (the `list_forms` tool): submissions are your visitors' personal data ([forms_private](errors.md#forms_private)). Members read and export; only owners delete submissions or change notifications and CAPTCHA ([owners_only](errors.md#owners_only)).

### deplane forms

List the app's forms: their submission and spam counts, CAPTCHA, the deployed version's fields, and the path each posts to.

```sh
$ deplane forms
FORM     SUBMISSIONS  SPAM  CAPTCHA  FIELDS          POSTS TO                                STATE
contact  12           3     off      email, message  /__auto_forms/3f9c2a1b7d4e5f60a1b2      active
```

### deplane forms submissions

Show a form's newest 100 submissions, or its spam with `--status spam`: `deplane forms submissions FORM [--status spam]`.

### deplane forms export

Write every submission that is not spam, with every field any version of the form had, as CSV on stdout: `deplane forms export FORM > FILE.csv`.

### deplane forms delete

Delete one submission, or with no ID all of a form's, spam included: `deplane forms delete FORM [ID] [--yes]`. It cannot be undone.

### deplane forms notify

Show or set where a form's submissions are announced: `deplane forms notify FORM [--email A,B|off] [--slack -|off] [--webhook -|off]`. Email goes only to team members, as digests; Slack and webhooks get each submission. URLs are read from stdin (`-`), never taken as arguments. A new webhook's signing secret is shown once.

```sh
$ pbpaste | deplane forms notify contact --slack -
Email digests: you@example.com
Slack: on
Webhook: off
```

### deplane forms captcha

Turn Cloudflare Turnstile on or off for a form: `deplane forms captcha FORM on|off [--secret -|off]`. Without `--secret`, the form keeps the key it has; `--secret -` reads your own Turnstile secret from stdin (needed on a custom domain); `--secret off` goes back to the platform's key, which works on the app's own address only.

## Databases

Managed Postgres (17, or 18 where Deplane offers it) next to your apps. Each database runs in its own microVM on a 10 GB disk that only it uses, reachable only by your team's apps. Attaching it to an app sets the app's `DATABASE_URL`, a write-only variable, and rolls out a new release. A database counts against your plan as one instance of its size.

### deplane db

List your team's databases, their Postgres version, their state, whether each has a live copy on another server, and the apps they are attached to. `COPY` is `live` (the copy follows the database), `none` (the copy is down because its server is; it comes back when the server returns) or `–` (no copy to show, as for a pull request preview). See [Live copy and failover](databases.md#live-copy-and-failover).

### deplane db create

Create a database. It is ready in about a minute. `--size` is `small` (default), `medium` or `large`. `--version` is its Postgres major version: `17` (the default) or `18` where Deplane offers it (otherwise refused with [postgres_version_unavailable](errors.md#postgres_version_unavailable)). A database keeps its version for its whole life: there is no in-place upgrade (see [Postgres versions](databases.md#postgres-versions)).

```sh
$ deplane db create main
$ deplane db create next --version 18
$ deplane db attach main --app shop
```

### deplane db attach

Give an app the database as `DATABASE_URL` (`postgres://app:…@pg--main:5432/app`). An app uses one database at a time.

### deplane db detach

Remove the app's `DATABASE_URL`. The database and its data stay.

### deplane db destroy

Delete a database and all its data, after detaching it from its apps. Asks for confirmation; pass `--yes` in scripts. It also needs a confirmation in your browser: the CLI prints a link and a code (and opens the link), you approve with your passkey, and the command continues. `--yes` does not skip this, and CI tokens cannot do it at all. Its backups are kept for your plan's backup window; restore it with `deplane db restore NAME --deleted --at latest`.

### deplane db backups

Show a database's backup status (Protected, Behind, Starting or Off), the window it can be restored to (your local time and UTC; gaps show as more than one window), the last full copy, the space used and recent restores. `--deleted` shows a deleted database's backups.

### deplane db restore

Restore a database into a **new** database; the original is never changed. `--at` is `latest`, a time such as `2026-10-15 16:31` (your local time) or `2026-10-15T14:31:20Z`, or a time ago: `-10m`, `2h`, `1d`. The new name is `--to`, or `<name>-restored-<HHMM>`. `--deleted` restores a deleted database. It waits up to 15 minutes, showing progress (`--no-wait` returns at once), then prints the `deplane db switch` command to run.

Restoring and switching need a confirmation in your browser with your passkey: the CLI prints a link and a code, waits for you to approve, and continues. Each restore or switch uses up its confirmation, so the next one asks again. CI tokens and AI tools cannot restore or switch databases.

    $ deplane db restore main --at -10m
    Restoring from main as of 2026-10-15 14:42:31 UTC.
    main-restored-1442 is ready.
    Check it, then move your apps to it: deplane db switch main-restored-1442 --from main

### deplane db switch

Give every app attached to `--from` this database as `DATABASE_URL` instead (a new release each, no downtime). The original database is kept until you delete it.

### deplane db import

Copy a Postgres database from anywhere into a **new** Deplane database. The source is only read; nothing there is changed except, in live mode, a publication and a replication slot that Deplane removes when the import ends.

The source's URL is asked for at a hidden prompt, or read from stdin when no terminal is attached. It is never taken from an argument (it would land in your shell history), and `deplane db import postgres://...` is refused. The URL is never printed, and `--debug` hides it.

The command checks the source, prints what it found (size, tables, extensions, warnings, and whether copy and live are possible, with reasons), then asks for a name and a mode, starts, and follows the import. `--name`, `--size`, `--mode` and `--exclude-schema` answer those questions; `--version` sets the new database's Postgres version (left out: 17, or the source's own major when it is newer, so a Postgres 18 source goes into Postgres 18; asking for an older major than the source's is refused with [source_newer_than_target](errors.md#source_newer_than_target)); `--yes` skips the confirmation; `--no-wait` returns right after the start.

- **copy** copies the data once. It ends with the new database ready.
- **live** copies the data, then keeps the new database following the source until you stop writes to the source and run `deplane db import finish`. Nothing written in the meantime is lost.

Without `--mode`, a terminal is asked (live is the default when it is possible); a script gets live when it is possible, otherwise copy, and the CLI says which. Without a terminal, `--name` and `--yes` are required. `--exclude-schema a,b` leaves out schemas by name, replacing the check's default list; `--exclude-schema ""` leaves nothing out. A live import stops following at "Syncing" and returns; `--follow` keeps watching it.

An import is for people: CI tokens and AI tools cannot run it. A team has one import at a time. The source can be at most 5 GB.

```sh
$ printf '%s' "$SOURCE_URL" | deplane db import --name shop --mode live --yes
Checking db.example.com:5432/shop ...
Checked db.example.com:5432/shop
  Postgres 16.4, 953 MB, 12 tables
  Largest: public.orders 572 MB
  Copy: possible
  Live: possible
Copying the data into shop ...
The data is copied. shop now follows the source.
Syncing: 12 KB behind, last change applied 10:00:14 UTC.
Keep your app on the source until you are ready. Then stop writes to the source and run `deplane db import finish`.
```

### deplane db import status

Show an import: its state, source, new database and, while a live import is syncing, how far behind it is. `ID` defaults to the newest active import (or, with none, the newest). With `--json`, one document with the full report.

### deplane db import finish

Finish a live import: the last changes are copied, every table's rows are compared with the source, and the link to the source is removed. Stop writes to the source first (scale your app to 0, or turn on maintenance mode); the CLI asks you to confirm that, and `--yes` skips the question. If the schema changed, the new database had not caught up, or a count differs, nothing is changed and the import goes back to syncing with the reason. `ID` defaults to the newest active import. `--no-wait` returns right after the request.

### deplane db import cancel

Cancel an import, in any state before it is done. Copying stops, and Deplane removes what a live import created on the source. The new database stays: empty after a copy or a live import cancelled before its copy finished, with the data copied so far after a live import cancelled while in sync; delete it with `deplane db destroy`. Asks for confirmation; `--yes` skips it. If Deplane could not reach the source to clean up, the CLI prints the two SQL lines to run there. `ID` defaults to the newest active import.

## Migrations

### deplane import heroku

Move a Heroku app to Deplane: `deplane import heroku HEROKU_APP [--source DIR] [--name NAME] [--check] [--yes]`. See [Moving from Heroku](heroku.md) for what moves, what stays, and how to switch, or import from the dashboard instead ([From the dashboard](heroku.md#from-the-dashboard)).

The command reads the Heroku app with a Heroku token (only GET requests, to `api.heroku.com`) and prints a report: what is copied, what is recreated and what stays on Heroku, with ✓, ⚠ and ✗ lines. Config var names are listed; values never are. When you are logged in, the report also checks your plan's limits (workers, sizes, apps, databases, Redis, instances, memory) against what your team uses, with read-only requests, and a limit the import would cross is a ✗ line. `--check` stops there and creates nothing; without a Deplane login it skips the plan and says so; `--check --json` prints the report as one JSON document. Without `--check`, after a confirmation (`--yes` skips it), it has Deplane check the Postgres database first, so a database Deplane cannot copy stops the import before anything is created, then creates the app (named `--name`, or after the Heroku app), sets the copied config vars as secrets, the release command, the web instances and size and the workers, copies the database ([Postgres import](import-postgres.md), copy mode) and attaches it, creates and attaches a Redis, and deploys `--source` (default: the current folder) as `deplane launch` does. Only process types that run dynos on Heroku become workers. It ends with the URL and the next steps, including the switch-over with [`deplane db switch`](#deplane-db-switch).

The Heroku token is read from stdin (a pipe, or a hidden prompt in a terminal) or from `HEROKU_API_KEY`. A piped token wins over the variable, which is used when nothing is piped in; the CLI says which one it used, never the token. There is no flag for it, and a token typed as the argument is refused ([token_in_argument](errors.md#token_in_argument)). It is sent only to Heroku: never to Deplane, never printed or written, and `--debug` shows Heroku requests as method, path and status only. Create a one-hour, read-only token for it:

```sh
$ heroku authorizations:create --scope read-protected --expires-in 3600 --short | deplane import heroku shop --check
$ heroku authorizations:create --scope read-protected --expires-in 3600 --short | deplane import heroku shop --yes
Reading the Heroku app shop (read-only) ...
...
Deployed shop v1: https://shop.deplane.dev

Imported the Heroku app shop into shop: https://shop.deplane.dev
Heroku still serves your users; nothing changed there.
```

If a step fails, the command stops, lists what it created, the commands to finish by hand and the commands to delete it, and exits with the failed step's code (6 for a plan limit, 3 for a failed build, 4 for a failed deploy). A report with a ✗ line exits with 1 ([heroku_import_refused](errors.md#heroku_import_refused)).

### deplane import render

Move a Render web service, with the background workers and cron jobs built from the same repository, to Deplane: `deplane import render SERVICE [--source DIR] [--name NAME] [--check] [--yes]`. `SERVICE` is the service's ID (`srv-...`) or its name. See [Moving from Render](render.md), or import from the dashboard instead ([From the dashboard](render.md#from-the-dashboard)).

It works like [`deplane import heroku`](#deplane-import-heroku): a read-only check (only GET requests, to `api.render.com`), a report with ✓, ⚠ and ✗ lines that names variables but never shows their values, your plan's limits checked before anything is created, then, after a confirmation (`--yes` skips it), the app with its secrets, release command (Render's pre-deploy command), instances, size, workers and cron jobs, the Postgres database copied through its external URL ([Postgres import](import-postgres.md), copy mode), a new Redis for a Key Value, and the deploy of `--source`. A database whose IP allow list does not let Deplane in is a ✗ line that names the addresses to add.

The Render API key is read from a pipe on stdin, then from `RENDER_API_KEY`, then from a hidden prompt in a terminal; a piped key wins. There is no flag for it, and a key typed as an argument is refused ([token_in_argument](errors.md#token_in_argument)). It is sent only to Render, never to Deplane, never printed or written. Render keys can change everything your account can: delete it when the import is done.

```sh
$ deplane import render shop --check
Render API key (input is hidden; create one in the Render Dashboard under Account Settings → API Keys):
Render service shop (web service, region frankfurt, repo github.com/acme/shop@main, commit 1a2b3c4) → Deplane app shop
...
```

A report with a ✗ line exits with 1 ([render_import_refused](errors.md#render_import_refused)); a failed step stops the import as for Heroku.

### deplane import railway

Move a Railway service, with the worker and cron services of the same environment, to Deplane: `deplane import railway PROJECT[/SERVICE] [--source DIR] [--name NAME] [--check] [--yes]`. `PROJECT` is the project's name or ID; `SERVICE` is needed when more than one service has a public domain. See [Moving from Railway](railway.md), or import from the dashboard instead ([From the dashboard](railway.md#from-the-dashboard)).

It works like [`deplane import heroku`](#deplane-import-heroku): a read-only check (GraphQL queries only, to `backboard.railway.com`), the same report and plan check, then the app with its secrets, release command (the pre-deploy command), replicas, workers and cron jobs, the Postgres database copied through its public URL (`DATABASE_PUBLIC_URL`, which needs a TCP proxy on the Postgres service), a new Redis, and the deploy of `--source`. Sealed variables cannot be read through Railway's API; the report says so.

The token is read from a pipe on stdin, then from `RAILWAY_TOKEN`, then from a hidden prompt; a piped token wins. Use a project token (the project's Settings → Tokens): it reaches one environment of one project, and that environment is the one imported. An account token also works; the CLI then imports the `production` environment, or the only one. Workspace tokens are not supported yet. There is no flag for the token, and an argument that holds it is refused. It is sent only to Railway, never to Deplane, never printed or written.

```sh
$ RAILWAY_TOKEN=... deplane import railway shop --check
Railway service shop (project shop, environment production, region europe-west4-drams3a) → Deplane app shop
...
```

A report with a ✗ line exits with 1 ([railway_import_refused](errors.md#railway_import_refused)).

### deplane import compose

Move a Docker Compose project to Deplane: `deplane import compose [-f FILE] [--prefix NAME] [--check] [--yes]`. See [Moving from Docker Compose](compose.md) for what each service becomes and what cannot move.

The command reads the compose file (`-f FILE`, default `compose.yaml`, `compose.yml`, `docker-compose.yaml` or `docker-compose.yml` in the current folder), fills in its `${VAR}` variables from your environment and the `.env` beside it, reads its `env_file`s, and prints a report with ✓, ⚠ and ✗ lines: services with `ports:` become apps (from their `image:`, or built from their `build:` folder), well-known Postgres, MySQL/MariaDB, Redis/Valkey and ClickHouse images become managed ones attached to the services that use them, services that share an app's image or build become its workers, other services become apps on the [private network](private-networking.md), named volumes become [disks](disks.md), and variables become secrets. Variable names are listed; values never are. Variables that pointed at a database (its host, URL, user, password or name) are filled in from the managed one on Deplane's servers, so its password never reaches your computer. When you are logged in, the report also checks your plan's limits against what your team uses, with read-only requests. `--check` stops there and creates nothing; `--check --json` prints the report as one JSON document.

Without `--check`, after a confirmation (`--yes` skips it), it creates the apps, sets their port, health check and private network, scales them, creates and attaches the databases, sets the secrets, adds the workers and disks, waits for the databases, and deploys each app: an image with `deploy --image`, a folder as `deplane deploy` does (writing `deplane.toml` there if it has none). `--prefix NAME` names the apps: the main app (the first with a port) is `NAME`, the others `NAME-SERVICE`; the default is the file's `name:`, else its folder's name. Data is not copied: the report says how to copy it.

```sh
$ deplane import compose --check
$ deplane import compose -f deploy/docker-compose.prod.yml --prefix shop --yes
...
Imported 1 app(s):
  shop  https://shop.deplane.dev
```

`-f` names the compose file only after `import compose` (and the stack file after `stack`); elsewhere it is `--follow`. If a step fails, the command stops, lists what it created, the commands to finish by hand and the commands to delete it, and exits with the failed step's code. A report with a ✗ line exits with 1 ([compose_import_refused](errors.md#compose_import_refused)); nothing is created before that.

### deplane import fly

Move a Fly.io app, with its process groups and scheduled Machines, to Deplane: `deplane import fly APP [--source DIR] [--name NAME] [--check] [--yes]`. See [Moving from Fly.io](fly.md), or import from the dashboard instead ([From the dashboard](fly.md#from-the-dashboard)).

It works like [`deplane import heroku`](#deplane-import-heroku): a read-only check (only GET requests, to Fly.io's Machines API at `api.machines.dev`), the same report and plan check, then the app with its variables as secrets, release command, instances, size, workers and cron jobs. `fly.toml` in `--source` is the main source: its `[processes]`, `[http_service]` or `[[services]]` (`internal_port`), `[deploy] release_command`, `[[mounts]]` and `[env]`; the Machines give the counts and sizes (by memory: up to 512 MB small, 1 GB medium, 2 GB large). Fly.io never gives secret values through its API, so secrets are listed by name: the app is then created without being deployed, and the next steps say which secrets to set before `deplane deploy`. No database is copied: the report says how to move Fly Postgres.

The token is read from a pipe on stdin, then from `FLY_API_TOKEN`, then from a hidden prompt; a piped token wins. Use a read-only one: `fly tokens create readonly --expiry 1h | deplane import fly shop --check`. There is no flag for it, and an argument that holds it is refused. It is sent only to Fly.io, never to Deplane, never printed or written.

A report with a ✗ line exits with 1 ([fly_import_refused](errors.md#fly_import_refused)).

### deplane import vercel

Move a Vercel project, with its cron jobs, to Deplane: `deplane import vercel [TEAM/]PROJECT [--source DIR] [--name NAME] [--check] [--yes]`. `PROJECT` is the project's name or ID; `TEAM` is the team's slug or ID, also read from `VERCEL_TEAM_ID` or the folder's `.vercel/project.json`. See [Moving from Vercel](vercel.md), or import from the dashboard instead ([From the dashboard](vercel.md#from-the-dashboard)).

It works like [`deplane import heroku`](#deplane-import-heroku): a read-only check (only GET requests, to `api.vercel.com`), the same report and plan check, then the app with its production variables as secrets and as build secrets, a cron job for each Vercel cron (a request to the app's path, over the private network), and the deploy of `--source`. Secret (sensitive) variables cannot be read through Vercel's API: they are listed by name, and the app is then created without being deployed. The report says what does not move: Vercel Functions outside Next.js, `vercel.json` rewrites, redirects and headers, and Vercel's own services.

The token is read from a pipe on stdin, then from `VERCEL_TOKEN`, then from a hidden prompt; a piped token wins. There is no flag for it, and an argument that holds it is refused. It is sent only to Vercel, never to Deplane, never printed or written.

A report with a ✗ line exits with 1 ([vercel_import_refused](errors.md#vercel_import_refused)).

### deplane import netlify

Move a Netlify site to Deplane: `deplane import netlify SITE [--source DIR] [--name NAME] [--check] [--yes]`. `SITE` is the site's name (or `name.netlify.app`) or ID. See [Moving from Netlify](netlify.md), or import from the dashboard instead ([From the dashboard](netlify.md#from-the-dashboard)).

It works like [`deplane import heroku`](#deplane-import-heroku): a read-only check (only GET requests, to `api.netlify.com`), the same report and plan check, then the app with its production variables as secrets (and the ones Netlify gives to builds as build secrets, with `netlify.toml`'s), and the deploy of `--source`. Secret variables cannot be read through Netlify's API: they are listed by name, and the app is then created without being deployed. Functions, Edge Functions, redirects, Forms and Identity are listed, not moved.

The token (a personal access token) is read from a pipe on stdin, then from `NETLIFY_AUTH_TOKEN`, then from a hidden prompt; a piped token wins. There is no flag for it, and an argument that holds it is refused. It is sent only to Netlify, never to Deplane, never printed or written.

A report with a ✗ line exits with 1 ([netlify_import_refused](errors.md#netlify_import_refused)).

### deplane import cpanel

Move a WordPress site from cPanel to Deplane: `deplane import cpanel --host HOST --user USER [--site DOMAIN] [--name NAME] [--backup FILE] [--db-dump FILE] [--check] [--cutover] [--replace-live] [--yes]`. See [Moving from cPanel](cpanel.md) for what moves, what stays (email), and how to switch DNS. CLI only for now: the dashboard has no cPanel import yet.

The command reads the cPanel account with a cPanel API token (read-only calls to `https://HOST:2083`): the site's document root (`--site`, default the main domain), its `wp-config.php` and WordPress version, the PHP version, the database's size, and the domain's mailboxes and MX records. It prints a report with ✓, ⚠ and ✗ lines. Setting names are listed; values from `wp-config.php` (the database password, keys) never are. When you are logged in, the report also checks that the WordPress template fits your plan (a small app, a medium MySQL and a 5 GB disk; Free has no MySQL) and that the app name (`--name`, default from the domain: `example.com` becomes `example-com`) is free. `--check` stops there and creates nothing; `--check --json` prints the report as one JSON document.

Without `--check`, after a confirmation (`--yes` skips it), it has cPanel write a full account backup into your home directory, downloads it, and keeps only the site's files and database dump in a private temporary folder; sizes are checked here (site files up to 4.5 GB, database up to 5 GB), so a site that does not fit stops before anything is created. With `--backup FILE` it reads a backup you downloaded yourself instead (with `--db-dump FILE` for a home directory backup's database). Then it creates the app from the [WordPress template](templates.md) with its MySQL and disk, turns on the app's one-time import receiver, replaces the app's files and database with the site's (site addresses rewritten to the app's own address, PHP serialized values kept valid), turns the receiver off, and prints the test address and the cutover checklist. While the files and database are replaced, the site shows a maintenance page. Running the same command again replaces the files and database of the app an earlier import made (of the same site only), after a disk backup and a MySQL backup.

`--cutover` copies the site again into that app without the address rewrite, sets the address to the real domain, and adds the domain and `www.` to the app, printing their DNS records. Run it when you are ready to change DNS. After a cutover the app is the live site: running the import or `--cutover` again asks you to type the domain (`--yes` does not skip that), or needs `--replace-live` ([cpanel_live_site](errors.md#cpanel_live_site)).

The token is read from stdin (a pipe, or a hidden prompt in a terminal) or from `CPANEL_API_TOKEN`. There is no flag for it, and a token typed as an argument is refused ([token_in_argument](errors.md#token_in_argument)). It is sent only to cPanel: never to Deplane or to the app, never printed or written, and `--debug` shows cPanel calls by name and status only.

```sh
$ deplane import cpanel --host server1.host.example --user shop --check < token.txt
$ deplane import cpanel --host server1.host.example --user shop --yes < token.txt
...
example.com's copy is live at https://example-com.deplane.app, with the site's files, database and users.
$ deplane import cpanel --host server1.host.example --user shop --cutover --yes < token.txt
```

A report with a ✗ line exits with 1 ([cpanel_import_refused](errors.md#cpanel_import_refused)). If a step after the app was created fails, the command says how to run it again and how to delete what it made, and turns the import receiver off.

## Stacks

Several apps, their workers, cron jobs, databases, disks, domains and variables, described once in `stack.yaml` and applied together. See [Stacks](stacks.md) for the file and what an apply changes or leaves. `-f FILE` picks the file (default: `stack.yaml` in the current folder). AI tools can read, check and plan stacks (the `stacks` and `check_stack` tools), never apply them.

### deplane stack check

Check a stack file: `deplane stack check [-f FILE]`. It reads the file and reports every problem, not only the first, as ✗ (stops the stack) and ⚠ (worth reading) lines, each with the place in the file and the fix. When you are logged in, it then plans the file against your team, as [`deplane stack plan`](#deplane-stack-plan) does; logged out, it checks on your computer only and says so. It changes nothing.

```sh
$ deplane stack check
✓ stack.yaml: stack shop, 2 services, 2 engines.
```

A file Deplane cannot read at all fails at once with the line ([stack_invalid](errors.md#stack_invalid)). A file with a ✗, or a plan that cannot be applied, exits with 1. `--json` prints `{"valid": true|false, "problems": [{"path", "code", "message", "fix", "warning"}]}`, with `"plan"` when logged in. A `path:` folder's `deplane.toml` fills in what the stack leaves out; where both set a field, the stack wins ([set_in_both](errors.md#set_in_both)).

### deplane stack plan

Show what applying the file would do to your team now: `deplane stack plan [-f FILE] [--adopt] [--move] [--prune]`. It changes nothing. Each object gets a line, `+` create, `~` update, `=` unchanged, `→` adopt, `⇢` move (with `--move`), `-` left (no longer in the file; it keeps running and stays in the stack until pruned), `✗` delete (with `--prune`) or `!` refused, with the settings that change below it. Variables show names only, never values. Then come the ⚠ and ✗ lines, whether the stack fits your plan, and the values the apply will ask for.

```sh
$ deplane stack plan
= postgres shop-db (unchanged)
= app shop-web (unchanged)
~ app shop-api (update)
    instances: 1 → 2
    env.LOG_LEVEL: set by hand → set
⚠ services.shop-api.env.LOG_LEVEL: LOG_LEVEL was set by hand since the last apply; an apply with --yes sets it back.
  Keep the change in the file instead, or leave it out of the file to manage it by hand.
```

`--adopt` and `--prune` plan what [`deplane stack apply`](#deplane-stack-apply) would do with them. `--json` prints the plan: `stack`, `new`, `can_apply`, `changes`, `problems`, `steps`, `fit`, `prompts` and `kept`. The command exits with 1 when the plan cannot be applied (a ✗ line, or it does not fit your plan), so a CI job can stop on it; `--json` still prints the plan.

### deplane stack apply

Create or update the stack the file describes: `deplane stack apply [-f FILE] [--adopt] [--move] [--prune] [--yes] [--no-wait]`. It plans, prints the plan, asks for a confirmation (`--yes` skips it; when the plan deletes anything, with `--prune`, it asks you to type the stack's name instead, and `--yes` does not skip that; the apply is refused if it would then delete anything else, [stack_plan_changed](errors.md#stack_plan_changed)), asks for the values the file marks `{prompt: true}`, starts the apply on Deplane's servers and prints each step as it finishes. At the end it uploads and deploys the services whose source is a `path:` folder, as `deplane deploy` does.

```sh
$ deplane stack apply
Stack shop is new.
+ postgres shop-db (create)
...
Apply stack shop (18 steps)? [y/N] y
STRIPE_KEY for shop-web (hidden):
✓ postgres shop-db: create small
✓ app shop-web: create
...
Applied stack shop.
```

A `{prompt: true}` value comes from `DEPLANE_STACK_<SERVICE>__<NAME>` or `DEPLANE_STACK_<NAME>` when one is set (the CLI says which it used, never the value), else from a hidden prompt in a terminal; a shell variable that only has the same name is never read; there is no flag for it ([stack_prompt_missing](errors.md#stack_prompt_missing)). Deplane sets it and does not keep it.

- `--adopt`: apps and databases your team already has outside any stack, under a name the file uses, join the stack and are changed to match the file. Without it such a name is refused ([name_taken](errors.md#name_taken)).
- `--move`: apps and databases of another stack that the file names move into this stack, with their values and whether a stack created them. Without it such a name is refused ([in_another_stack](errors.md#in_another_stack)). Needs your own login; CI tokens cannot move.
- `--prune`: delete the apps and databases the stack created that the file no longer has, with their data. Without it they keep running and stay in the stack, shown as left on every plan, so a later `--prune` still deletes them. It needs a fresh confirmation with your passkey in the browser; CI tokens cannot prune. Objects that joined with `--adopt` are never deleted.
- `--yes`: also set back variables changed by hand since the stack set them. Without it, such a plan stops before anything changes ([stack_changed_outside](errors.md#stack_changed_outside)).
- `--no-wait`: return once the apply has started; follow it with [`deplane stack status`](#deplane-stack-status). `path:` folders are then not uploaded.

A plan with a ✗ stops before anything changes ([stack_cannot_apply](errors.md#stack_cannot_apply)). A failed step stops the apply and exits with 1 ([stack_apply_failed](errors.md#stack_apply_failed)); fix the cause and run [`deplane stack retry`](#deplane-stack-retry). With `--json`, it prints the finished apply: `id`, `stack`, `status`, `step`, `steps` and `message`. A CI token can apply, but cannot connect a GitHub repository to an app for the first time ([ci_token_not_allowed](errors.md#ci_token_not_allowed)).

### deplane stack list

List your team's stacks: name, who manages it (`blueprint` for a `stack.yaml`), how many apps and databases it holds, its revision (one more for each apply), and its last apply's status. `--json` prints the list.

```sh
$ deplane stack list
shop  blueprint  4 objects  revision 3  apply done
```

### deplane stack status

Show one stack: `deplane stack status NAME`. Its apps with their state, ready instances and address, its databases, which objects joined with `--adopt`, and its last apply: its ID, status, the step it is on while running, and the message of a failed one. `--json` prints the stack with `members`, `status` and `apply`.

```sh
$ deplane stack status shop
Stack shop (blueprint, revision 3)
  postgres shop-db
  app shop-web  Running, 2 ready  https://shop-web.deplane.app
Last apply sta_8f2k...: failed
  The app shop-web was deleted while the stack was being applied. Apply again.
```

### deplane stack export

Print a stack as `stack.yaml`: `deplane stack export NAME [--format yaml]`. It is built from the last file applied, and never contains a secret: values come back only when neither their name nor a password in a URL looks secret, the rest as `{prompt: true}`, generated values as `{generate: N}`. `--format yaml` is the only format for now ([invalid_format](errors.md#invalid_format)).

```sh
$ deplane stack export shop > stack.yaml
```

### deplane stack retry

Continue a failed apply from the step that failed: `deplane stack retry APPLY-ID` (`deplane stack status NAME` shows the ID). Steps already done are kept. After a failed build or release, apply again instead: a retry waits for the same build. The retry runs as you and prints each step as `deplane stack apply` does; `--no-wait` returns at once. Values asked for at apply time are not kept, so if the remaining steps need them, run `deplane stack apply` again instead ([stack_retry_needs_values](errors.md#stack_retry_needs_values)). Only a failed apply can be retried ([stack_apply_not_failed](errors.md#stack_apply_not_failed)). When deletes are still to run, it asks you to type the stack's name, as apply does.

### deplane stack forget

Forget a stack: `deplane stack forget NAME`. Its apps and databases keep running, outside any stack; nothing is deleted. It first lists what it releases (no stack command deletes those afterwards), then asks (`--yes` skips the question). Only a person can forget a stack: CI tokens are refused ([ci_token_not_allowed](errors.md#ci_token_not_allowed)). A stack being applied cannot be forgotten until the apply ends ([stack_apply_running](errors.md#stack_apply_running)).

### deplane stack destroy

Delete what a stack created, then the stack: `deplane stack destroy NAME`. It lists the apps and databases that go, with their data, and asks you to type the stack's name (always, even with `--yes`). Then it needs a fresh confirmation with your passkey in the browser, and follows the deletes. Apps and databases that joined with `--adopt` are left running, outside any stack. CI tokens and AI tools cannot destroy a stack.

```sh
$ deplane stack destroy shop
This deletes, with their data: app shop-web, app shop-api, postgres shop-db, redis shop-cache.
Type the stack's name (shop) to confirm shop
Destroyed stack shop.
```

## Team

Work on apps together. Everyone signs in with their own passkey, and everything they do appears in the activity log with their name. Members deploy and manage apps; owners can also invite and remove people.

### deplane team

List your team's members and open invitations.

### deplane team invite

Invite someone by email. You get a link to send them yourself: it works once, for that address, for 7 days, and they set up a passkey when they join. Needs a fresh confirmation with your passkey. `--role owner` makes them an owner.

```sh
$ deplane team invite colleague@company.com
Send this link to colleague@company.com. It works once, for 7 days, and is shown only now:

  https://app.deplane.io/invite?t=…
```

### deplane team remove

Remove a member. Their tokens for the team stop working. Asks for confirmation; pass `--yes` in scripts. Needs a fresh confirmation with your passkey.

## Workers and cron jobs

Run more processes from the same app: workers that run all the time (a queue consumer, a bot) and cron jobs that run on a schedule (a nightly report). They use the app's current image and its variables, each in its own microVM, and their output appears in `deplane logs`. The command runs through the image's shell (`/bin/sh -c`). Workers count against your plan's instances and memory; a cron job counts as one instance of its size. Free has cron jobs but no workers: adding a worker, or starting or growing one, is refused with [limit_sleep](errors.md#limit_sleep); a worker from before a move to Free can be scaled down or removed.

### deplane processes

List the app's workers and cron jobs.

```sh
$ deplane processes
NAME     KIND    RUNS            SIZE   COMMAND
emails   worker  1 instance(s)   small  node emails.js
nightly  cron    0 3 * * * UTC   small  node report.js
```

### deplane processes add

Add a worker, or with `--schedule` a cron job. Schedules have five fields in UTC (minute, hour, day of month, month, day of week), or `@hourly`, `@daily`, `@weekly`, `@monthly`. A cron job runs one instance at a time, for at most an hour, and a failed run is not retried.

```sh
$ deplane processes add emails --command "node emails.js"
$ deplane processes add nightly --command "node report.js" --schedule "0 3 * * *"
```

### deplane processes scale

Change a worker's instances (`0` stops it), or any process's `--size`, `--command`, or `--schedule`.

### deplane processes remove

Remove a worker or cron job. Asks for confirmation; pass `--yes` in scripts.

## App disks

A 5 GB disk for an app, mounted at a path, whose files stay across deploys and restarts. An app with a disk runs one instance and does not autoscale, and each deploy stops it for a few seconds. See [Disks](disks.md).

### deplane disks create

Give an app a disk: `deplane disks create APP [--path /data]`. The app must run one instance without autoscaling; if it runs more, scale it first with `deplane scale 1 --max 0 --app APP`.

```sh
$ deplane disks create my-app
Added a 5 GB disk to my-app at /data. Files there survive deploys and restarts, and are backed up every night.
my-app now runs one instance and cannot autoscale. Each deploy stops it for a few seconds while the new release takes the disk.
```

### deplane disks list

List your team's disks with their path and last backup. `--json` prints `{"disks": [...]}`.

### deplane disks backups

List an app's disk backups, newest first: ID, status, nightly or on request, start time (UTC), and size.

### deplane disks backup

Back up an app's disk now. At most once an hour; the app keeps running.

### deplane disks restore

Replace all files on an app's disk with a backup: `deplane disks restore APP BACKUP-ID`. Files written since that backup are lost, and the app stops while the restore runs. Asks for confirmation; pass `--yes` in scripts. It also needs a confirmation in your browser with your passkey, each time; CI tokens and AI tools cannot restore disks.

### deplane disks destroy

Destroy an app's disk and the files on it. Asks for confirmation; pass `--yes` in scripts. Like restore, it needs your passkey in the browser each time. Its backups are kept for 3 days.

### deplane templates list

Lists the templates with what each needs (memory, Postgres, Redis, MySQL, ClickHouse, a disk) and whether your team can deploy it now: ready, "no room now" (free some room: the message says how much is needed and free) or "needs a larger plan". Below, your team's recent template deploys with their ID, status and step. `--category Starters|Analytics|Monitoring|Productivity|Developer tools|Data` narrows the list. `--json` prints both lists.

### deplane templates show

`deplane templates show NAME` shows one template: its description, what it needs, whether your team has room, its licence, source and version, the variables it sets (names only) and the trademark notice.

### deplane templates retry

`deplane templates retry DEPLOY-ID` continues a failed template deploy from the step that failed. What was already made is kept and its generated secrets stay the same; at the last step it builds again.

### deplane templates credentials

`deplane templates credentials DEPLOY-ID` shows, once, the admin sign-in a template set up at deploy time (for example Grafana's generated admin password), for a deploy you started and that is done. `deplane launch --template` prints it by itself when it waits for the deploy; use this for a deploy started with `--no-wait`, from the dashboard or by an AI tool. Deplane never shows it a second time ([template_credentials_shown](errors.md#template_credentials_shown)).

### deplane templates delete

`deplane templates delete DEPLOY-ID` deletes the app (with its disk) and the database, Redis, MySQL and ClickHouse database that this deploy made, and nothing else; one that another app uses is kept. It asks first (`--yes` skips the question) and needs your browser confirmation, like other deletions. A running deploy cannot be deleted; wait for it to finish or fail.

## Redis

A managed Redis (Valkey 8, 256 MB) for caches and queues. See [Redis](redis.md).

### deplane redis create

Create a Redis: `deplane redis create NAME`. It counts 256 MB against your plan's memory.

### deplane redis list

List your team's Redis with their address, apps and last backup. `--json` prints `{"redis": [...]}`. Passwords are never shown.

### deplane redis attach

Give an app the Redis as `REDIS_URL`: `deplane redis attach NAME --app APP`. A new release rolls out. An app has one Redis.

### deplane redis detach

Remove an app's `REDIS_URL`: `deplane redis detach NAME --app APP`. The Redis and its data are kept.

### deplane redis backups

List a Redis's backups, newest first: ID, status, nightly or on request, start time (UTC), size.

### deplane redis backup

Back up a Redis now. At most once an hour; it keeps running.

### deplane redis restore

Replace a Redis's data with a backup: `deplane redis restore NAME BACKUP-ID`. Data written since the backup is lost, and the Redis stops while the restore runs. Asks for confirmation; pass `--yes` in scripts. It also needs your passkey in the browser each time; CI tokens and AI tools cannot restore.

### deplane redis destroy

Destroy a Redis and its data; its apps lose `REDIS_URL`. Asks for confirmation; pass `--yes` in scripts. Needs your passkey each time. Its backups are kept for 3 days.

## MySQL

A managed MySQL 8.4 (medium or large, 10 GB) for apps that need MySQL. See [MySQL](mysql.md).

### deplane mysql create

Create a MySQL: `deplane mysql create NAME [--size medium|large]`. Without `--size` it is medium (1 GB). It counts as one instance of its size against your plan.

### deplane mysql list

List your team's MySQL with their status, size, apps and last backup. `--json` prints `{"mysql": [...]}`, with each one's host, port and version. Passwords are never shown.

### deplane mysql attach

Give an app the MySQL as `MYSQL_URL`: `deplane mysql attach NAME --app APP`. A new release rolls out. An app has one MySQL.

### deplane mysql detach

Remove an app's `MYSQL_URL`: `deplane mysql detach NAME --app APP`. The MySQL and its data are kept.

### deplane mysql backups

List a MySQL's backups, newest first: ID, status, nightly or on request, start time (UTC), size. `deplane mysql backups --destroyed` lists instead the backups of your team's destroyed MySQL, with the name each came from; you can restore them into another MySQL until they expire.

### deplane mysql backup

Back up a MySQL now. At most once an hour; it keeps running.

### deplane mysql restore

Replace a MySQL's data with a backup: `deplane mysql restore NAME BACKUP-ID`. The backup can be from any MySQL of your team, including a destroyed one. Data written since the backup is lost, and the MySQL stops while the restore runs. Asks for confirmation; pass `--yes` in scripts. It also needs your passkey in the browser each time; CI tokens and AI tools cannot restore.

### deplane mysql destroy

Destroy a MySQL and its data; its apps lose `MYSQL_URL`. Asks for confirmation; pass `--yes` in scripts. Needs your passkey each time. Its backups are kept for 3 days at most; `deplane mysql backups --destroyed` lists them.

There is no command to open a MySQL session from your computer: the MySQL has no public address. To run SQL, use `deplane run` in an app whose image has a client; see [MySQL](mysql.md#connect).

## ClickHouse

A managed ClickHouse 26.8 database (large, 2 GB memory, 10 GB) for analytics and event data. See [ClickHouse®](clickhouse.md).

### deplane clickhouse create

Create a ClickHouse database: `deplane clickhouse create NAME [--size large]`. Large is the only size, and the default. It counts as one large instance against your plan and needs 2 GB of your plan's memory.

### deplane clickhouse list

List your team's ClickHouse databases with their status, size, disk used (as of the last backup), apps and last backup. If your plan cannot hold one, it says what to upgrade, and links https://deplane.io/pricing. `--json` prints `{"clickhouse": [...], "plan": {"allowed", "fits", "message"}}`, with each one's host, ports and version. Passwords are never shown.

### deplane clickhouse attach

Give an app the ClickHouse database as `CLICKHOUSE_URL`: `deplane clickhouse attach NAME --app APP`. A new release rolls out. An app has one ClickHouse database.

### deplane clickhouse detach

Remove an app's `CLICKHOUSE_URL`: `deplane clickhouse detach NAME --app APP`. The ClickHouse database and its data are kept.

### deplane clickhouse backups

List a ClickHouse database's backups, newest first: ID, status, nightly or on request, start time (UTC), size, and why one failed. It ends with the disk used at the last backup, against the 4.5 GB line above which backups have no room. `deplane clickhouse backups --destroyed` lists instead the backups of your team's destroyed ClickHouse databases, with the name each came from; you can restore them into another one until they expire.

### deplane clickhouse backup

Back up a ClickHouse database now. At most once an hour; it keeps running. At most 2 backups are kept, and this one counts.

### deplane clickhouse restore

Replace a ClickHouse database's data with a backup: `deplane clickhouse restore NAME BACKUP-ID`. The backup can be from any ClickHouse database of your team, including a destroyed one; another one's backup brings its password, so the apps get a new `CLICKHOUSE_URL` and restart. Data written since the backup is lost, and the database stops while the restore runs. Asks for confirmation; pass `--yes` in scripts. It also needs your passkey in the browser each time; CI tokens and AI tools cannot restore.

### deplane clickhouse destroy

Destroy a ClickHouse database and its data; its apps lose `CLICKHOUSE_URL`. Asks for confirmation; pass `--yes` in scripts. Needs your passkey each time. Its backups are kept for 3 days at most; `deplane clickhouse backups --destroyed` lists them.

There is no command to open a session with a ClickHouse database from your computer: it has no public address. To run SQL, use `deplane run` in an app whose image has a client; see [ClickHouse](clickhouse.md#client-notes).

## Admin commands

These commands work only for Deplane administrators. Everyone else gets [not_found](errors.md#not_found). Each one needs a fresh passkey confirmation from the last 5 minutes; in a terminal the CLI opens the browser for it.

### deplane admin requests

List beta access requests.

| Flag | What it does |
|---|---|
| `--status STATUS` | `pending` (default), `approved`, or `denied`. |

```sh
$ deplane admin requests
ID                         EMAIL             NAME      COMPANY   REQUESTED
acr_3n8m2b5v9c1x7z4l6k0j   dev@example.com   Dana Lee  Example   2026-10-12
```

```json
{
  "access_requests": [
    {
      "id": "acr_3n8m2b5v9c1x7z4l6k0j",
      "email": "dev@example.com",
      "name": "Dana Lee",
      "company": "Example",
      "use_case": "Internal tools",
      "status": "pending",
      "created_at": "2026-10-12T16:20:00Z"
    }
  ]
}
```

### deplane admin approve

Approve a pending access request by email or ID: `deplane admin approve EMAIL|ID`. This creates the user and a personal organization on the default plan, and tries to email them a sign-in link. Email sending is not set up yet, so the email may not arrive; contact them another way.

```sh
$ deplane admin approve dev@example.com
Approved dev@example.com. They were emailed a sign-in link.
```

```json
{
  "user_id": "usr_6f1d9s4a8p2o7i3u5y0t",
  "org_id": "org_9r4e7w2q5t8y1u6i3o0p",
  "email": "dev@example.com"
}
```

### deplane admin deny

Deny a pending access request by email or ID: `deplane admin deny EMAIL|ID`. Asks for confirmation; pass `--yes` to skip it.

```sh
$ deplane admin deny dev@example.com --yes
Denied dev@example.com.
```

```json
{
  "denied": "acr_3n8m2b5v9c1x7z4l6k0j"
}
```

### deplane admin abuse list

List abuse reports, oldest first, so the queue is worked in order.

| Flag | What it does |
|---|---|
| `--status STATUS` | `open` (default), `investigating`, `actioned`, `rejected`, or `all`. |

```sh
$ deplane admin abuse list
ID                            STATUS  CATEGORY  APP            URL                                  REPORTED
abr_2k7j4h9g1f6d3s8a5p0o1q2w  open    phishing  pay-verify-01  https://pay-verify-01.deplane.app/x  2026-10-13 07:55
```

```json
{
  "abuse_reports": [
    {
      "id": "abr_2k7j4h9g1f6d3s8a5p0o1q2w",
      "status": "open",
      "category": "phishing",
      "url": "https://pay-verify-01.deplane.app/x",
      "details": "This page copies our bank's login form.",
      "reporter_email": "victim@example.org",
      "reporter_ip": "203.0.113.9",
      "app_id": "app_8d2k4j6h1g3f5s7a9p0o1q2w",
      "app": "pay-verify-01",
      "org_id": "org_3j5h7g9f1d2s4a6p8o0i1u2y",
      "org_slug": "7f3k2p",
      "actions": [],
      "created_at": "2026-10-13T07:55:00Z",
      "resolved_at": null,
      "resolution_note": ""
    }
  ]
}
```

`app`, `app_id`, `org_id`, and `org_slug` are empty when the reported address is not a Deplane app. `reporter_email` is empty for an anonymous report.

### deplane admin abuse show

Show one abuse report in full: `deplane admin abuse show ID`. This includes the details, the reporter, the reported app and its organization, and the history of the report.

```sh
$ deplane admin abuse show abr_2k7j4h9g1f6d3s8a5p0o1q2w
Report:    abr_2k7j4h9g1f6d3s8a5p0o1q2w
Status:    open
Category:  phishing
URL:       https://pay-verify-01.deplane.app/x
App:       pay-verify-01 (organization 7f3k2p, org_3j5h7g9f1d2s4a6p8o0i1u2y)
Reporter:  victim@example.org from 203.0.113.9
Received:  2026-10-13T07:55:00Z
Details:
  This page copies our bank's login form.
```

The JSON is one report, with the same fields as in `deplane admin abuse list`.

### deplane admin abuse resolve

Move a report to a new status: `deplane admin abuse resolve ID --status investigating|actioned|rejected [--note TEXT]`. Each step is added to the report's `actions` history. `actioned` and `rejected` also record who resolved it, when, and the note. Resolving a report does not suspend anything; use `suspend-app` or `suspend-org` for that.

| Flag | What it does |
|---|---|
| `--status STATUS` | Required. `investigating`, `actioned`, or `rejected`. |
| `--note TEXT` | What you found or did. Stored with the report. |

```sh
$ deplane admin abuse resolve abr_2k7j4h9g1f6d3s8a5p0o1q2w --status actioned --note "Phishing confirmed; app suspended."
Report abr_2k7j4h9g1f6d3s8a5p0o1q2w is now actioned.
```

The JSON is the updated report, as in `deplane admin abuse show`, with `status`, `resolved_at`, `resolution_note`, and a new entry in `actions` (`at`, `by`, `status`, `note`).

### deplane admin suspend-app

Suspend an app by name, in any organization: `deplane admin suspend-app NAME --reason TEXT [--yes]`. The app runs zero instances and can no longer be deployed or changed. Nothing is deleted. The reason is recorded in the organization's audit log. Without a terminal, pass `--yes`.

| Flag | What it does |
|---|---|
| `--reason TEXT` | Required, at most 500 characters. Why the app is suspended; include the report ID. |

```sh
$ deplane admin suspend-app pay-verify-01 --reason "Phishing, report abr_2k7j4h9g1f6d3s8a5p0o1q2w" --yes
Suspended app pay-verify-01. Undo with `deplane admin unsuspend --app pay-verify-01`.
```

```json
{
  "id": "sus_4h6j8k0l2z4x6c8v0b2n4m6q",
  "target_type": "app",
  "target_id": "app_8d2k4j6h1g3f5s7a9p0o1q2w",
  "reason": "Phishing, report abr_2k7j4h9g1f6d3s8a5p0o1q2w",
  "created_at": "2026-10-13T08:10:00Z",
  "lifted_at": null
}
```

### deplane admin suspend-org

Suspend an organization by slug or ID: `deplane admin suspend-org SLUG --reason TEXT [--yes]`. All its apps run zero instances, and all its logins and tokens stop working (`account_suspended`). You cannot suspend your own organization.

| Flag | What it does |
|---|---|
| `--reason TEXT` | Required, at most 500 characters. Why the organization is suspended. |

```sh
$ deplane admin suspend-org 7f3k2p --reason "Repeated phishing" --yes
Suspended organization 7f3k2p. Undo with `deplane admin unsuspend --org 7f3k2p`.
```

The JSON has the same shape as for `suspend-app`, with `"target_type": "org"` and the organization's ID as `target_id`.

### deplane admin unsuspend

Lift a suspension: `deplane admin unsuspend --app NAME` or `deplane admin unsuspend --org SLUG`. Pass exactly one of the two. Apps return to the instance count they had. Without a terminal, pass `--yes`.

| Flag | What it does |
|---|---|
| `--app NAME` | Unsuspend this app. |
| `--org SLUG` | Unsuspend this organization (slug or ID). |

```sh
$ deplane admin unsuspend --org 7f3k2p --yes
Unsuspended organization 7f3k2p.
```

The JSON is the lifted suspension, with `lifted_at` set.

## Help

`deplane help` prints the command list, the global flags, the environment variables, and the exit codes. `deplane help --json` prints:

```json
{
  "commands": [
    {"command": "apps", "usage": "List your apps."},
    {"command": "deploy", "usage": "Build and deploy this folder's app again [--app NAME]."}
  ]
}
```
