# Moving from Heroku

Move a Heroku app to Deplane with one command: `deplane import heroku` reads the app on Heroku, shows what moves and what stays, then creates the Deplane app with its secrets, workers and release command, copies the Postgres database, adds a Redis, and deploys your code. Heroku keeps serving your users until you switch.

The command runs on your own computer with a short-lived Heroku token that never leaves it. It only reads Heroku: nothing there is changed. The command and its flags are in the [CLI reference](cli.md#deplane-import-heroku), and every error has a fix in [Troubleshooting and error codes](errors.md#heroku_token_missing).

## Before you start

1. [Install the Deplane CLI](install.md) and run `deplane login`.
2. Have the app's code in a folder: a checkout of the repository you deploy to Heroku, at the commit Heroku runs. The report shows that commit and whether your folder is at it.
3. Log in to the Heroku CLI (`heroku login`).

**The Heroku token.** Create a token that can only read, and only for an hour:

```sh
heroku authorizations:create --scope read-protected --expires-in 3600 --short
```

`read-protected` lets it read config vars, including `DATABASE_URL`, which the import needs; it cannot change anything on Heroku. Don't use `heroku auth:token`: that one can do everything your account can, for a year.

Give the token to Deplane in one of three ways, never as a flag:

- pipe it in: `heroku authorizations:create ... --short | deplane import heroku my-app --check`;
- set it for one command: `HEROKU_API_KEY=$(heroku authorizations:create ... --short) deplane import heroku my-app`;
- or run `deplane import heroku my-app` in a terminal and paste it at the hidden prompt.

A token piped on stdin wins over `HEROKU_API_KEY`: the variable is used when nothing is piped in (when it is set, the CLI waits up to 20 seconds for a piped token, then uses it). The CLI says which one it used ("Using the Heroku token piped on stdin."), never the token itself. It sends the token only to Heroku's API (`api.heroku.com`). It is never written to disk, never printed, left out of `--debug`, and never sent to Deplane.

## Check first

`--check` reads the app and prints the report without creating anything. When you are logged in to Deplane, it also reads your plan and what your team uses (read-only) and checks that the import fits; without a Deplane login it says the plan was not checked yet, and the import checks it.

```sh
$ heroku authorizations:create --scope read-protected --expires-in 3600 --short | deplane import heroku shop --check
Heroku app shop (region us, stack heroku-24, commit 1a2b3c4) → Deplane app shop

Code
  ✓ Deploys /home/me/shop with Railpack: Detected Node 22, port 3000.
  ✓ Your folder is at commit 1a2b3c4, the one Heroku runs.

Processes
  ✓ web: 2 × standard-2x on Heroku → 2 medium instance(s).
  ✓ worker: 1 × standard-1x on Heroku → worker worker, 1 small instance(s): `node worker.js`.
  ✓ release phase → release command `npm run migrate`.

Data
  ✓ Postgres (heroku-postgresql:essential-0): copied into a new Deplane database shop-db ...
  ⚠ Redis (heroku-redis:mini): a new Deplane Redis shop-redis ... Its data is not copied ...

Config vars
  ✓ 3 copied as secrets: APP_URL, SECRET_KEY_BASE, SENDGRID_API_KEY.
  ✓ Not copied, Deplane sets them: DATABASE_URL, PORT, REDIS_URL.
  ✓ Not copied, Heroku-managed: HEROKU_APP_NAME, REDIS_TLS_URL.

Add-ons
  ⚠ Heroku Scheduler: its jobs cannot be read through Heroku's API ...
  ⚠ sendgrid (sendgrid:starter): email. Its variables are kept ...

Domains
  ⚠ www.shop.example points at Heroku ... It moves when you switch ...

Plan
  ✓ Fits your Pro plan: after the import the team uses 6 of 10 instances and 3 GB of 3 GB memory.

Nothing was changed (--check). Run the command again without --check to import.
```

✓ is what moves as it is, ⚠ is what you should know or do yourself, and ✗ is what stops the import, with what to do about it. The report names variables but never shows their values. `--check --json` prints the same report as one JSON document.

## Import

Run the command again without `--check`. In a terminal it asks before it creates anything; from a script, or with the token piped in, add `--yes`.

```sh
heroku authorizations:create --scope read-protected --expires-in 3600 --short | deplane import heroku shop --yes
```

| Flag | What it does |
|---|---|
| `--source DIR` | The folder to deploy. Default: the current folder. |
| `--name NAME` | The Deplane app's name. Default: the Heroku app's name. The database and Redis are named after it. |
| `--check` | Print the report and stop. |
| `--yes` | Import without asking. |

In order, the import:

1. checks your plan (the report's **Plan** section) and has Deplane check the Postgres database (version, size, what it holds). If the plan cannot hold the app, or Deplane cannot copy the database, the import stops here and **nothing is created**;
2. creates the app and sets the copied config vars as [secrets](secrets.md), the [release command](release-command.md), the number and size of web instances, and the [workers](workers.md);
3. copies the database into a new Deplane [database](databases.md) with the [Postgres import](import-postgres.md) in copy mode, and attaches it as `DATABASE_URL`;
4. creates a [Redis](redis.md) and attaches it as `REDIS_URL`;
5. deploys your folder as `deplane launch` does (it writes `deplane.toml` there), runs the release command against the copied database, and prints the app's URL.

Then it prints what to do next: test the app, the switch-over steps below, and how to revoke the token.

## What moves

| On Heroku | On Deplane |
|---|---|
| The `web` process | The app. Instances as on Heroku (at least 1). Railpack starts your Procfile's `web` line, or your Dockerfile's `CMD`; the report warns when they differ from what Heroku runs. |
| Other process types that run dynos (`worker`, `clock`, ...) | [Workers](workers.md), with the same command and number of dynos (at most 20 each, 10 per app). A name Deplane does not accept is changed, for example `clock_tick` becomes `clock-tick`. Types at 0 dynos on Heroku (such as the `console` and `rake` a Rails app lists) are not created; run one-off commands with [`deplane run`](cli.md#deplane-run). |
| The `release` process type | The [release command](release-command.md). It may run 15 minutes (Heroku allows an hour). |
| Dyno sizes | `eco`, `basic`, `standard-1x`: small (0.5 CPU, 512 MB). `standard-2x`: medium (1 CPU, 1 GB). `performance-*` and larger: large (2 CPU, 2 GB), with a warning when that is less memory than on Heroku. See [Limits](limits.md). |
| Config vars | Secrets, except the ones Heroku or Deplane manages: Heroku's dyno metadata (`HEROKU_APP_*`, `HEROKU_RELEASE_*`, `HEROKU_SLUG_*`, `HEROKU_DYNO_*`, `HEROKU_BUILD_*`), `PORT`, and the variables of the Postgres and Redis that Deplane replaces. The report lists every dropped name. Variables of a follower or second database that stays on Heroku are copied, and the report names them. Deplane's Redis sets `REDIS_URL` only, not `REDIS_TLS_URL`. |
| Heroku Postgres (`DATABASE_URL`) | A new Deplane database, copied once (copy mode), attached as `DATABASE_URL`. |
| Heroku Key-Value Store (`REDIS_URL`) | A new, empty Deplane Redis (Valkey 8, 256 MB), attached as `REDIS_URL`. |
| The code | Your folder, built by Railpack, or by your Dockerfile if it has one. |

## What stays on Heroku

- **Redis data** is not copied. Nothing to do for a cache; let job queues (Sidekiq, RQ, BullMQ) drain before you switch. A Heroku plan larger than 256 MB gets no Deplane Redis, and `REDIS_URL` keeps pointing at Heroku.
- **Scheduler jobs** cannot be read through Heroku's API. Open them with `heroku addons:open scheduler -a APP` and add each as a [cron job](workers.md). Both run in UTC:

  | Heroku Scheduler | Deplane schedule |
  |---|---|
  | Every 10 minutes | `*/10 * * * *` |
  | Every hour at :MM | `MM * * * *` |
  | Every day at HH:MM | `MM HH * * *` |

  ```sh
  deplane processes add nightly-report --schedule "30 2 * * *" --command "rake report" --app shop
  ```

- **Other add-ons** stay with Heroku, and their variables are copied so your app keeps using them. They end when the Heroku app or the add-on is deleted, so keep the Heroku app (at 0 dynos) until each has its own account with the vendor:

  | Add-on | What happens |
  |---|---|
  | `heroku-postgresql` | Copied into a Deplane database (copy mode) |
  | `heroku-redis` | Recreated empty on Deplane; data not copied |
  | `scheduler` | Recreate the jobs as cron jobs yourself |
  | `papertrail`, `logdna` (Mezmo), `coralogix`, `sumologic`, `logentries` | Logging stays on Heroku. Deplane keeps your logs itself (`deplane logs`) and has no log drains yet; to keep the service, open an account with the vendor |
  | `sendgrid`, `mailgun`, `postmark`, `mailtrap` | Variables kept; move to a direct account with the vendor before you delete the Heroku app |
  | `bucketeer`, `cloudcube` (S3) | Variables kept; files are not moved; same as above |
  | `cloudamqp` (RabbitMQ) | Variables kept; same as above |
  | `heroku-kafka` | Ends with the Heroku app, and Deplane has no Kafka |
  | `memcachier` | Variables kept; move to a direct account, or use a Deplane Redis |
  | `newrelic`, `scout`, `sentry`, `rollbar`, `honeybadger`, `bugsnag` | Variables kept; most agents work anywhere; the vendor account must become a direct one |
  | `jawsdb`, `cleardb` (MySQL) | Variables kept. Deplane has [managed MySQL](mysql.md), but importing MySQL comes later |
  | `heroku-inference`, `heroku-connect`, `heroku-applink` | Work only on Heroku or with Salesforce |
  | Anything else | Variables kept; move to a direct account before you delete the Heroku app |

- **Custom domains** keep pointing at Heroku until you switch them.
- **Also not carried over:** binaries that came from third-party buildpacks (add a Dockerfile if the build needs them), `WEB_CONCURRENCY` that Heroku's buildpacks set for each dyno size (set it yourself if you relied on it), other Postgres databases and followers (`HEROKU_POSTGRESQL_<COLOR>_URL`), and pipelines and review apps. Deplane runs in one region, in Europe.

## Switch over

The first import is a rehearsal: Heroku keeps serving, and writes made there after the copy are not in the Deplane database. When the Deplane app works:

1. **Before the day:** for each custom domain, run `deplane domains add HOSTNAME --app APP` and add the TXT record it shows. The domain keeps pointing at Heroku. See [Custom domains](domains.md).
2. **Make room for one more database, before you stop Heroku.** The switch copies the data into a second database, and the rehearsal's copy is deleted only after the switch, so for a while your team has both. The report's **Plan** section says if your plan has no room for it: free a database's worth (a database, one instance, 512 MB), or email support@deplane.io to change your plan, first.
3. **Stop writes on Heroku:** `heroku maintenance:on -a APP`, and scale every worker to 0 (`heroku ps:scale worker=0 -a APP`): maintenance mode stops web traffic only.
4. **Copy the data again** into a fresh database, now that nothing writes, and move the app to it in one release ([`deplane db switch`](cli.md#deplane-db-switch) asks you to confirm with your passkey):

   ```sh
   heroku config:get DATABASE_URL -a APP | deplane db import --name APP-db-final --mode copy --yes
   deplane db switch APP-db-final --from APP-db
   ```

5. **Switch DNS:** change each custom domain's CNAME (or A records, for a root domain) to the target `deplane domains add` showed.
6. **Wait for the certificates:** `deplane domains check HOSTNAME --app APP` until it is ready. Expect a short gap while the certificate is issued.
7. **Keep the Heroku app at 0 dynos** (`heroku ps:scale web=0 worker=0 -a APP`) until every add-on has moved. Scaling to 0 stops dyno billing, not add-on billing.
8. **Delete the rehearsal's database** when the app works on the new copy: `deplane db destroy APP-db`.
9. **Revoke the token:** `heroku authorizations` lists its ID; `heroku authorizations:revoke ID`. It expires by itself after an hour anyway.

The downtime is from step 3 to the end of step 4: a few minutes per GB of data. Classic Heroku Postgres gives no replication rights, so a live import (switch in under a minute) is not possible from it.

## Plan limits

Before it creates anything, the import adds up what it needs (the app, its web instances and workers with their sizes, the database and the Redis) and compares it with your plan and what your team already uses. Each limit it would cross is a ✗ line in the **Plan** section, with the fix:

- **Workers** need a plan whose apps do not sleep (Hobby and larger). On Free, a Heroku app with a process type other than `web` that runs dynos is refused; types at 0 dynos are not created, so a web-only app (a Rails app with its `console` and `rake` types, for example) is fine.
- **Sizes** your plan does not include (Free has only small, so a `standard-2x` web or a `performance` worker is refused).
- **Apps, Postgres databases and Redis**: one more of each than your team has must fit the plan.
- **Instances and memory**: the team's total after the import, counted as the plan does (a database is one instance of its size, a Redis 256 MB).
- **The switch-over's second database** (see [Switch over](#switch-over)): when the plan has no room for it, a ⚠ line says what is missing, so you can make room before you stop Heroku.

To import anyway, email support@deplane.io to change your plan, or free room by deleting what you no longer need. The API still has the last word: if something changed in the meantime, the step it refuses stops the import as below.

## If the import stops

The command stops at the first step that fails (a plan limit, a build that fails, a database that cannot be copied) and shows the error as it came. Before the error it lists what it created so far, the commands that finish the import by hand from that step, and the commands that delete what it created so you can start again. A Postgres check that is still waiting is cancelled, so your team can start another import. Plan limits are in [Limits](limits.md).

## Limits

| Limit | Value |
|---|---|
| Postgres versions | 9.2 to 17. **Heroku creates new databases on Postgres 18, which Deplane cannot import yet**: the check says so before anything is created. Postgres 18 support is coming. |
| Database size | 5 GB, as for every [Postgres import](import-postgres.md#limits) |
| Databases | `DATABASE_URL` only; other databases later, one at a time with `deplane db import` |
| Redis | A new 256 MB Redis; data is not copied |
| Workers | 10 per app, 20 instances each |
| Sizes | small, medium, large (2 GB at most) |
| Private Spaces | An app whose database is in a Private Space or Shield space is refused: the database only accepts connections from inside the space. Email support@deplane.io |
| Container stack | Deplane builds your Dockerfile; a `release:` in `heroku.yml` is not read (set it with `deplane config --release-command`), and a second image is imported as its own app |
| Who can import | Copying the database needs a person logged in to the CLI: CI tokens and AI tools cannot start a database import |

Not in this version yet: an **Import from Heroku** page in the dashboard, a one-click re-copy and switch, copying Redis data, and recreating Scheduler jobs for you.
