# 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`. |
| `--team SLUG` | The team to act in, by its slug (`deplane teams` lists them). Default: the one `deplane team switch` set. Needs a login for that team on this computer. |
| `--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` | The team to act in, like `--team`. In a pipeline job: the team's slug, when one repository is trusted by apps of several teams. Ignored when `DEPLANE_TOKEN` is set, which already picks the team. |
| `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.

A login works in one team. If you are in several, the approval page asks which; `--team SLUG` pre-selects one. Each team keeps its own login on this computer, and the first one becomes the default (see [deplane team switch](#deplane-team-switch)). Logging in to a team again replaces that team's login. 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 (team you-example).
```

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

`org` is the team's slug. `stored_in` is `keychain` or `file`. `default` says whether it is the default team.

### deplane logout

Revoke this computer's login for the default team (or `--team SLUG`) and remove it. `--all` logs out of every team.

```sh
$ deplane logout
Logged out of team you-example. The token was revoked and removed from this computer.
```

```json
{
  "logged_out": true,
  "teams": ["you-example"],
  "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. |
| `--community ID` | Deploy a listed [community template](templates.md#community-templates), 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).

`deplane launch --community ID [--name APP]` deploys a listed [community template](templates.md#community-templates) (see `deplane community list`). The command first says whose template it is, with the badge "Community — not made or endorsed by Deplane.", its licence and the commit Deplane checked. Then it deploys the image Deplane checked and follows the steps exactly like `--template`. A template your team has no room for stops before anything is made ([template_no_room](errors.md#template_no_room), [template_needs_upgrade](errors.md#template_needs_upgrade)). It cannot be combined with `--template`, `--image`, `--repo` or `--port` ([flag_conflict](errors.md#flag_conflict)). CI tokens and AI tools cannot use it ([community_deploy_interactive_only](errors.md#community_deploy_interactive_only)).

```sh
$ deplane launch --community cml_4k8m2q7x9c1v5b3n6z0a --name notes-qa
Notes by Acme Labs. Community — not made or endorsed by Deplane.
A notes app with Postgres.
Licence MIT. Source acme/notes-template at 9f2c4e1, checked and reviewed by Deplane before it was listed.
Deploying the community template Notes as notes-qa (tpd_6v2b8n4m1k7j3h9g5f0d).
  Create app: done
  Create database: done
  Attach database: done
  Set secrets: done
  Build: done
  Deploy: done
notes-qa is live at https://notes-qa.deplane.app
```

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, follow them live, or read and search the logs your plan keeps.

| 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. |
| `--since TIME` | Read the logs your plan keeps instead of the running instances: from an age (`30m`, `3h`, `3d`), a UTC day (`2026-11-03`) or a time (`2026-11-03T14:00:00Z`). |
| `--until TIME` | With kept logs: only lines before this age or time. Default now. |
| `--search TEXT` | With kept logs: only lines that hold this text, not case-sensitive. At most 200 characters. |
| `--limit N` | With kept logs: the newest N matching lines, from 1 to 5000. Default 1000. |

```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).

