# Stacks: several apps in one file

Describe several apps, their workers, cron jobs, databases, disks, domains and variables once in a `stack.yaml`, and Deplane creates them and keeps them in that shape.

The commands and their flags are in the [CLI reference](cli.md#deplane-stack-apply), and every error has a fix in [Troubleshooting and error codes](errors.md#stack_invalid).

## What a stack is

A stack is a group of your team's apps and managed databases that you describe in one file and apply together. You write `stack.yaml` once, keep it in your repository, and run `deplane stack apply`. Deplane shows what it will create, change or leave alone, asks you to confirm, then does it.

Apply the same file again and nothing happens. Change the file and apply again, and only the difference is applied. Every object in a stack is an ordinary app or database with the name you gave it: `deplane status --app shop-web`, the dashboard and every other command see it as usual.

Stacks are new. Today they work from the CLI and the API. A visual builder in the dashboard (the canvas) and Terraform / OpenTofu support are coming.

## A stack.yaml

```yaml
version: 1
name: shop

services:
  - name: shop-web
    source: {github: {repo: acme/shop, branch: main, root_dir: web}}
    port: 3000
    health_check: /healthz
    size: small
    instances: 2
    autoscale: {max: 4}
    release_command: npm run migrate
    domains: [shop.example.com]
    uses: [shop-db, shop-cache]
    env:
      NODE_ENV: production
      SESSION_SECRET: {generate: 48}
      STRIPE_KEY: {prompt: true}
      API_URL: {from: shop-api.private_url}
    workers:
      - {name: jobs, command: node worker.js, instances: 1}
    cron:
      - {name: nightly, command: node cleanup.js, schedule: "0 3 * * *"}

  - name: shop-api
    source: {image: ghcr.io/acme/shop-api:1.4.2}
    port: 8000
    private_network: true
    sleep_after_minutes: 0
    disk: {path: /data}
    uses: [shop-db]
    env:
      DB_HOST: {from: shop-db.host}
      DB_PASSWORD: {from: shop-db.password}

engines:
  - {name: shop-db, engine: postgres, version: 18, size: small}
  - {name: shop-cache, engine: redis}
```

This makes two apps, a Postgres database and a Redis. `shop-web` is built from a GitHub repository, uses both databases, runs a worker and a nightly cron job, and reaches `shop-api` over the [private network](private-networking.md). `shop-api` runs a public image with a disk.

The file is `stack.yaml` in the current folder; `-f FILE` picks another. At the top:

| Key | What it is |
|---|---|
| `version` | Always `1`. |
| `name` | The stack's name: 3 to 40 characters, lowercase letters, digits and single hyphens, starting with a letter (the [app name](limits.md#app-names) rules). |
| `services` | The apps. |
| `engines` | The managed databases. |

A stack needs at least one service or engine. Every service and engine needs its own name. A key Deplane does not know is an error, with its line and the key you probably meant, so a typo never goes unnoticed.

## Services

Each service is one app. Its `name` is the app's real name. The other keys:

| Key | What it does |
|---|---|
| `source` | Where the code comes from: exactly one of `image`, `github` or `path` (below). |
| `port` | The port your app listens on, 1 to 65535. A new app gets 8080. |
| `health_check` | A path that answers 200, such as `/healthz`. Left out, Deplane checks that the port accepts connections. |
| `size` | `small`, `medium` or `large`. A new app gets the smallest size your plan has. See [instance sizes](limits.md#instance-sizes). |
| `instances` | How many instances run, 0 (stopped) to 1000. A new app gets 1. |
| `autoscale` | `{max: N}`: grow up to N instances under load. N must be more than `instances`. See [Scaling](scaling.md#autoscaling). |
| `sleep_after_minutes` | `0` (never sleeps), `5`, `15` or `60`. See [sleep when idle](scaling.md#sleep-when-idle). |
| `release_command` | One line that runs before each new version serves, such as your migrations. See [Release command](release-command.md). |
| `private_network` | `true` lets your other apps reach this one at `http://NAME`. An app on the private network must not sleep. See [Private networking](private-networking.md). |
| `auto_rollback` | `false` turns off [automatic rollback](cli.md#automatic-rollback). It is on by default. |
| `stack_label` | The app's stack label in the dashboard, such as `Node.js`, at most 40 characters. |
| `disk` | `{path: /data}`: a 5 GB [disk](disks.md) at that path. An app with a disk runs exactly one instance, without `autoscale`. |
| `domains` | Up to 10 [custom domains](domains.md), such as `shop.example.com`. A domain belongs to one app. |
| `workers` | Background processes: `name`, `command`, `instances` (0 to 20, default 1) and `size` (default `small`). See [Workers and cron jobs](workers.md). |
| `cron` | Scheduled processes: `name`, `command`, `schedule` (cron syntax, UTC) and `size` (default `small`). |
| `uses` | The engines this app uses, by name. Each is attached: `DATABASE_URL`, `MYSQL_URL`, `REDIS_URL` or `CLICKHOUSE_URL` is set for you. One engine of each kind per app. |
| `env` | Runtime variables (below). |
| `build_env` | Variables for the build only, such as a token for private packages. See [Secrets](secrets.md). |

An app has at most 10 workers and cron jobs together. Their names are 2 to 22 characters, lowercase letters, digits and hyphens, and not `web`, `release` or anything starting with `run-`. A command is one line of at most 1000 characters. Workers need a plan whose apps never sleep, as does the private network.

When a key is left out, an app the stack creates gets Deplane's default. An app that already exists keeps its `port`, `size`, `instances`, `sleep_after_minutes`, `auto_rollback` and `stack_label`. For `health_check`, `release_command`, `private_network` and `autoscale` the file is the whole truth: leave one out and the apply turns it off.

### Source

| Source | What happens |
|---|---|
| `image: ghcr.io/acme/api:1.4.2` | Deplane deploys the image from a public registry, as [`deplane deploy --image`](quickstart.md#deploy-a-docker-image) does. It deploys when the app is new or the image in the file changes. |
| `github: {repo: acme/shop, branch: main, root_dir: web}` | Deplane connects the repository through its GitHub App and deploys the branch; every later push deploys, as on the [GitHub](github.md) page. `branch` and `root_dir` are optional. Pull request previews are off unless you add `previews: true`; with them off, there are no preview databases either. |
| `path: ./web` | The CLI uploads that folder (relative to `stack.yaml`, and inside its folder) once the apply has made the app, and deploys it as `deplane deploy` does. Only the CLI can do this. |

Connecting a repository for the first time needs a person: CI tokens cannot do it. The GitHub App must be installed on the repository, and you must have opened the dashboard's GitHub page in the last hour so Deplane knows you can reach it.

## Engines

Each engine is one managed database:

| `engine` | `size` | `version` | Attached as |
|---|---|---|---|
| `postgres` | `small` (default), `medium` or `large` | `17` (default) or `18` | `DATABASE_URL` |
| `mysql` | `medium` (default) or `large` | none | `MYSQL_URL` |
| `redis` | none: one size, 256 MB | none | `REDIS_URL` |
| `clickhouse` | `large` only (the default) | none | `CLICKHOUSE_URL` |

Engine names are 3 to 30 characters: lowercase letters, digits and single hyphens, starting with a letter. A Postgres cannot be called `postgres`, `template0` or `template1`, and its name cannot end in `-config`, `-failover`, `-sync` or `-failsafe`. Other databases are not offered yet; a file that asks for one is refused, never quietly dropped.

New databases start empty. To bring data in, use [`deplane db import`](import-postgres.md) for Postgres, or load a dump through `deplane run` for the others. See [Databases](databases.md), [MySQL](mysql.md), [Redis](redis.md) and [ClickHouse](clickhouse.md).

## Variables

A variable in `env` or `build_env` is one of four forms:

| Form | What it is |
|---|---|
| `NODE_ENV: production` | A value, as written. Deplane keeps it encrypted, to set it and to [export](#export) it when it does not look secret (neither its name nor a password in a URL). |
| `SESSION_SECRET: {generate: 48}` | A random value of 16 to 128 letters and digits, made once by Deplane and kept for the stack's life. Change N and a new one is made. |
| `STRIPE_KEY: {prompt: true}` | Asked for when you apply, so the value is never in the file (see [values asked for](#values-asked-for-when-you-apply)). |
| `DB_HOST: {from: shop-db.host}` | A value from another object of the stack (below). |

Names use capital letters, digits and underscores, and do not start with a digit. `PORT` and the names Deplane sets itself are refused, and so are the attach variables (`DATABASE_URL` and the others) when `uses` already sets them.

A value written in the file under a name that looks secret (a name with a part such as `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `PASS`, `CREDENTIAL`, `PRIVATE` or `DSN`) is allowed, with a ⚠: use `{prompt: true}` or `{generate: N}` instead and keep secrets out of your repository.

**From an engine.** `{from: ENGINE.FIELD}` follows the engine: when the app moves to another database (`deplane db switch`) it follows, as attach variables do. The engine must be in the service's `uses`. The fields:

| Engine | Fields |
|---|---|
| `postgres` | `url`, `host`, `port`, `user`, `password`, `database` |
| `mysql` | `url`, `host`, `port`, `user`, `password`, `database` |
| `redis` | `url`, `host`, `port`, `password` |
| `clickhouse` | `url`, `host`, `http_port`, `native_port`, `user`, `password`, `database` |

**From a service.** `{from: SERVICE.url}` is the other app's public HTTPS address. `{from: SERVICE.private_url}` is `http://SERVICE`, and needs `private_network: true` on that service.

`build_env` takes values, `{generate: N}` and `{prompt: true}`, but not `{from: ...}`: references are filled in when the app runs, not when it builds. One value is at most 32 KB. There is no `${VAR}` interpolation: one value means one thing everywhere.

## Check, plan, apply

**Check** reads the file on your computer and finds every problem at once, not only the first. When you are logged in, it then also plans the file against your team, as `deplane stack plan` does; logged out, it checks on your computer only and says so. It never changes anything.

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

$ deplane stack check
✗ services.shop-web.uses.shop-dbb: The stack has no engine called shop-dbb.
  Add it under engines:, or remove it from uses:.
Error: stack.yaml has problems (✗ above).
Fix: Fix them and run `deplane stack check` again.
```

Each problem names its place in the file (`services.shop-web.uses.shop-dbb`). A ✗ stops the stack; a ⚠ is worth reading but does not. `--json` prints `{"valid": ..., "problems": [...]}` (and `"plan"` when logged in), and the command exits with 1 when the file has a ✗ or the plan cannot be applied.

A service built from a `path` folder that has its own `deplane.toml` still uses it: what the stack leaves out (`port`, `instances`, `size`, `health_check`, `release_command`) comes from `deplane.toml`. Where both set a field, the stack wins, with a ⚠ ([set_in_both](errors.md#set_in_both)).

**Plan** checks the file against your team as it is now, and shows what an apply would do. It changes nothing.

```sh
$ deplane stack plan
Stack shop is new.
+ postgres shop-db (create)
+ redis shop-cache (create)
+ app shop-web (create)
    instances: 1 → 2
    autoscale.max: 0 → 4
    port: 8080 → 3000
    health_check: tcp → /healthz
    release_command: npm run migrate
    uses: shop-db
    uses: shop-cache
    env.NODE_ENV: set
    env.SESSION_SECRET: set
    env.STRIPE_KEY: set
    env.API_URL: set
    workers.jobs: created
    cron.nightly: created
    domains: shop.example.com
    github: acme/shop@main, previews off, deploys the latest commit
+ app shop-api (create)
    port: 8080 → 8000
    private_network: false → true
    disk: /data
    uses: shop-db
    env.DB_HOST: set
    env.DB_PASSWORD: set
    image: ghcr.io/acme/shop-api:1.4.2
Asked for when applied: shop-web/runtime/STRIPE_KEY.
```

**Apply** plans again, shows the plan, asks you to confirm (`--yes` skips the question and lets the apply set back variables changed by hand; a plan that deletes anything asks you to type the stack's name, which `--yes` does not skip), asks for the values the file marks `{prompt: true}`, then applies and follows it step by step:

```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
✓ redis shop-cache: create
✓ app shop-web: create
✓ app shop-web: scale
✓ app shop-web: config
✓ app shop-web: attach shop-db
✓ app shop-web: attach shop-cache
✓ app shop-web: env runtime
✓ app shop-web: process_create jobs
✓ app shop-web: process_create nightly
✓ app shop-web: domain_add shop.example.com
✓ app shop-web: github acme/shop
✓ app shop-api: create
...
✓ app shop-api: image ghcr.io/acme/shop-api:1.4.2
✓ app shop-web: release
✓ app shop-api: release
Applied stack shop.
```

The apply runs on Deplane's servers: databases first (it waits up to 15 minutes for each new one to be ready), then each service in file order: create, scale and settings, disk, attachments, variables, workers and cron jobs, domains, and its source. Then it waits for each app's build and first release, up to 45 minutes: an apply is done when its apps run, not when their builds are queued. A failed build ([stack_build_failed](errors.md#stack_build_failed)) or a release that does not pass its health check ([stack_app_unhealthy](errors.md#stack_app_unhealthy)) stops the apply there. Deletes come last. If you leave with Ctrl-C, the apply keeps going: `deplane stack status NAME` shows where it is.

When you type the stack's name to confirm deletes, the CLI sends the list it showed you; if the apply would delete anything else by then, it is refused ([stack_plan_changed](errors.md#stack_plan_changed)). `deplane stack retry` asks for the name too when deletes are still to run.

`path` folders are uploaded by the CLI at the end, so they need the command to stay until it finishes. With `--no-wait` the command returns at once and does not upload them: deploy those folders with `deplane deploy --app NAME` from each.

A stack has one apply at a time. With `--json`, `deplane stack plan` prints the plan and `deplane stack apply` prints the finished apply, each as one JSON document.

## What a plan shows

Every object gets one line with one action:

| Mark | Action | What it means |
|---|---|---|
| `+` | create | It does not exist yet; the apply makes it. |
| `~` | update | It exists and some settings change. Each change is listed below it. |
| `=` | unchanged | It is already as the file says. |
| `→` | adopt | It exists outside any stack and joins this one (with `--adopt`). |
| `⇢` | move | It is in another stack and moves into this one, with its values (with `--move`). |
| `-` | left | It is no longer in the file: it keeps running and stays in the stack until `--prune` deletes it or you put it back. |
| `✗` | delete | It is no longer in the file and the stack created it: `--prune` deletes it. |
| `!` | refused | The file asks for a change Deplane does not make (below). |

Variables show their names only, never values: `set`, `changed`, `unset`, or `set by hand` for one changed outside the stack. Problems follow as ✓/⚠/✗ lines, then whether the stack fits your plan: before anything is created, Deplane checks that the new apps, instances, memory, sizes, databases, disks, custom domains and pull request previews fit what your plan allows and what your team already uses, for example "✗ Needs 1 GB more memory; you have 512 MB free." See [Limits](limits.md#plans).

## What an apply changes, and what it leaves

An apply never deletes an app or a database because a line went missing from the file.

- **An app or engine removed from the file** keeps running and stays in the stack, with a ⚠ on every plan, until you apply with `--prune` (which deletes it, if the stack created it) or put it back in the file. Nothing leaves the stack unnoticed.
- **`--prune`** deletes the apps and databases the stack created that the file no longer has, with their disks and data. The CLI asks you to type the stack's name, then it needs a fresh confirmation with your passkey (the CLI opens your browser); each prune uses up its confirmation. CI tokens and AI tools cannot use it. It never deletes anything that joined with `--adopt`: those are left running, outside the stack. A database another app still uses is not deleted ([engine_in_use](errors.md#engine_in_use)).
- **Databases are never resized, upgraded or swapped.** An engine's kind, size and Postgres version cannot change once it exists. The plan refuses it and says how to do it by hand: add a new engine with the new setting, move the data with `deplane db import`, then point `uses` at it.
- **A disk never moves.** Its path cannot change. A disk the file no longer lists is kept, with its files, and a ⚠. Delete it with `deplane disks destroy` if you no longer need it.
- **An engine removed from `uses`** stays attached, with a ⚠. Detach it by hand (`deplane db detach`, `deplane redis detach` and the others).
- **A worker cannot become a cron job**, nor the other way round. Give it a new name.

Within an app the file does decide:

- **Domains** the file no longer lists are removed from the app and stop serving, with a ⚠.
- **Workers and cron jobs** the app has that the file does not list are deleted.
- **Variables** an earlier apply set and the file no longer has are unset. Variables you set by hand, and that the file never had, are left alone.

## Existing apps and databases

A stack can name an app or database your team already has. Without a flag, the plan refuses that ([name_taken](errors.md#name_taken)). With `--adopt`, the existing one joins the stack and its settings are changed to match the file. The apply that adopts an app keeps the domains, workers and cron jobs it has that the file does not list, each with a ⚠; from the next apply on, the file decides them as for any app in the stack. Check the plan first.

An object belongs to one stack at most. A name that is already in another stack is refused ([in_another_stack](errors.md#in_another_stack)), unless you apply with `--move`: the object then moves from its stack into this one, with its values (no secret changes) and whether a stack created it (so this stack can prune or destroy it later). Remove it from the other stack's file too. A database moves only together with the apps that use it, or once they are detached ([engine_in_use](errors.md#engine_in_use)). Only a person can move objects; CI tokens cannot.

## Changed by hand

You can still change a stack's apps by hand. Each apply records a keyed fingerprint (HMAC) of every value it sets, never the value itself, so the next plan can tell when a variable was set by hand since. The plan says so with a ⚠ ("was set by hand since the last apply") and shows `set by hand → set`. The apply sets it back to what the file says only with `--yes`; without it, it stops before changing anything ([stack_changed_outside](errors.md#stack_changed_outside)). To keep your change, put it in the file, or take the variable out of the file to manage it by hand.

## Values asked for when you apply

`{prompt: true}` values are asked for when you apply. The CLI takes each from `DEPLANE_STACK_<SERVICE>__<NAME>` (that service only, the service name in capitals with `_` for `-`, such as `DEPLANE_STACK_SHOP_WEB__STRIPE_KEY`) or `DEPLANE_STACK_<NAME>` (every service that asks for it), if one is set (it says which, never the value), else asks with a hidden prompt in a terminal. It never reads a shell variable that merely has the same name, such as `USER` or `PATH`. There is no flag for them. Without either, the apply stops before anything changes ([stack_prompt_missing](errors.md#stack_prompt_missing)). In CI, set the `DEPLANE_STACK_` variables in your CI's secret settings.

Deplane sets the value on the app and does not keep it: it is dropped when the apply ends. Later applies do not ask again, unless the variable was changed by hand since.

## Who may do what

Every step of an apply runs as you, through the same checks as the single commands. A stack never lets you do what your login or token could not do one step at a time.

| What | Your login | CI token | AI tools |
|---|---|---|---|
| `stack check`, `plan`, `list`, `status`, `export` | ✓ | ✓ | ✓ (the `stacks` and `check_stack` tools) |
| `stack apply`: create and update | ✓ | ✓ | ✗ |
| Connect a GitHub repository, or change its branch or folder | ✓ | ✗ | ✗ |
| `stack retry` | ✓ | ✓ | ✗ |
| `stack forget` | ✓ | ✗ | ✗ |
| `--prune`, `stack destroy` | ✓, with your passkey | ✗ | ✗ |

A CI token can keep applying a stack whose repositories a person connected once: apply once yourself after `deplane login`, then let CI apply. `deplane stack check` needs no login at all. AI tools connected to Deplane can read, check and plan stacks, never apply, retry, forget or destroy them. Every apply, adopt, prune, forget and destroy is in the team's activity log.

## Status, retry, forget, destroy

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

$ deplane stack status shop
Stack shop (blueprint, revision 3)
  postgres shop-db
  redis shop-cache
  app shop-web  Running, 2 ready  https://shop-web.deplane.app
  app shop-api  Running, 1 ready  https://shop-api.deplane.app
Last apply sta_8f2k...: done
```

Objects that joined with `--adopt` are marked `(adopted)`.

**Retry.** When a step fails, the apply stops there and says why. Fix the cause, then `deplane stack retry APPLY-ID` continues from the failed step; everything done before it is kept. A failed build or release is the exception: a retry would wait for the same build, so apply again instead ([stack_build_failed](errors.md#stack_build_failed)). Only the stack's newest apply can be retried. Values asked for at apply time are not kept, so when the remaining steps need them, apply again instead. When what is left deletes something (a prune or a destroy), the retry needs your own fresh passkey confirmation, as the delete did.

**Forget.** `deplane stack forget NAME` removes the stack and keeps every app and database running, outside any stack. Nothing is deleted. It first lists what it releases: afterwards no stack command deletes those, so delete them by hand if you no longer need them. Only a person can forget a stack; CI tokens and AI tools cannot.

**Destroy.** `deplane stack destroy NAME` deletes every app and database the stack created, with their disks and data, then the stack. It lists what goes, asks you to type the stack's name (always) and needs a fresh confirmation with your passkey. Objects that joined with `--adopt` are left running, outside any stack. A database the stack made that an app outside the stack still uses stops the destroy before anything is deleted ([engine_in_use](errors.md#engine_in_use)). CI tokens and AI tools cannot destroy.

## Export

`deplane stack export NAME` prints the stack as `stack.yaml`, from the last file applied:

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

An export never contains a secret. Values the file wrote out come back as written only when they do not look secret. A value whose name looks secret (such as `*_KEY`, `*_SECRET`, `*_TOKEN` or `*PASSWORD*`), or that is a URL with a password in it (`postgres://app:secret@db.example.com/app`), comes back as `{prompt: true}`. Generated values come back as `{generate: N}`, and references as they were. `--format yaml` is the only format for now.

## Limits

| Limit | Value |
|---|---|
| `stack.yaml` | 256 KB, one YAML document, nested at most 32 levels deep, at most 200 aliases. |
| Services per stack | 64 |
| Engines per stack | 64 |
| Workers and cron jobs per app | 10 |
| Custom domains per app | 10 |
| Steps in one apply | 2000 |
| Checks, plans and applies | 30 per team per 10 minutes, counting the API's check, plan and apply calls. `deplane stack apply` counts two (it plans, then applies); `deplane stack check` counts one when you are logged in (it plans), none when not. |
| Applies at the same time | 1 per stack |

Your plan's limits apply as they do to every app and database. See [Limits](limits.md#stacks).

## Coming next

- The canvas: every stack drawn in the dashboard, edited, checked and applied there, and exported as `stack.yaml`.
- Terraform and OpenTofu: a provider for teams that manage their infrastructure in `.tf` files, and `deplane stack export --format hcl`.
- Applying a stack on every push to its repository.
