Skip to content

Migrate a Heroku app to exe.dev

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

Deploy on exe.dev

Shelley handles the setup. About 1 hour.

Prefer a terminal?
curl -fsSL https://exe.dev/deploy/migrate-heroku/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

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.

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.
What's running, and where
Private https://my-app.exe.xyz/ the app, until you make the move
Public https://www.example.com/ the app, once your domain points at the VM and it's public
Private ssh my-app.exe.xyz the shell, in place of heroku run bash; git push exe main deploys
my-app.exe.xyz exe.dev 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 :8000~/app, from ~/app.git
PostgreSQL 18 In place of Heroku Postgres, set up as in the PostgreSQL recipe and restored from Heroku's backup /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 ~/heroku

Deploy with Shelley#

Click Deploy and Shelley will set up Apps from Heroku on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • HEROKU_APP: the Heroku app's name, as heroku apps lists it
Prefer a terminal?
curl -fsSL \
  https://exe.dev/deploy/migrate-heroku/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin
What Shelley is told
HEROKU_APP=<fill-this-in>

Set up Apps from Heroku on this VM by following the exe.dev recipe at https://exe.dev/deploy/migrate-heroku.md from top to bottom.

The values at the top of this message are mine:
- HEROKU_APP: the Heroku app's name, as `heroku apps` lists it
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-app. This VM's name is the output of `hostname`: use it wherever the recipe writes my-app. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- 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.

Read the whole recipe, including the closed details, and do "Rehearse the
move by hand" in order, skipping VM creation; the walkthrough above it is
for the owner. Use its scripts and units as written: they were tested
together, and move and check rely on them. Change them only where this
app needs it (another Procfile type, a build step, a Scheduler job), and
list the changes in the report. For PostgreSQL, follow the PostgreSQL
recipe's steps as "Install PostgreSQL and Valkey" says (its Markdown is
/deploy/postgres.md on the same site), skipping Adminer.
You need the owner once, early: show them the commands in
"Hand over the app" with this VM's name and HEROKU_APP, and wait until
~/heroku/config.json is there and ~/app.git has a commit. Install what
doesn't depend on them meanwhile.
Never run heroku commands yourself, and never write to Heroku's database,
Key-Value Store, or other add-ons: the URLs in ~/heroku/config.json are
live production ones. Never run ~/heroku/move, change DNS, or run share
set-public: the move is the owner's call. Until then, keep the workers
and Scheduler timers off on this VM.
Keep config var values and the backup URL out of your replies and the
report.
If the app relies on something exe.dev can't run (Heroku Connect, Apache
Kafka on Heroku, inbound ports other than HTTP and SSH), don't work
around it silently: put it in the report and ask.
Finish with the report's summary, https://<vm>.exe.xyz/, and the owner's
steps in "Make the move" with this VM's name, the Heroku app, and its
domains.

Finish with the URLs I should open.

Also at /deploy/migrate-heroku/prompt.txt. The recipe it follows is /deploy/migrate-heroku.md.

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 for Python, mise for Ruby and Node.js
Procfile web app.service, on port 8000, behind exe.dev's HTTPS 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, 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, 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

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
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, Rails, Next.js, FastAPI, and Laravel.

Hand over the app#

When Shelley asks, run these on your computer, logged in to the Heroku CLI, with your app's name in place of my-heroku-app:

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.

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:

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:

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:

~/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:
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:

  1. 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.
  2. On your computer, register the domain and open the VM to everyone:
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:

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. For anything else about the database, from connecting a desktop client to undoing a mistake, the PostgreSQL recipe 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:
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.

Rehearse the move by hand

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:

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

Then 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 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.
  • 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:

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.

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" 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:

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, 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 installs any version in seconds, from prebuilt binaries. Name each language the app uses, at the version from dyno.txt:
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:

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:

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:

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:

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:

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:

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:

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:

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 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:

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:

~/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:

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.

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:

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:

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. 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 with this VM's name and the Heroku app's.

That's the rehearsal. Open https://my-app.exe.xyz/ and look around.

Something not working?
  • 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 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.

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 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 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: 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 once you're settled.

Updated Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us