With `--since`, `--until` or `--search`, `deplane logs` reads the logs your plan keeps (7 days on Free up to 365 days on Enterprise; see [Limits](limits.md#logs)). One read covers at most 31 days. The newest matching lines print oldest first, each with its time in UTC. When earlier lines match, the CLI says the command that shows them:

```sh
$ deplane logs --since 3d --search timeout --limit 2
2026-11-09 10:14:02 jobs/q9z1m | timeout talking to the database
2026-11-09 11:40:57 x7k2p | upstream timeout after 30s
More lines match. For the earlier ones: deplane logs --since 3d --until 2026-11-09T10:14:02Z --search timeout --limit 2
```

An earlier `--since` than your plan keeps starts at the oldest kept day, with a note. `--follow` cannot be combined with these flags. Kept logs are being switched on team by team; until then the command answers [logs_not_kept](errors.md#logs_not_kept).

### 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    SOURCE              UPDATED
NPM_TOKEN     build    app                 2026-10-13 09:30
DATABASE_URL  runtime  app                 2026-10-13 09:50
SENTRY_DSN    runtime  group:shared-config -
```

SOURCE says where each comes from: `app`, or the [variable group](variable-groups.md) that gives it, marked `(overridden)` when the app sets the same name itself, which wins.

```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"}
  ],
  "groups": [
    {"group": "shared-config", "variables": [{"name": "SENTRY_DSN", "scope": "runtime", "overridden": false}]}
  ]
}
```

## Variable groups

Secrets shared by several apps. See [Variable groups](variable-groups.md). These commands need a person: a `deplane login`, not a CI token.

### deplane groups list

List the team's groups, how many variables each has, and the apps that use it.

### deplane groups create

Create an empty group: `deplane groups create NAME`.

### deplane groups show

Show a group's variable names (never values) and its apps: `deplane groups show NAME`.

### deplane groups set

Set variables in a group: `deplane groups set NAME KEY=VALUE [KEY=VALUE...]`. Pass `--build` for build variables. A runtime change creates a new release of every app that uses the group and already has a release.

```sh
$ deplane groups set shared-config SENTRY_DSN=https://key@sentry.example.com/1
Set runtime variable(s) SENTRY_DSN in shared-config.
Created release v7 of shop.
```

### deplane groups unset

Remove variables from a group: `deplane groups unset NAME KEY [KEY...]`. Pass `--build` to remove build variables.

### deplane groups attach

Make an app use a group: `deplane groups attach NAME --app APP`. The app's own variables win over the group's.

### deplane groups detach

Make an app stop using a group: `deplane groups detach NAME --app APP`.

### deplane groups delete

Delete a group and its variables: `deplane groups delete NAME`. Asks for confirmation; pass `--yes` to skip it. It also needs a confirmation in your browser with your passkey, which `--yes` does not skip. Refused while an app uses the group.

## 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 at your DNS provider for you: `deplane domains switch DOMAIN [--provider P] [--lower-ttl] [--check] [--yes] [--no-wait] [--rollback FILE]`. `P` is `cloudflare`, `route53`, `digitalocean`, `gandi`, `porkbun`, `godaddy`, or `manual` to set the records by hand; by default, the provider the domain's nameservers name. `--lower-ttl` first lowers the TTL of the records that will change and says when to switch. 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 where the provider can (Cloudflare, Route 53), one at a time elsewhere, 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. The key comes from stdin or the provider's variables, never a flag, and never goes to Deplane. See [Switch DNS for me](domains.md#switch-dns-for-me).

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 -]`. `--secret -` reads the secret key of your own Turnstile widget from stdin (how to make one: [CAPTCHA](forms.md#captcha)); without `--secret`, the form keeps the key it has. Turning the check on needs a key ([captcha_key_required](errors.md#captcha_key_required)).

## 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 copy

Copy one of your team's databases into a **new** database, in the same Postgres version or a newer one: the way to move a database from Postgres 17 to 18 ([Databases](databases.md#copy-one-of-your-databases)). It checks the database, prints what it found, starts the copy and follows it until the new database is ready, then prints the `deplane db switch` command to run.

The copy is a snapshot: writes to the database after it starts are not in the new one, so stop your apps' writes first (for example `deplane scale 0` in each app that writes to it). It is always a one-time copy (`--mode live` is refused). `--name` names the new database (default `NAME-pgVERSION`, such as `main-pg18`, when that name is free; otherwise a terminal is asked and a script must pass `--name`), `--size` sets its size (default the source's), `--version` its Postgres version (default the source's; an older one is refused with [source_newer_than_target](errors.md#source_newer_than_target)). `--exclude-schema`, `--yes` and `--no-wait` work as for `deplane db import`, and `deplane db import status` and `deplane db import cancel` work on a copy. The database cannot be deleted while the copy runs. Copying is for people: CI tokens and AI tools cannot run it. The source can be at most 5 GB.

```sh
$ deplane db copy main --version 18 --yes
Writes to main after the copy starts are not in main-pg18. Stop your apps' writes first, for example `deplane scale 0` in each app that writes to it.
Checking main ...
Checked main
  Postgres 17.6, 40 MB, 3 tables
  Copy: possible
Copying main into main-pg18.
Copying the data into main-pg18 ...
main-pg18 is ready: a copy of main.
Check it, then move your apps to it with `deplane db switch main-pg18 --from main`.
```

### 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|hcl]`. 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 hcl` prints it as `main.tf` for the [Terraform and OpenTofu provider](terraform.md) instead, and says on stderr what the file could not include (a disk, ClickHouse, a GitHub source, values Deplane generated). Other formats: [invalid_format](errors.md#invalid_format). `--json` prints the file, the warnings and the notice as one document.

```sh
$ deplane stack export shop > stack.yaml
$ deplane stack export shop --format hcl > main.tf
The GitHub source of shop-web and the disk of shop-api could not be included in main.tf; the rest was exported.
```

### 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 sync on

Apply a stack on every push: `deplane stack sync on NAME --repo OWNER/NAME [--branch main] [--path stack.yaml] [--environments]`. A push to the branch applies the file at that path, as you: what a CI token of yours could do, never a delete, a value asked for at apply time, a new repository connection, an adopt or a move (those stop the sync's apply with "a person must apply"). With `--environments`, every open pull request gets its own copy of the stack, deleted when it closes; the stack's apps make no previews of their own. Only a person can turn sync on; it stops, and tells the team's owners, if you leave the team.

GitLab: `--provider gitlab --project ID --branch main`. Bitbucket: `--provider bitbucket --repo WORKSPACE/NAME --branch main`. Both need a token limited to the repository, read from `DEPLANE_STACK_SYNC_TOKEN` or a hidden prompt, and a webhook you add on the host: the command prints its URL. Bitbucket's secret is shown once; GitLab's you paste with `deplane stack sync secret`. That one webhook also deploys every app of the stack connected to the same repository and branch.

```sh
$ deplane stack sync on shop --repo acme/shop --environments --yes
Stack shop syncs from GitHub acme/shop, branch main, file stack.yaml.
On. Pushes apply as ada@example.com.
Every open pull request gets its own copy of the stack.
```

### deplane stack sync secret

Set a GitLab sync's webhook secret: `deplane stack sync secret NAME`. Add the webhook in GitLab (Settings, Webhooks, the URL `deplane stack sync status` shows, push and merge request events), then paste its secret token here. It is read from `DEPLANE_STACK_SYNC_WEBHOOK_SECRET` or a hidden prompt, never a flag.

### deplane stack sync status

Show a stack's sync: `deplane stack sync status NAME [--json]`: repository, branch, file, whether it is on or stopped (and why), who it runs as, the last commit and apply, and the webhook URL for GitLab and Bitbucket.

### deplane stack sync off

Stop applying a stack on push: `deplane stack sync off NAME [--yes]`. It lists the pull request environments it deletes, with their databases, and asks first. They are deleted in the background over the next minutes, then the sync is gone; until then `deplane stack sync status` says so. The stack keeps running.

### deplane stack env list

List a stack's pull request environments: `deplane stack env list NAME [--json]`. Each line is the pull request, the environment's name (`NAME-pr-N`), its branch and commit, its apps and its last apply.

```sh
$ deplane stack env list shop
#7  shop-pr-7  feature-x@abcdef0  shop-web-pr-7  apply done
```

### deplane stack env delete

Delete one pull request environment, its apps and databases: `deplane stack env delete NAME-pr-N [--yes]`. A person or a CI token can; a push to the pull request makes it again, with empty databases.

### 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.

### deplane teams

List the teams you are in, your role, and which have a login on this computer. Only your own login can list them, never a CI or agent token.

```sh
$ deplane teams
SLUG      NAME   ROLE    LOGIN HERE
k3j9x0ab  Acme   member  yes (default)
q81mzv2c  Solo   owner   no
```

### deplane team switch

Make another team the default for every command on this computer: `deplane team switch SLUG`. Log in to it first with `deplane login --team SLUG`. For a single command, pass `--team SLUG` instead.

### deplane team leave

Leave the default team (or `--team SLUG`). Your tokens for it stop working at once, and its login is removed from this computer; your other teams are not affected. A team's last owner cannot leave ([last_owner](errors.md#last_owner)). 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.

## Community templates

Templates other teams publish from their public GitHub repositories, checked by Deplane and approved by a person before they are listed. See [Community templates](templates.md#community-templates). Claiming, submitting and unpublishing need a passkey confirmation from the last 5 minutes (the CLI opens your browser for it). CI tokens and AI tools cannot claim, submit or deploy. Titles, descriptions and publisher names are the publishers' own text.

### deplane community list

Lists the listed community templates, and your team's own listings in every state.

```sh
$ deplane community list
ID                        TITLE  PUBLISHER  LICENCE  REPOSITORY           STATUS
cml_4k8m2q7x9c1v5b3n6z0a  Notes  Acme Labs  MIT      acme/notes-template  listed

Community templates are made by their publishers, not made or endorsed by Deplane.
Deploy one with `deplane launch --community ID`.
```

```json
{
  "listings": [
    {
      "id": "cml_4k8m2q7x9c1v5b3n6z0a",
      "repo": "acme/notes-template",
      "repo_name": "acme/notes-template",
      "status": "listed",
      "own": false,
      "publisher": "Acme Labs",
      "licence": "MIT",
      "badge": "Community — not made or endorsed by Deplane",
      "version": "cmv_8d3f6h1j4k7l2z5x9c0v",
      "sha": "9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e",
      "source_url": "https://github.com/acme/notes-template",
      "commit_url": "https://github.com/acme/notes-template/commit/9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e",
      "deploy_url": "/new?community=cml_4k8m2q7x9c1v5b3n6z0a",
      "template": {"title": "Notes", "description": "A notes app with Postgres.", "image": null, "source": true, "...": "..."},
      "created_at": "2026-11-12T09:00:00Z",
      "updated_at": "2026-11-12T10:30:00Z"
    }
  ]
}
```

`template` has the fields of `deplane templates show --json`: what the template makes, the variable names (never values) and whether your team has room. Your team's own listings have `"own": true`, their `delist_reason` and every version in `versions`.

### deplane community show

`deplane community show LISTING-ID` shows one listing: its badge, description, publisher, the repository and commit, the licence, its status and what it needs. For your team's own listing, it also lists every version with its state, the reviewer's reason and each check's result. A check that found a secret names the file and line, never the value.

```sh
$ deplane community show cml_4k8m2q7x9c1v5b3n6z0a
Notes (community template cml_4k8m2q7x9c1v5b3n6z0a)
Community — not made or endorsed by Deplane.

A notes app with Postgres.

By:        Acme Labs
Source:    github.com/acme/notes-template at v1.0.0 (9f2c4e1)
Licence:   MIT
Status:    listed
Needs:     1 GB memory, size small, Postgres (small)

Versions:
  cmv_8d3f6h1j4k7l2z5x9c0v  v1.0.0 (9f2c4e1)  approved  submitted 2026-11-12
    source: passed: Downloaded and repacked.
    licence: passed: Licence MIT; no secrets found.
    test_deploy: passed: The test deploy went live and answered its health path.
    image_scan: passed: No HIGH or CRITICAL flaw with a fix; 0 without one.
    mirror: passed: The image is kept in Deplane's registry.

Deploy it with `deplane launch --community cml_4k8m2q7x9c1v5b3n6z0a`.
```

The JSON is one listing, as in `deplane community list`. Someone else's listing that is not listed is [community_listing_not_found](errors.md#community_listing_not_found).

### deplane community claim

`deplane community claim OWNER/REPO --publisher NAME` claims a public GitHub repository for your team and prints the code to put in its `deplane.template.json`. You must be a team owner or admin, on a paid plan, in a team at least 30 days old ([community_publish_not_allowed](errors.md#community_publish_not_allowed)).

| Flag | What it does |
|---|---|
| `--publisher NAME` | Required. The name shown on your listing: 2 to 40 letters, digits, spaces, dots and hyphens, no brand names. |

```sh
$ deplane community claim acme/notes-template --publisher "Acme Labs"
Claimed acme/notes-template for the publisher name "Acme Labs". Your claim code: dpc_abcdefghijklmnopqrstuvwx

Add this line to the top-level object of deplane.template.json at the root of acme/notes-template (with a comma where JSON needs one):

  "publisher": "dpc_abcdefghijklmnopqrstuvwx"

Push it to a branch or a tag, then submit that branch or tag before 2026-11-19 09:00 UTC:

  deplane community submit acme/notes-template@BRANCH-OR-TAG
```

```json
{
  "id": "ccl_2n5b8v1c4x7z0l3k6j9h",
  "repo": "acme/notes-template",
  "publisher_name": "Acme Labs",
  "code": "dpc_abcdefghijklmnopqrstuvwx",
  "status": "pending",
  "created_at": "2026-11-12T09:00:00Z",
  "expires_at": "2026-11-19T09:00:00Z",
  "verified_at": null,
  "released_at": null
}
```

`status` is `pending` until your first submission verifies the claim, then `verified`; an unverified claim is `expired` after 7 days. Claiming a repository your team already holds prints the same claim and code. Another team's verified claim refuses it ([community_repo_claimed](errors.md#community_repo_claimed)).

### deplane community submit

`deplane community submit OWNER/REPO@REF` submits a branch or a tag of a claimed repository. Deplane pins it to its commit, checks that the file there carries your claim code, and starts the checks; a platform admin then reviews it. The first submission creates the listing. Follow the checks with `deplane community show LISTING-ID`.

```sh
$ deplane community submit acme/notes-template@v1.0.0
Submitted acme/notes-template@v1.0.0 (9f2c4e1) as version cmv_8d3f6h1j4k7l2z5x9c0v of the listing cml_4k8m2q7x9c1v5b3n6z0a.
Deplane checks it now (licence, secrets, a test deploy, an image scan), then a person reviews it.
Follow it with `deplane community show cml_4k8m2q7x9c1v5b3n6z0a`.
```

```json
{
  "listing": {"id": "cml_4k8m2q7x9c1v5b3n6z0a", "repo": "acme/notes-template", "status": "draft", "own": true, "...": "..."},
  "version": {
    "id": "cmv_8d3f6h1j4k7l2z5x9c0v",
    "listing_id": "cml_4k8m2q7x9c1v5b3n6z0a",
    "ref": "v1.0.0",
    "sha": "9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e",
    "status": "queued",
    "step": null,
    "checks": [],
    "licence": null,
    "submitted_at": "2026-11-12T09:05:00Z",
    "reviewed_at": null,
    "review_reason": null
  }
}
```

Without an `@` and a branch or tag it stops with [community_ref_missing](errors.md#community_ref_missing). A bare commit is refused ([community_ref_not_allowed](errors.md#community_ref_not_allowed)), and so is a file without your code ([community_publisher_mismatch](errors.md#community_publisher_mismatch)).

### deplane community unpublish

`deplane community unpublish LISTING-ID` takes your team's listing off the dashboard and the site at once. Apps already deployed from it keep running. It asks first; pass `--yes` in scripts. To list it again, submit a new version.

```sh
$ deplane community unpublish cml_4k8m2q7x9c1v5b3n6z0a --yes
Unpublished cml_4k8m2q7x9c1v5b3n6z0a (acme/notes-template).
```

The JSON is the listing, with `"status": "unpublished"`.

## 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.

### deplane redis import

Copy a Redis from another host into a **new** Deplane Redis, in one of three ways (see [Import a Redis](redis-import.md)):

- **From its URL** (the default): the `redis://` or `rediss://` URL is asked for at a hidden prompt, or read from stdin when no terminal is attached. The source is only read.
- **From a link to an RDB file**, with `--rdb-url`: an `https://` link is asked for the same way. The link is treated as a password: a presigned link's query string lets anyone download the file.
- **From a file on this computer**, with `--rdb FILE`: the CLI checks that it is an RDB file of at most 256 MB, version 11 or older, then uploads it.

The URL and the link are never taken from an argument or a flag (they would land in your shell history), are never printed, and `--debug` hides them; `deplane redis import rediss://...` is refused without repeating it.

With a URL or a link, the command waits for the check and prints what it found: for a URL, the server and version, the size of the data, the keys in each database (only the one the URL names is copied), how keys are copied (`DUMP` and `RESTORE`, or type by type for a Redis newer than Deplane's, and what that loses), warnings and the verdict; for a link, the file's RDB version and size and the verdict. If the import is not possible, it prints the reasons and exits 1. It then asks for the new Redis's name (`--name` answers), and for a URL asks you to confirm that writes to the source are stopped, since keys written during the copy may be missed (`--yes` answers). It starts and follows the copy: keys copied and scanned while it runs, then the counts, warnings, and the command to attach the Redis. `--rdb FILE` asks only for the name. `--no-wait` returns right after the start; `--json` prints the import as one document.

Without a terminal, `--name` is required, and `--yes` for a URL. An import is for people: CI tokens and AI tools cannot run it. A team has one Redis import at a time.

```sh
$ printf '%s' "$REDIS_URL" | deplane redis import --name cache --yes
Checking redis.example.com:6380/0 ...
Checked redis.example.com:6380/0
  Redis 7.2.4, 12 MB of data
  Keys:
    db0: 3400 keys (120 expire) copied
  Types (a sample): hash 150, list 50, string 800
  Copy method: DUMP and RESTORE (an exact copy of each key, with its expiry time)
  Verdict: possible
Copying the keys into cache ...
Copied 3398 keys into cache (3400 on the source, 3398 in cache, 2 expired during the copy).
cache is ready: the data is copied into it.
Attach it to an app with `deplane redis attach cache --app APP`.

$ deplane redis import --rdb ./dump.rdb --name cache
```

### deplane redis import status

Show a Redis import: its state, source, new Redis and, while it copies, the keys copied so far. `ID` defaults to the newest active import (or, with none, the newest). With `--json`, one document with the full report.

### deplane redis import cancel

Cancel a Redis import, in any state before it is done. The new Redis, if there is one, is emptied and stays; delete it with `deplane redis destroy`. Asks for confirmation; `--yes` skips it. `ID` defaults to the newest active import.

## 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.

### deplane admin community review

List the [community template](templates.md#community-templates) versions that passed their checks and wait for review, oldest first.

```sh
$ deplane admin community review
VERSION                   LISTING                   REPOSITORY           REF               PUBLISHER  TEAM                      SUBMITTED
cmv_8d3f6h1j4k7l2z5x9c0v  cml_4k8m2q7x9c1v5b3n6z0a  acme/notes-template  v1.0.0 (9f2c4e1)  Acme Labs  org_3j5h7g9f1d2s4a6p8o0i  2026-11-12 09:05
```

The JSON has a `versions` list; each is a version as in `deplane admin community show`.

### deplane admin community show

Show one version for review: `deplane admin community show VERSION-ID`. This includes the publisher and their team, the repository, commit and the changes since the listed version, the licence found, what the template makes and the variable names it sets, every check's result, the image's HIGH and CRITICAL flaws without a fix, and the `deplane.template.json` as submitted.

```sh
$ deplane admin community show cmv_8d3f6h1j4k7l2z5x9c0v
Version:   cmv_8d3f6h1j4k7l2z5x9c0v (in_review)
Listing:   cml_4k8m2q7x9c1v5b3n6z0a
Publisher: Acme Labs (team org_3j5h7g9f1d2s4a6p8o0i)
Source:    acme/notes-template at v1.0.0 (9f2c4e1)
Commit:    https://github.com/acme/notes-template/commit/9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e
Licence:   MIT
Submitted: 2026-11-12T09:05:00Z

Notes
A notes app with Postgres.
Makes:     an app (small), a Postgres database (small); needs 1 GB
Sets:      SECRET_KEY, DB_URL

Checks:
  source: passed: Downloaded and repacked.
  licence: passed: Licence MIT; no secrets found.
  test_deploy: passed: The test deploy went live and answered its health path.
  image_scan: passed: No HIGH or CRITICAL flaw with a fix; 0 without one.
  mirror: passed: The image is kept in Deplane's registry.

deplane.template.json as submitted:
{
  "title": "Notes",
  ...
}

Approve it with `deplane admin community approve cmv_8d3f6h1j4k7l2z5x9c0v`, or reject it with `deplane admin community reject cmv_8d3f6h1j4k7l2z5x9c0v --reason TEXT`.
```

```json
{
  "id": "cmv_8d3f6h1j4k7l2z5x9c0v",
  "listing_id": "cml_4k8m2q7x9c1v5b3n6z0a",
  "listing_status": "draft",
  "repo": "acme/notes-template",
  "publisher": "Acme Labs",
  "team": "org_3j5h7g9f1d2s4a6p8o0i",
  "submitted_by": "usr_6f1d9s4a8p2o7i3u5y0t",
  "ref": "v1.0.0",
  "sha": "9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e",
  "status": "in_review",
  "step": null,
  "licence": "MIT",
  "image_digest": "sha256:4f1c2a9e8b7d...",
  "checks": [
    {"name": "licence", "result": "passed", "reason": "Licence MIT; no secrets found.", "details": {"licence": "MIT"}}
  ],
  "commit_url": "https://github.com/acme/notes-template/commit/9f2c4e1a7b3d5f8e0c2a4b6d8f1e3c5a7b9d0f2e",
  "listed_sha": null,
  "compare_url": null,
  "file": "{\n  \"title\": \"Notes\", ...}",
  "template": {"title": "Notes", "...": "..."},
  "submitted_at": "2026-11-12T09:05:00Z",
  "reviewed_at": null,
  "review_reason": null
}
```

A check's `result` is `passed`, `failed` (the version broke a rule; `code` says which, such as `secret_found`) or `error` (the check could not run to the end). A secret's `details` give its file, line and rule, never its value.

### deplane admin community approve

Approve a version that is in review: `deplane admin community approve VERSION-ID`. It becomes its listing's listed version and the listing is listed; the version listed before is `superseded`. You cannot approve a version your own team submitted ([community_review_own](errors.md#community_review_own)).

```sh
$ deplane admin community approve cmv_8d3f6h1j4k7l2z5x9c0v
Approved cmv_8d3f6h1j4k7l2z5x9c0v: the listing cml_4k8m2q7x9c1v5b3n6z0a now lists acme/notes-template at v1.0.0 (9f2c4e1).
```

The JSON is the version, as in `deplane admin community show`, with `"status": "approved"`.

### deplane admin community reject

Reject a version that is in review: `deplane admin community reject VERSION-ID --reason TEXT`. The publishing team sees the reason. The listing keeps the version it had.

| Flag | What it does |
|---|---|
| `--reason TEXT` | Required, at most 500 characters. What the publisher must change. |

```sh
$ deplane admin community reject cmv_8d3f6h1j4k7l2z5x9c0v --reason "The test page asks for card numbers."
Rejected cmv_8d3f6h1j4k7l2z5x9c0v. The publishing team sees the reason; the listing keeps its current version.
```

The JSON is the version, with `"status": "rejected"` and `review_reason`.

### deplane admin community delist

Take a listing off the dashboard and the site at once: `deplane admin community delist LISTING-ID --reason TEXT [--yes]`. The publishing team sees the reason and cannot list it again. Apps already deployed from it keep running; to stop one, suspend it. Asks for confirmation; pass `--yes` in scripts.

| Flag | What it does |
|---|---|
| `--reason TEXT` | Required, at most 500 characters. Shown to the publishing team; include the report ID. |

```sh
$ deplane admin community delist cml_4k8m2q7x9c1v5b3n6z0a --reason "Malware, report abr_2k7j4h9g1f6d3s8a5p0o" --yes
Delisted cml_4k8m2q7x9c1v5b3n6z0a (acme/notes-template). Undo with `deplane admin community relist cml_4k8m2q7x9c1v5b3n6z0a`.
```

The JSON is the listing, with `"status": "delisted"`, `delist_reason` and `delisted_at`.

### deplane admin community relist

List a delisted listing again: `deplane admin community relist LISTING-ID`. It is listed again if it has an approved version, and "not listed yet" (`draft`) if not. A listing whose claim was released, or whose repository another listing now holds, stays delisted ([community_repo_claimed](errors.md#community_repo_claimed)); so does one whose kept image was deleted ([community_version_gone](errors.md#community_version_gone)).

```sh
$ deplane admin community relist cml_4k8m2q7x9c1v5b3n6z0a
Relisted cml_4k8m2q7x9c1v5b3n6z0a (acme/notes-template); it is listed.
```

### deplane admin community release-claim

Release a team's claim of a repository: `deplane admin community release-claim CLAIM-ID [--yes]`. The team's listing of the repository is delisted with the reason "claim released"; apps already deployed keep running. Another team can then claim the repository. The publishing team finds the claim's ID with `deplane community claim OWNER/REPO --publisher NAME --json`. Asks for confirmation; pass `--yes` in scripts.

```sh
$ deplane admin community release-claim ccl_2n5b8v1c4x7z0l3k6j9h --yes
Released the claim ccl_2n5b8v1c4x7z0l3k6j9h of acme/notes-template. Its listing is delisted (reason "claim released"); another team can claim the repository.
```

The JSON is the claim, with `"status": "released"`, `released_at` and the team's ID in `team`, without its code.

## 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]."}
  ]
}
```
