---
title: "Migrate a Heroku app to exe.dev"
description: "Move a Heroku app to an exe.dev VM: Procfile processes, config vars, Postgres, Key-Value Store, and Scheduler jobs, rehearsed while Heroku keeps serving."
url: https://exe.dev/deploy/migrate-heroku
category: "Migration"
updated: 2026-10-17
---

# Migrate a Heroku app to exe.dev

Your Procfile, config vars, and Postgres on a VM of your own.

- **Software:** Apps from Heroku (https://www.heroku.com/)
- **Source:** https://github.com/heroku/cli
- **License:** ISC (Heroku CLI)
- **Time:** 1 hour
- **Example VM name:** `my-app` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/migrate-heroku/manifest.json

## What you get

- The app's code, config vars, and a copy of its database on the VM, handed over by the owner with the Heroku CLI; no Heroku API token anywhere.
- Each Procfile process as a systemd unit, built the way its buildpack built it, with the web process answering on port 8000; workers and Scheduler jobs (as timers) installed but off until the move.
- `git push exe main` deploying like `git push heroku main`: build, release phase, restart, and a push rejected, with the previous release still running, if anything fails.
- `~/heroku/check` finding every page in `~/heroku/paths.txt` the same as on Heroku, and a nightly database backup in `~/postgres/backups`.
- A report in `~/heroku/report.md`: what moved, what changed for exe.dev, what stays with Heroku or its add-on vendors, and the owner's steps for the move.

Running on the VM:

- **Your app**: Each Procfile process as a systemd unit, built the way its buildpack built it; web on port 8000 behind exe.dev's HTTPS proxy, port 8000, data in `~/app, from ~/app.git`
- **PostgreSQL 18**: In place of Heroku Postgres, set up as in the PostgreSQL recipe and restored from Heroku's backup, data in `/var/lib/postgresql/18/main, ~/postgres/backups`
- **Valkey**: In place of Heroku Key-Value Store (Redis), on localhost
- **systemd timers**: In place of Heroku Scheduler
- **The move kit**: What Heroku handed over, the plan, check, move, and the report, data in `~/heroku`

How it is reached:

- https://my-app.exe.xyz/ (private): the app, until you make the move
- https://www.example.com/ (public): the app, once your domain points at the VM and it's public
- ssh my-app.exe.xyz (private): the shell, in place of `heroku run bash`; `git push exe main` deploys

## Deploy with Shelley

Open https://exe.dev/new?prompt=HEROKU_APP%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Apps%20from%20Heroku%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fmigrate-heroku.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20HEROKU_APP%3A%20the%20Heroku%20app%27s%20name%2C%20as%20%60heroku%20apps%60%20lists%20it%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-app.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-app.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20The%20app%27s%20code%2C%20config%20vars%2C%20and%20a%20copy%20of%20its%20database%20on%20the%20VM%2C%20handed%20over%20by%20the%20owner%20with%20the%20Heroku%20CLI%3B%20no%20Heroku%20API%20token%20anywhere.%0A-%20Each%20Procfile%20process%20as%20a%20systemd%20unit%2C%20built%20the%20way%20its%20buildpack%20built%20it%2C%20with%20the%20web%20process%20answering%20on%20port%208000%3B%20workers%20and%20Scheduler%20jobs%20%28as%20timers%29%20installed%20but%20off%20until%20the%20move.%0A-%20%60git%20push%20exe%20main%60%20deploying%20like%20%60git%20push%20heroku%20main%60%3A%20build%2C%20release%20phase%2C%20restart%2C%20and%20a%20push%20rejected%2C%20with%20the%20previous%20release%20still%20running%2C%20if%20anything%20fails.%0A-%20%60~%2Fheroku%2Fcheck%60%20finding%20every%20page%20in%20%60~%2Fheroku%2Fpaths.txt%60%20the%20same%20as%20on%20Heroku%2C%20and%20a%20nightly%20database%20backup%20in%20%60~%2Fpostgres%2Fbackups%60.%0A-%20A%20report%20in%20%60~%2Fheroku%2Freport.md%60%3A%20what%20moved%2C%20what%20changed%20for%20exe.dev%2C%20what%20stays%20with%20Heroku%20or%20its%20add-on%20vendors%2C%20and%20the%20owner%27s%20steps%20for%20the%20move.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20do%20%22Rehearse%20the%0Amove%20by%20hand%22%20in%20order%2C%20skipping%20VM%20creation%3B%20the%20walkthrough%20above%20it%20is%0Afor%20the%20owner.%20Use%20its%20scripts%20and%20units%20as%20written%3A%20they%20were%20tested%0Atogether%2C%20and%20move%20and%20check%20rely%20on%20them.%20Change%20them%20only%20where%20this%0Aapp%20needs%20it%20%28another%20Procfile%20type%2C%20a%20build%20step%2C%20a%20Scheduler%20job%29%2C%20and%0Alist%20the%20changes%20in%20the%20report.%20For%20PostgreSQL%2C%20follow%20the%20PostgreSQL%0Arecipe%27s%20steps%20as%20%22Install%20PostgreSQL%20and%20Valkey%22%20says%20%28its%20Markdown%20is%0A%2Fdeploy%2Fpostgres.md%20on%20the%20same%20site%29%2C%20skipping%20Adminer.%0AYou%20need%20the%20owner%20once%2C%20early%3A%20show%20them%20the%20commands%20in%0A%22Hand%20over%20the%20app%22%20with%20this%20VM%27s%20name%20and%20HEROKU_APP%2C%20and%20wait%20until%0A~%2Fheroku%2Fconfig.json%20is%20there%20and%20~%2Fapp.git%20has%20a%20commit.%20Install%20what%0Adoesn%27t%20depend%20on%20them%20meanwhile.%0ANever%20run%20heroku%20commands%20yourself%2C%20and%20never%20write%20to%20Heroku%27s%20database%2C%0AKey-Value%20Store%2C%20or%20other%20add-ons%3A%20the%20URLs%20in%20~%2Fheroku%2Fconfig.json%20are%0Alive%20production%20ones.%20Never%20run%20~%2Fheroku%2Fmove%2C%20change%20DNS%2C%20or%20run%20share%0Aset-public%3A%20the%20move%20is%20the%20owner%27s%20call.%20Until%20then%2C%20keep%20the%20workers%0Aand%20Scheduler%20timers%20off%20on%20this%20VM.%0AKeep%20config%20var%20values%20and%20the%20backup%20URL%20out%20of%20your%20replies%20and%20the%0Areport.%0AIf%20the%20app%20relies%20on%20something%20exe.dev%20can%27t%20run%20%28Heroku%20Connect%2C%20Apache%0AKafka%20on%20Heroku%2C%20inbound%20ports%20other%20than%20HTTP%20and%20SSH%29%2C%20don%27t%20work%0Aaround%20it%20silently%3A%20put%20it%20in%20the%20report%20and%20ask.%0AFinish%20with%20the%20report%27s%20summary%2C%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20and%20the%20owner%27s%0Asteps%20in%20%22Make%20the%20move%22%20with%20this%20VM%27s%20name%2C%20the%20Heroku%20app%2C%20and%20its%0Adomains.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/migrate-heroku/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

A Heroku app is a `Procfile`, config vars, and add-ons, deployed with
`git push`. Each of those has a plain Linux equivalent, so this move keeps
them all. The VM builds your app the way its buildpack did, runs each
Procfile line as a systemd service with the same config vars, and deploys
on `git push exe main`. Heroku Postgres becomes PostgreSQL on the VM,
restored from one of Heroku's own backups.

Nothing changes on Heroku until you say so. You hand over the app with a
few `heroku` commands on your computer, Shelley rehearses on the VM and
compares every page with Heroku's, and the switch is a maintenance window
of a few minutes, when you're ready.

Examples use a Heroku app called `my-heroku-app`, served at
`www.example.com`, moving to a VM called `my-app`. Use your own.

## How the move works

1. **Hand-over.** On your computer, the Heroku CLI writes down the app:
   its config vars, processes, add-ons, domains, and a fresh database
   backup. That goes to the VM over SSH, and you `git push` the code.
2. **Rehearsal.** Shelley builds the app the way its buildpack did,
   restores the backup, and starts the web process with your config vars.
   Workers and Scheduler jobs are set up but stay off, so nothing runs
   twice. Then it compares every page with Heroku's.
3. **The move.** You put Heroku in maintenance mode, hand the VM the last
   backup, and point your domain at the VM.

Shelley needs no Heroku login and changes nothing there. The commands
that touch Heroku are yours.

## Where everything goes

| On Heroku | On the VM |
|---|---|
| `git push heroku main` | `git push exe main`, into `~/app.git` |
| Buildpacks | The same runtimes: [uv](https://docs.astral.sh/uv/) for Python, [mise](https://mise.jdx.dev/) for Ruby and Node.js |
| Procfile `web` | `app.service`, on port 8000, behind exe.dev's [HTTPS proxy](/docs/proxy) |
| Procfile `worker` and others | `app-worker.service`, `app-<type>.service` |
| Release phase | The deploy hook, after the build and before the restart |
| Releases, `heroku rollback` | `~/releases/`; `git push -f exe <commit>:main` |
| Config vars | `~/app.env`, read by every unit |
| `heroku run bash` | `ssh my-app.exe.xyz`, then `app-run bash` |
| `heroku logs --tail` | `journalctl -u 'app*' -f` |
| Heroku Postgres | PostgreSQL 18 on the VM, as in [PostgreSQL](/deploy/postgres), restored from `pg:backups` |
| Heroku Key-Value Store (Redis) | Valkey on the VM |
| Heroku Scheduler | systemd timers, `app-<job>.timer` |
| Custom domains and ACM | [Custom domains](/docs/cnames), with certificates from exe.dev |
| Dyno sizes, `ps:scale` | The VM's CPUs and memory, `WEB_CONCURRENCY`, and `ssh exe.dev resize` |
| Review apps, pipelines | More VMs: [Dev, test, and prod](/deploy/dev-test-prod) |

Add-ons go one of three ways: onto the VM, to a direct account with the
same vendor, or nowhere, when exe.dev has no equivalent.

| Add-on | Goes |
|---|---|
| Heroku Postgres, Heroku Key-Value Store, Redis Cloud | Onto the VM, as above |
| MemCachier | memcached or Valkey, on the VM |
| Papertrail, Mezmo, Better Stack, Sumo Logic | journald on the VM; for search and alerts, keep the vendor with its own agent |
| New Relic, Scout, AppSignal, Sentry, Rollbar, Honeybadger | A direct account with the vendor; its key goes in `~/app.env` |
| SendGrid, Mailgun, Postmark | A direct account; SMTP on ports 587 and 465 works from the VM |
| Bucketeer, Cloudcube | A bucket of your own; copy the files over before you remove the add-on |
| Cloudinary, Algolia, Pusher, and other services | A direct account with the vendor |
| CloudAMQP, Bonsai, SearchBox | A direct account, or RabbitMQ or OpenSearch on the VM |
| Heroku Managed Inference and Agents | exe.dev's [LLM integration](/docs/integrations-llm) |
| Heroku Connect, AppLink, Apache Kafka on Heroku | Nowhere: no equivalent. Shelley flags them in the report |

An add-on's config vars keep working from the VM until the add-on is
removed, which happens when you delete the Heroku app. Move to direct
accounts before then.

The VM uses the same layout as the framework recipes (`~/app.git`,
`~/app.env`, `app.service`), so their tips for each framework apply here
too: [Django](/deploy/django), [Rails](/deploy/rails),
[Next.js](/deploy/nextjs), [FastAPI](/deploy/fastapi), and
[Laravel](/deploy/laravel).

## Hand over the app

When Shelley asks, run these on your computer, logged in to the
[Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli), with your
app's name in place of `my-heroku-app`:

```bash
APP=my-heroku-app H=$(mktemp -d)
heroku apps:info   -a $APP --json > $H/info.json
heroku config      -a $APP --json > $H/config.json
heroku ps          -a $APP --json > $H/ps.json
heroku addons      -a $APP --json > $H/addons.json
heroku buildpacks  -a $APP --json > $H/buildpacks.json
heroku domains     -a $APP --json > $H/domains.json
heroku releases    -a $APP --json -n 10 > $H/releases.json
heroku run         -a $APP --no-tty 'env; python --version; ruby --version; node --version' > $H/dyno.txt
heroku pg:info     -a $APP > $H/pg-info.txt
heroku pg:backups:capture -a $APP
heroku pg:backups:url     -a $APP > $H/db-url.txt
tar -c -C $H . | ssh my-app.exe.xyz 'mkdir -p -m 700 heroku && tar -x -C heroku' && rm -r $H
```

They only read, apart from taking a backup. `heroku run` starts a one-off
dyno for a few seconds, to see what the buildpack set beyond your config
vars and which language versions it installed. `pg:backups:url` is a link
to the backup that expires in an hour. No Heroku database? Skip the `pg:`
lines.

Then, in your app's repository, push the code. If `main` has moved on
since your last Heroku deploy, push the commit Heroku runs instead, which
`heroku releases -n 1` names: `git push exe <commit>:main`. If your branch
is `master`, push `master:main`.

```bash
ssh my-app.exe.xyz git init -q --bare app.git
git remote add exe my-app.exe.xyz:app.git
git push exe main
```

If the app uses Heroku Scheduler, give the VM its jobs too. Open them
with `heroku addons:open scheduler -a my-heroku-app` and paste one per
line, like `every 10 minutes: python manage.py report` or
`daily at 03:00 UTC: rake send_reminders`, then press Ctrl-D:

```bash
ssh my-app.exe.xyz 'cat > heroku/scheduler.txt'
```

> [!NOTE]
> Heroku Postgres Advanced has no `pg:backups`. Skip those two lines:
> Shelley runs `pg_dump` itself, read-only, with the `DATABASE_URL` in your
> config vars. Do the same above 20 GB, where Heroku advises against
> `pg:backups`.

## Look around

When Shelley finishes, read its report. On your computer:

```bash
ssh my-app.exe.xyz cat heroku/report.md
```

It says what moved, what changed for exe.dev, and what stays with Heroku
or an add-on vendor. Then open `https://my-app.exe.xyz/`: your app on the
VM, with your data as of the hand-over. Changes to the data stay here,
but it's your app with your config vars: if it sends email or stores
uploads in a bucket, it does that from here too.

To compare it with Heroku page by page, on the VM:

```bash
~/heroku/check
```

Expect `same` on every line. Add pages to `~/heroku/paths.txt`, one path
per line.

## Make the move

A day ahead, lower your domain's DNS TTL to 300 seconds. If you've
deployed to Heroku since the hand-over, `git push exe main` the same
commit. Then, at a quiet moment:

1. In the Scheduler dashboard, delete the jobs: maintenance mode doesn't
   stop them. The VM has the list.
2. On your computer, stop Heroku and hand the VM the last backup. Scale
   each process type in `heroku ps` except `web` to 0, after its queue
   drains:

```bash
heroku maintenance:on -a my-heroku-app
heroku ps:scale worker=0 -a my-heroku-app
heroku pg:backups:capture -a my-heroku-app
heroku pg:backups:url -a my-heroku-app | ssh my-app.exe.xyz heroku/move
```

On Heroku Postgres Advanced, replace the last two lines with
`heroku config:get DATABASE_URL -a my-heroku-app | ssh my-app.exe.xyz heroku/move`.
`heroku/move` checks that Heroku shows its maintenance page, restores the
backup, starts the workers and timers here, and checks every page against
Heroku's answers from the rehearsal. Then:

3. At your DNS provider, point the domain at the VM:
   `www.example.com CNAME my-app.exe.xyz`, in place of the
   `herokudns.com` target. For a bare `example.com`, use an ALIAS or
   flattened CNAME, or an A record to the address `host my-app.exe.xyz`
   shows. On Cloudflare, turn the proxy off (grey cloud). See
   [custom domains](/docs/cnames).
4. On your computer, register the domain and open the VM to everyone:

```bash
ssh exe.dev domain add my-app www.example.com
ssh exe.dev share set-public my-app
```

Changed your mind before step 3? This turns the VM's workers and timers
off again, and prints the commands that bring Heroku back:

```bash
ssh my-app.exe.xyz heroku/move undo
```

## Day to day

On your computer, `git push exe main` deploys: build, release phase,
restart, and a check that the app answers. As on Heroku, if any step
fails, the push is rejected and the running release stays. To roll back,
push an older commit: `git push -f exe <commit>:main`.

On the VM:

| Instead of | Run |
|---|---|
| `heroku run rails console` | `app-run bin/rails console` |
| `heroku logs --tail` | `journalctl -u 'app*' -f` |
| `heroku ps` | `systemctl status 'app*'` |
| `heroku ps:restart` | `sudo systemctl restart app app-worker` |
| `heroku config:set` | Edit `~/app.env`, then `~/app.git/hooks/pre-receive main` |
| `heroku pg:psql` | `app-run sh -c 'psql "$DATABASE_URL"'` |
| `heroku pg:backups:capture` | `~/postgres/backup.sh` |
| `heroku pg:backups:restore` | `~/heroku/restore-db ~/postgres/backups/<time>/app.dump` |

`app-run` runs a command the way `heroku run` did: in `~/app`, with the
config vars from `~/app.env`. From your computer, `ssh -t my-app.exe.xyz
app-run bash` gets a shell like `heroku run bash`.

To move to a new language version, change it where Heroku looked: Python
in `.python-version`, and the deploy picks it up. For Ruby and Node.js,
also tell mise on the VM, then push: `mise use -g ruby@3.4.11` or
`mise use -g node@26`. For Valkey and minor PostgreSQL updates, now and
then run `sudo apt-get update && sudo apt-get upgrade` on the VM; for a
new major PostgreSQL, follow [Update PostgreSQL](/deploy/postgres#update).
For anything else about the database, from connecting a desktop client to
undoing a mistake, the [PostgreSQL recipe](/deploy/postgres) applies as is.

## Clean up

Once the VM has run well for a week or so:

- Move the add-ons you keep to direct accounts with their vendors, and
  put the new keys in `~/app.env`. Copy files out of Bucketeer or
  Cloudcube buckets.
- Keep a last copy of the Heroku database, then delete the app. That
  removes its dynos, add-ons, and their bills:

```bash
heroku pg:backups:download -a my-heroku-app
heroku apps:destroy -a my-heroku-app
```

- On the VM, delete what's no longer needed in `~/heroku`: `config.json`
  and `dyno.txt` hold Heroku's old credentials, and `latest.dump` is
  the database as of the move. Keep `report.md`: it records what the VM
  runs and why.

## The nuts and bolts

These are the steps Shelley follows for the rehearsal, in order.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Rehearse the move by hand</summary>

### Create the VM

On your computer, create a VM with room for the database, Heroku's backup
of it, and two weeks of nightly backups: about five times the size
`heroku pg:info` shows, plus 10 GB. Pick another name if `my-app` is
taken:

```bash
ssh exe.dev new --name=my-app --disk=40GB
```

Then [hand over the app](#hand-over-the-app). Everything below runs **on
the VM**, as `exedev`: `ssh my-app.exe.xyz`.

### Read the hand-over

`~/heroku` holds what Heroku said about the app, and `~/app.git` its
code. `config.json`, `dyno.txt`, and `db-url.txt` hold secrets: read them,
but keep their values out of anything you write or say.

- `info.json`: the stack and generation (`cedar` or `fir`), the region,
  `web_url`, and the add-ons. An app that deploys a Docker image says
  `container`: run it with [Docker Compose](/deploy/docker-compose) instead
  of the build below.
- `ps.json` and the `Procfile`: the process types, and how many of each.
- `addons.json`: each add-on and the config vars it set. Sort them with the
  add-on table in [Where everything goes](#where-everything-goes).
- `config.json`: the config vars. `dyno.txt`: the same plus what the
  buildpacks set, then the language versions Heroku runs.
- `domains.json`, `pg-info.txt` (the Postgres version and size),
  `scheduler.txt`, and `releases.json`.

Check the code is the commit Heroku runs: the newest release in
`releases.json` says `Deploy 1a2b3c4d`, and this should match:

```bash
git -C ~/app.git log -1 --format='%h %s' main
```

Write down where Heroku serves the app, and the pages to compare: `/`,
a page that reads the database, a static file, a missing page. Only pages
that just read: the comparison asks Heroku too.

```bash
echo https://www.example.com > ~/heroku/source-url
printf '%s\n' / /pricing /static/logo.png /no-such-page > ~/heroku/paths.txt
```

Use the first custom domain in `domains.json`, or else `web_url` from
`info.json`.

### Install PostgreSQL and Valkey

Set up PostgreSQL as the [PostgreSQL recipe's "Install by
hand"](/deploy/postgres#create-the-vm) does, on this VM: every step
from "Install PostgreSQL 18 and pgvector" to "Test a backup", but skip
"Add a web admin", or put Adminer on port 8001 as that step says, since
port 8000 is the app's. That gives you PostgreSQL 18 with an
`en_US.UTF-8` cluster like Heroku's, an `app` database with its URL in
`~/postgres/app.env`, and nightly backups of it in `~/postgres/backups`.
`pg_restore` 18 reads backups from any Heroku Postgres version, and SSL
is on, with a self-signed certificate, so apps that insist on
`sslmode=require`, as many Heroku apps do, connect unchanged.

Then Valkey, the open-source fork of Redis that Heroku Key-Value Store
runs too, and the headers that Ruby's and Python's database drivers
build against. Skip Valkey if the app has no Key-Value Store or Redis
add-on:

```bash
sudo apt-get install -y valkey-server build-essential libpq-dev libyaml-dev
```

### Install the language

Install what the buildpacks in `info.json` installed, at the versions at
the end of `dyno.txt`:

- **Python:** nothing to install. The deploy uses
  [uv](https://docs.astral.sh/uv/), which is already here and fetches the
  Python in `.python-version`. If the app has no `.python-version`, set
  `UV_PYTHON` in `vm.env` below to the version Heroku ran.
- **Ruby, Node.js, and others:** [mise](https://mise.jdx.dev/) installs
  any version in seconds, from prebuilt binaries. Name each language the
  app uses, at the version from `dyno.txt`:

```bash
curl -fsSL https://mise.run | sh
~/.local/bin/mise use -g ruby@3.4.9 node@24.22.0
```

If `package.json` names an npm version in `engines`, Heroku installed
that npm, so install it once too:

```bash
PATH=~/.local/bin:~/.local/share/mise/shims:$PATH npm install -g npm@12
```

PHP, Go, and Java apps need their own build steps in the deploy hook
below; ask the owner before taking them on.

### Write the config

`~/app.env` holds the config vars, in place of Heroku's. It's made from
two files: Heroku's `config.json`, and `~/heroku/vm.env`, the VM's own
settings, which win. Write `vm.env`, with the database URL from
`~/postgres/app.env`:

```bash
cat > ~/heroku/vm.env <<EOF
# The VM's own settings. They win over Heroku's config vars in ~/app.env.
PORT=8000
PATH=/home/exedev/app/.venv/bin:/home/exedev/app/node_modules/.bin:/home/exedev/app/bin:/home/exedev/.local/share/mise/shims:/home/exedev/.local/bin:/usr/local/bin:/usr/bin:/bin
$(cat ~/postgres/app.env)
REDIS_URL=redis://localhost:6379
WEB_CONCURRENCY=$(nproc)
EOF
chmod 600 ~/heroku/vm.env
```

Then add to `vm.env` what the buildpack set on Heroku: the lines of
`dyno.txt` that aren't config vars, such as `SECRET_KEY_BASE` (Ruby's
buildpack made one if you didn't, and it signs sessions), `RAILS_ENV`,
`NODE_ENV`, `LANG`, `PYTHONUNBUFFERED`, and `FORWARDED_ALLOW_IPS=*`,
without which gunicorn ignores `X-Forwarded-Proto`. Leave out Heroku's
own: `HOME`, `PATH`, `PORT`, `DYNO`, `PS1`, `WEB_CONCURRENCY`, and paths
into `/app`.

This writes `~/app.env`, with the URLs of Heroku's Postgres and
Key-Value Store pointing at the VM's own:

```bash
cat > ~/heroku/make-env <<'EOF'
#!/usr/bin/env python3
# ~/heroku/make-env: write ~/app.env from Heroku's config vars (~/heroku/config.json),
# then the VM's own settings (~/heroku/vm.env), which win. The URLs of Heroku's
# Postgres and Key-Value Store point at the VM's own instead.
import json, os, re
home = os.path.expanduser("~")
config = json.load(open(f"{home}/heroku/config.json"))
vm = {}
for line in open(f"{home}/heroku/vm.env"):
    if "=" in line and not line.startswith("#"):
        k, v = line.rstrip("\n").split("=", 1)
        vm[k] = v
env, moved = dict(config), []
for k in config:
    if re.fullmatch(r"DATABASE_URL|HEROKU_POSTGRESQL_\w+_URL", k):
        env[k] = vm["DATABASE_URL"]; moved.append(k)
    elif re.fullmatch(r"REDIS_URL|REDIS_TLS_URL|HEROKU_REDIS_\w+_URL|HEROKU_REDIS_\w+_TLS_URL", k):
        env[k] = vm["REDIS_URL"]; moved.append(k)
env.update(vm)
quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
fd = os.open(f"{home}/app.env", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
    f.write("# Heroku's config vars, then the VM's own (heroku/vm.env).\n"
            "# Edit freely, then: ~/app.git/hooks/pre-receive main\n")
    f.writelines(f"{k}={quote(v)}\n" for k, v in env.items())
print(f"Wrote ~/app.env: {len(env)} vars. Now on the VM: {', '.join(moved) or 'nothing'}")
EOF
chmod +x ~/heroku/make-env && ~/heroku/make-env
```

The services set `DYNO`, as Heroku did, so code that checks for Heroku
that way, to trust its proxy or force HTTPS, behaves as it did there.
Every other config var stays as it was, so the app here uses the same
mail, storage, and monitoring services as on Heroku.

### Run like a dyno

Two small commands stand in for Heroku's dyno manager. `app-run` runs a
command the way `heroku run` did, and `procfile-run` starts a Procfile
line, for the services:

```bash
sudo tee /usr/local/bin/app-run > /dev/null <<'EOF'
#!/bin/bash
# app-run: what `heroku run` did. Runs a command in ~/app as exedev with
# ~/app.env loaded exactly the way the app*.service units load it.
#   app-run bash      app-run python manage.py shell      app-run bin/rails console
tty=--pipe; [ -t 0 ] && [ -t 1 ] && tty=--pty
exec sudo systemd-run --quiet --collect --wait "$tty" --uid=exedev --gid=exedev \
  -p WorkingDirectory=-/home/exedev/app -p EnvironmentFile=/home/exedev/app.env \
  -E HOME=/home/exedev -E DYNO=run.1 -- /usr/bin/env "$@"   # env: find "$@" on app.env's PATH
EOF
sudo tee /usr/local/bin/procfile-run > /dev/null <<'EOF'
#!/bin/bash
# procfile-run TYPE: start the Procfile's TYPE line the way a dyno does
cmd=$(sed -n "s/^$1:[[:space:]]*//p" /home/exedev/app/Procfile)
[ -n "$cmd" ] || { echo "Procfile has no '$1:' line" >&2; exit 1; }
exec bash -c "$cmd"
EOF
sudo chmod +x /usr/local/bin/app-run /usr/local/bin/procfile-run
```

### The deploy hook

`git push exe main` runs this hook, which does what Heroku's build and
release did, before Git accepts the push. Like Heroku, it rejects the push
if any step fails, so `main` is always what runs. Each deploy builds in a
new directory under `~/releases`, and `~/app` points at the one that runs.
Until the first deploy below, pushes only store the code:

```bash
cat > ~/app.git/hooks/pre-receive <<'EOF'
#!/usr/bin/env bash
# ~/app.git/hooks/pre-receive: what `git push heroku main` did. Builds the
# commit in ~/releases/<time>-<commit> as the buildpacks did, runs the
# Procfile's release: line, points ~/app at it, restarts the app*.service
# units and checks the web process answers. If anything fails, it puts the
# previous release back and rejects the push, so main is what runs.
# Redeploy by hand (after editing ~/app.env, say): ~/app.git/hooks/pre-receive main
set -euo pipefail
export GIT_DIR=$HOME/app.git
rev='' head=''
if [ $# -gt 0 ]; then
  rev=$(git rev-parse --verify "$1^{commit}")
else
  while read -r old new ref; do
    case $ref in refs/heads/main|refs/heads/master) rev=$new base=$old head=$ref ;; esac
  done
  [ -n "$rev" ] || { echo "Not deployed: push main (or master) to deploy."; exit 0; }
  case $rev in *[!0]*) ;; *) echo "Refusing to delete the deployed branch."; exit 1 ;; esac
  [ -L "$HOME/app" ] || { echo "Stored ${rev:0:7}. Not deployed: no first deploy yet."; exit 0; }
fi

exec 9>"$HOME/.deploy.lock" && flock 9  # one deploy at a time
if [ -n "$head" ]; then
  now=$(git rev-parse -q --verify "$head" || true)
  [ "${now:-$base}" = "$base" ] || { echo "Another push deployed first: pull, then push again."; exit 1; }
fi
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-${rev:0:7}
previous=$(readlink "$HOME/app" || true)
units=$(systemctl list-unit-files --state=enabled --plain --no-legend 'app*.service' | cut -d' ' -f1)
restart() {  # reset-failed: a unit that just crash-looped refuses restarts for 10s
  for unit in $units; do sudo systemctl reset-failed "$unit"; sudo systemctl restart "$unit"; done
}
fail() {  # put the previous release back, and reject the push
  trap - ERR
  echo "-----> Deploy FAILED${1:+: $1}. Running: ${previous:-nothing}"
  if [ -n "$previous" ] && [ "$(readlink "$HOME/app")" != "$previous" ]; then
    ln -sfn "$previous" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
    restart
  fi
  rm -rf "$release"
  exit 1
}
trap fail ERR

echo "-----> Building ${rev:0:7} in $release"
mkdir -p "$release"
git archive "$rev" | tar -x -C "$release"
app-run bash -euo pipefail -s "$release" <<'BUILD'
cd "$1"
export PATH=$PWD/.venv/bin:$PWD/node_modules/.bin:$PWD/bin:$PATH
if command -v mise >/dev/null; then mise install --quiet; fi  # ~/.config/mise/config.toml
if [ -f package.json ]; then                      # what heroku/nodejs did
  npm ci --include=dev
  if grep -q '"heroku-postbuild"' package.json; then npm run heroku-postbuild
  else npm run --if-present build; fi
  npm prune --omit=dev
fi
if [ -f Gemfile.lock ]; then                      # what heroku/ruby did
  bundle config set --local deployment true
  bundle config set --local without development:test
  bundle config set --local path "$HOME/.gems"
  bundle install --quiet
  if bundle exec rake -P 2>/dev/null | grep -q '^rake assets:precompile'; then
    bundle exec rake assets:precompile
  fi
fi
if [ -f requirements.txt ] || [ -f uv.lock ]; then  # what heroku/python did
  uv venv --quiet .venv
  if [ -f uv.lock ]; then uv sync --quiet --locked --no-dev
  else uv pip install --quiet -r requirements.txt; fi
  if [ -f manage.py ] && [ -z "${DISABLE_COLLECTSTATIC:-}" ]; then
    python manage.py collectstatic --noinput
  fi
fi
BUILD
if grep -q '^release:' "$release/Procfile"; then
  echo "-----> Release phase"
  app-run bash -euo pipefail -s "$release" <<'RELEASE'
cd "$1"
export PATH=$PWD/.venv/bin:$PWD/node_modules/.bin:$PWD/bin:$PATH
exec bash -c "$(sed -n 's/^release:[[:space:]]*//p' Procfile)"
RELEASE
fi

echo "-----> Starting the new release"
ln -sfn "$release" "$HOME/app.new" && mv -T "$HOME/app.new" "$HOME/app"
restart
code=none
if grep -q '^web:' "$release/Procfile"; then
  for _ in $(seq 60); do
    code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' http://localhost:8000/ || true)
    case $code in 000|5??) sleep 1 ;; *) break ;; esac
  done
fi
case $code in 000|5??) fail "it answers HTTP $code (journalctl -u app -n 50)" ;; esac
trap - ERR
ls -1d "$HOME"/releases/*/ | head -n -3 | xargs -r rm -rf  # keep 3
echo "-----> Deployed ${rev:0:7}: HTTP $code on port 8000"
EOF
chmod +x ~/app.git/hooks/pre-receive
```

The build steps follow the buildpacks: `npm ci` with dev dependencies,
then `heroku-postbuild` or `build`, then pruning; `bundle install` and
`assets:precompile`; a virtualenv from `uv.lock` or `requirements.txt`,
and Django's `collectstatic`. Apps that use Poetry or Pipenv need
`uv pip install .` or `pipenv` in its place. If the app has build hooks
(`bin/post_compile`, `heroku-prebuild`, `heroku-cleanup`), add them to
the build where the buildpack ran them. If the app relies on a buildpack
beyond its language's, such as Apt or Chrome, install what it installed
here, once.

### The services

Each Procfile line but `release` becomes a systemd service: `web` is
`app.service`, the others `app-<type>.service`. This is `web`; for a
`worker`, change the description, `DYNO=worker.1`, and `procfile-run
worker`:

```bash
sudo tee /etc/systemd/system/app.service > /dev/null <<'EOF'
[Unit]
Description=Procfile web
After=network.target postgresql.service valkey-server.service

[Service]
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=DYNO=web.1
ExecStart=/usr/local/bin/procfile-run web
Restart=always

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable app
```

Heroku Scheduler's jobs become timers. For each line in
`scheduler.txt`, write `app-<job>.service` and `app-<job>.timer`, with
`OnCalendar=*:0/10` for every 10 minutes, `*:15` for hourly at :15, or
`*-*-* 03:00 UTC` for daily at 03:00. Heroku stopped a job when its
next one was due; `TimeoutStartSec`, set to the interval, does the same:

```bash
sudo tee /etc/systemd/system/app-report.service > /dev/null <<'EOF'
[Unit]
Description=Scheduler job: python manage.py report

[Service]
Type=oneshot
User=exedev
WorkingDirectory=/home/exedev/app
EnvironmentFile=/home/exedev/app.env
Environment=DYNO=scheduler.1
ExecStart=/bin/bash -c 'python manage.py report'
TimeoutStartSec=10min
EOF
sudo tee /etc/systemd/system/app-report.timer > /dev/null <<'EOF'
[Unit]
Description=Every 10 minutes, like Heroku Scheduler

[Timer]
OnCalendar=*:0/10

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
```

Workers and timers stay off until the move, so jobs don't run twice and
the VM doesn't pull from Heroku's queues. List them in `~/heroku/held`,
one per line, for `move` to start:

```bash
printf '%s\n' app-worker.service app-report.timer > ~/heroku/held
```

Want more than one of a process, as `ps.json` may show? Raise
`WEB_CONCURRENCY` for `web`. For workers, most queue libraries take a
concurrency setting; or copy the unit to `app-worker2.service`.

### Restore the database

This restores a Heroku Postgres backup into the app's database: from the
link in `db-url.txt`, a file, or a `postgres://` URL to dump from. It
runs the [PostgreSQL recipe's restore](/deploy/postgres#restore-it) as
one command, for the move: into an empty `app` database, as the `app`
role made superuser for the duration, since Heroku's dumps create
extensions in a `heroku_ext` schema, which it then keeps on the search
path, as Heroku did. It stops at the first error and stops the app while
it works:

```bash
cat > ~/heroku/restore-db <<'EOF'
#!/bin/bash
# ~/heroku/restore-db SOURCE: replace the app database with a pg_dump -Fc backup,
# in the PostgreSQL recipe's restore order (/deploy/postgres#restore-it), app stopped.
# SOURCE is a file (heroku pg:backups:download, ~/postgres/backups/*/app.dump), the
# https URL from heroku pg:backups:url, or a postgres:// URL to dump from, read-only.
set -euo pipefail
src=${1:?usage: restore-db FILE|https-URL|postgres-URL}
db=app dump=$src
case $src in
  http*)
    dump=$HOME/heroku/latest.dump
    echo "Downloading the backup..."
    curl -fsS --retry 3 -o "$dump" "$src" ;;
  postgres*)
    dump=$HOME/heroku/latest.dump
    echo "Dumping the database, read-only..."
    PGOPTIONS='-c default_transaction_read_only=on' \
      pg_dump --format=custom --no-owner --no-privileges --dbname="$src" --file="$dump" ;;
esac
pg_restore --list "$dump" > /dev/null    # is it a pg_dump -Fc file?
echo "Stopping the app while the database is replaced..."
sudo systemctl stop 'app*.service' 2>/dev/null || true
dropdb --if-exists --force $db
createdb --owner=$db $db
echo "Restoring..."
psql -X -q -d postgres -c "ALTER ROLE $db SUPERUSER"
trap 'psql -X -q -d postgres -c "ALTER ROLE $db NOSUPERUSER"' EXIT
pg_restore --dbname=$db --role=$db --no-owner --no-privileges --exit-on-error --jobs=2 "$dump"
psql -X -q -d postgres -c "ALTER ROLE $db NOSUPERUSER"
trap - EXIT
psql -X -q -d $db -c 'SET client_min_messages = warning; CREATE EXTENSION IF NOT EXISTS vector'
if [ -n "$(psql -X -At -d $db -c "SELECT 1 FROM pg_namespace WHERE nspname = 'heroku_ext'")" ]; then
  psql -X -q -d postgres -c "ALTER DATABASE $db SET search_path = \"\$user\", public, heroku_ext"
fi
vacuumdb --analyze-only --quiet --dbname=$db
for unit in $(systemctl list-unit-files --state=enabled --plain --no-legend 'app*.service' | cut -d' ' -f1); do
  sudo systemctl start "$unit"
done
echo "Restored the database from ${dump##*/}"
EOF
chmod +x ~/heroku/restore-db
~/heroku/restore-db "$(cat ~/heroku/db-url.txt)" && rm ~/heroku/db-url.txt
```

The link lasts an hour. If it's expired, ask the owner to run
`heroku pg:backups:url -a my-heroku-app | ssh my-app.exe.xyz 'cat > heroku/db-url.txt'`.
On Heroku Postgres Advanced, which has no `pg:backups`, pass the
`DATABASE_URL` from `config.json` instead: `restore-db` only reads from
it. For a database that has an extension Ubuntu's PostgreSQL lacks, such
as PostGIS, install it first: `sudo apt-get install -y postgresql-18-postgis-3`.

### First deploy

The owner's push only stored the code, so deploy it now. It takes a few
minutes the first time, as Heroku's first build did:

```bash
~/app.git/hooks/pre-receive main
```

It ends `Deployed 1a2b3c4: HTTP 200 on port 8000`, or a redirect. Then
open `https://my-app.exe.xyz/`. exe.dev's proxy sends `X-Forwarded-Proto`,
`X-Forwarded-Host`, and `X-Forwarded-For`, as Heroku's router did, so an
app that trusted Heroku's proxy trusts this one. It also has to accept
the VM's name: add `my-app.exe.xyz` where the app lists its hosts (Django's
`ALLOWED_HOSTS`, Rails' `config.hosts`), through a config var if it reads
one.

### Back up every night

Heroku Postgres took backups for you. Here the PostgreSQL steps above
already back up every database each night into `~/postgres/backups`,
keeping 14. Take one now, with Heroku's data in it, and restore it the
way `heroku pg:backups:restore` did, to prove both work:

```bash
latest=$(~/postgres/backup.sh)
~/heroku/restore-db "$latest/app.dump"
```

They're on this VM's disk, so they don't survive losing the VM: copy
them somewhere else too, as [the PostgreSQL recipe
shows](/deploy/postgres#keep-your-data).

### Check

This asks Heroku and the VM for each page in `paths.txt` and compares
the status, redirect, and page title. It saves Heroku's answers for after
the move, when Heroku shows its maintenance page:

```bash
cat > ~/heroku/check <<'EOF'
#!/bin/bash
# ~/heroku/check: ask Heroku and the VM the same questions and compare the answers.
#   ~/heroku/check          live Heroku vs the VM; saves Heroku's answers
#   ~/heroku/check --saved  the VM vs the answers saved last time (Heroku in maintenance)
# Questions are the paths in ~/heroku/paths.txt; an answer is "<status> <redirect> | <title>".
set -uo pipefail
cd ~/heroku
src=$(cat source-url)                  # where the app is served today, e.g. https://www.example.com
host=${src#*://}; host=${host%%/*}
answer() {
  local body head
  body=$(mktemp)
  head=$(curl -s -o "$body" -m 20 -w '%{http_code} %{redirect_url}' "$@")
  echo "$head | $(tr -d '\r\n' < "$body" | grep -o -i -m1 '<title>[^<]*' | head -1 | sed 's/^<title>[[:space:]]*//I')"
  rm -f "$body"
}
for _ in $(seq 30); do curl -s -o /dev/null -m 5 http://localhost:8000/ && break; sleep 1; done  # app starting?
different=0
[ "${1:-}" = --saved ] || : > answers.txt
while read -r path; do
  [ -n "$path" ] || continue
  if [ "${1:-}" = --saved ]; then
    heroku=$(awk -F'\t' -v p="$path" '$1 == p { print $2 }' answers.txt)
  else
    heroku=$(answer "$src$path")
    printf '%s\t%s\n' "$path" "$heroku" >> answers.txt
  fi
  vm=$(answer "http://localhost:8000$path" -H "Host: $host" -H "X-Forwarded-Host: $host" \
       -H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7")
  if [ "$heroku" = "$vm" ]; then
    echo "same       $path  $vm"
  else
    echo "DIFFERENT  $path"; echo "    Heroku: $heroku"; echo "    VM:     $vm"
    different=$((different + 1))
  fi
done < paths.txt
if [ "$different" = 0 ]; then echo "same: the VM answers like Heroku"; else echo "$different DIFFERENT"; exit 1; fi
EOF
chmod +x ~/heroku/check && ~/heroku/check
```

`DIFFERENT` lines show both answers. Fix what's different here, never on
Heroku, until every line says `same`.

### Get ready for the move

`move` is the owner's: it checks Heroku is in maintenance mode, restores
the last backup, starts the held workers and timers, and checks again:

```bash
cat > ~/heroku/move <<'EOF'
#!/bin/bash
# ~/heroku/move: the VM takes over from Heroku. The owner runs it after
# `heroku maintenance:on` and scaling the other processes to 0, with the URL
# of a fresh backup:
#   heroku pg:backups:url -a my-heroku-app | ssh my-app.exe.xyz heroku/move
# To give the app back to Heroku, before DNS changes:
#   ssh my-app.exe.xyz heroku/move undo
set -euo pipefail
cd ~/heroku
app=$(python3 -c 'import json; print(json.load(open("info.json"))["app"]["name"])')
src=$(cat source-url)
held=$(grep -v '^#' held || true)   # the workers and timers that wait for the move
if [ "${1:-}" = undo ]; then
  [ -z "$held" ] || sudo systemctl disable --now $held
  scale=$(python3 -c 'import collections, json; print(" ".join(f"{t}={n}" for t, n in collections.Counter(d["type"] for d in json.load(open("ps.json"))).items() if t != "web"))')
  echo "Workers and timers are off here. To bring Heroku back, on your computer:"
  echo "  heroku maintenance:off -a $app"
  [ -z "$scale" ] || echo "  heroku ps:scale $scale -a $app"
  echo "and add the Scheduler jobs back, from ~/heroku/scheduler.txt."
  exit
fi
code=$(curl -s -o /dev/null -m 20 -w '%{http_code}' "$src/" || true)
if [ "$code" != 503 ] && [ "${1:-}" != --anyway ]; then
  echo "Heroku still answers HTTP $code at $src. First: heroku maintenance:on -a $app"
  exit 1
fi
url=
[ -t 0 ] || read -r url || true
if [ -n "$url" ]; then
  ~/heroku/restore-db "$url"
elif grep -q '"DATABASE_URL"' config.json; then
  echo "Pipe in the backup's URL: heroku pg:backups:url -a $app | ssh $(hostname).exe.xyz heroku/move"
  exit 1
fi
[ -z "$held" ] || sudo systemctl enable --now $held
~/heroku/check --saved || echo "Look into the DIFFERENT answers before you change DNS."
vm=$(hostname)
domains=$(python3 -c 'import json; print(" ".join(d["hostname"] for d in json.load(open("domains.json")) if d["kind"] == "custom"))')
echo
echo "The VM has Heroku's data and runs everything. Next, on your computer:"
echo "point your domains at $vm.exe.xyz, then"
for d in $domains; do echo "  ssh exe.dev domain add $vm $d"; done
echo "  ssh exe.dev share set-public $vm"
echo "Changed your mind before the DNS change? Run: ssh $vm.exe.xyz heroku/move undo"
EOF
chmod +x ~/heroku/move
```

Last, write `~/heroku/report.md` for the owner, without secrets:

- **What moved:** the processes, timers, runtimes and versions, the
  database (its size, and how long the restore took), and the config vars
  by name.
- **What changed for exe.dev:** each line of `vm.env`, and any change to
  the build or the app's settings.
- **The add-ons:** each one, and where it goes, from the table in
  [Where everything goes](#where-everything-goes). Say which keep working
  through their config vars until the Heroku app is deleted.
- **What didn't move,** and what to do instead.
- **The move:** the process types to scale to 0, the Scheduler jobs to
  delete, each domain with its DNS record now (from `domains.json`) and
  the one it needs, and the commands from [Make the move](#make-the-move)
  with this VM's name and the Heroku app's.

That's the rehearsal. Open `https://my-app.exe.xyz/` and
[look around](#look-around).

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

- **`git push` says `src refspec main does not match any`:** your branch
  is `master`. Push it as `main`: `git push exe master:main`.
- **The deploy stops on a missing header or library**, such as
  `pg_config` or `vips.h`: Heroku's stack had it installed. Install the
  Ubuntu package (`libpq-dev`, `libvips-dev`) and push again.
- **`Your Ruby version is 3.4.9, but your Gemfile specified 3.4.11`:**
  run `mise use -g ruby@3.4.11` on the VM, then push again.
- **`mv: cannot overwrite directory`:** `~/app` is a directory, not the
  link to a release. Move it aside (`mv ~/app ~/app.old`) and deploy again.
- **The push ends `pre-receive hook declined`:** the deploy failed, and
  the lines above say where. The previous release still runs and `main`
  on the VM still names it. `Deploy FAILED: it answers HTTP 000` means
  the web process didn't start, or listens somewhere other than `$PORT`:
  see `journalctl -u app -n 50`.
- **A `502` at `https://my-app.exe.xyz/`:** the same: the web process
  isn't answering on port 8000.
- **`400 Bad Request` or `Blocked host`:** add the VM's name or your
  domain to `ALLOWED_HOSTS` or `config.hosts`.
- **Too many redirects:** the app redirects to HTTPS without trusting
  `X-Forwarded-Proto`. Django needs `SECURE_PROXY_SSL_HEADER`; Rails,
  `config.assume_ssl`.
- **`restore-db` stops with an error:** the [PostgreSQL recipe's
  list](/deploy/postgres#restore-it) covers the usual ones. For
  `extension "..." is not available`, install its package, such as
  `postgresql-18-postgis-3`, and restore again. The app stays stopped
  until a restore succeeds.
- **`curl: (22) ... 403`** downloading the backup: the link expired after
  an hour. Ask for a fresh one.
- **Connecting to Valkey: `SSL` or `certificate` errors:** the app still
  forces TLS for Heroku Key-Value Store's `rediss://` URL. Valkey here is
  plain `redis://` on localhost; drop the TLS options.
- **Config changes don't show:** units read `~/app.env` when they start.
  Redeploy (`~/app.git/hooks/pre-receive main`) or restart the units.
- **npm: `install scripts blocked because they are not covered by
  allowScripts`:** npm 12 runs a package's install scripts only when
  `allowScripts` in `package.json` allows them. Allow the ones the app
  needs with `npm install-scripts approve <package>`, commit, and push.
- **A worker restarts over and over:** `journalctl -u app-worker -n 50`.
  It keeps restarting until the problem is fixed, as dynos did.

</details>

## Frequently asked questions

### Is Heroku shutting down?

No. In February 2026 Salesforce moved Heroku to what it calls a sustaining engineering model, focused on stability, security, and support, and stopped offering Enterprise contracts to new customers. Apps keep running. Many teams are moving anyway, to have a roadmap they control. Nothing on this page is in a hurry: Heroku keeps serving through the whole rehearsal.

### Why not give Shelley a Heroku API token?

Because a Heroku token can't be limited to one app. Its scopes cover every app your account or team can reach, and reading config vars takes `read-protected`, which includes every app's secrets. It would also end up in the prompt, Shelley's conversation, and the VM. The hand-over needs a few read-only commands run once, so you run them, and their output goes straight to the VM over SSH. The only credential that travels is the backup link, which expires in an hour. The commands that change Heroku, at the move, stay yours.

### How long is the app down?

From `heroku maintenance:on` until your domain points at the VM: the time to back up and restore the database, a minute or two for a few GB, plus the DNS change. Lower your DNS TTL to 300 seconds a day ahead. Visitors see Heroku's maintenance page meanwhile.

### What about the data in Heroku Key-Value Store?

It isn't copied. Apps use it for caches, job queues, and sessions, which refill on their own; at worst, people sign in again. Let your workers finish the queue before you scale them to zero. If you keep data there that you can't lose, ask Shelley to copy the keys across before the move; it has `REDIS_URL` from your config vars.

### What replaces review apps, pipelines, and staging?

More VMs. `ssh exe.dev cp my-app my-app-staging` copies this one, data and all, and `git push` to it deploys there. See [Dev, test, and prod](/deploy/dev-test-prod) for a setup with a VM per stage.

### My app is on the Fir generation, or deploys a Docker image.

Fir apps and Cedar apps on Cloud Native Buildpacks hand over the same way: the Procfile and the language's usual files still say how to build and run them. An app that builds from `heroku.yml` is a Dockerfile: run it with [Docker Compose](/deploy/docker-compose) on the VM, and use this page for its config vars, database, and the move.

### Can I go back?

Until your domain points at the VM, yes: `heroku/move undo` turns the VM's workers and timers off, and `heroku maintenance:off` brings Heroku back as it was. After that, new data exists only on the VM, so treat the move as one-way.

### What does it cost?

Your [exe.dev plan](https://exe.dev/pricing): one VM runs the web process, workers, database, and Valkey together, using all of its CPU and memory. Heroku bills dynos and add-ons until you delete them, so [clean up](#clean-up) once you're settled.


## Related

- [Migrate a DigitalOcean Droplet to exe.dev](https://exe.dev/deploy/migrate-digitalocean-droplet.md): Same server, new home. Rehearsed while the droplet keeps serving.
- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Deploy a Rails app](https://exe.dev/deploy/rails.md): Your Rails app on a server of its own. git push, and it's live.
- [Deploy a Django app](https://exe.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
- [Migrate a Durable Object from Cloudflare](https://exe.dev/deploy/migrate-durable-objects.md): Same Worker, same objects, new home. Every key and row checked.
