Migrate a Heroku app to exe.dev
Your Procfile, config vars, and Postgres on a VM of your own.
Shelley handles the setup. About 1 hour.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/migrate-heroku/prompt.txt | ssh exe.dev new --prompt=/dev/stdinA 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 maindeploying likegit push heroku main: build, release phase, restart, and a push rejected, with the previous release still running, if anything fails.~/heroku/checkfinding every page in~/heroku/paths.txtthe 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
https://my-app.exe.xyz/
the app, until you make the move
https://www.example.com/
the app, once your domain points at the VM and it's public
ssh my-app.exe.xyz
the shell, in place of heroku run bash; git push exe main deploys
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, asheroku appslists it
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/migrate-heroku/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat 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-staging.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#
- 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 pushthe code. - 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.
- 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 runspg_dumpitself, read-only, with theDATABASE_URLin your config vars. Do the same above 20 GB, where Heroku advises againstpg: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:
- In the Scheduler dashboard, delete the jobs: maintenance mode doesn't stop them. The VM has the list.
- On your computer, stop Heroku and hand the VM the last backup. Scale
each process type in
heroku psexceptwebto 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:
- At your DNS provider, point the domain at the VM:
www.example.com CNAME my-app.exe.xyz, in place of theherokudns.comtarget. For a bareexample.com, use an ALIAS or flattened CNAME, or an A record to the addresshost my-app.exe.xyzshows. On Cloudflare, turn the proxy off (grey cloud). See custom domains. - 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.jsonanddyno.txthold Heroku's old credentials, andlatest.dumpis the database as of the move. Keepreport.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 (cedarorfir), the region,web_url, and the add-ons. An app that deploys a Docker image sayscontainer: run it with Docker Compose instead of the build below.ps.jsonand theProcfile: 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, andreleases.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, setUV_PYTHONinvm.envbelow 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 pushsayssrc refspec main does not match any: your branch ismaster. Push it asmain:git push exe master:main.- The deploy stops on a missing header or library, such as
pg_configorvips.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: runmise use -g ruby@3.4.11on the VM, then push again.mv: cannot overwrite directory:~/appis 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 andmainon the VM still names it.Deploy FAILED: it answers HTTP 000means the web process didn't start, or listens somewhere other than$PORT: seejournalctl -u app -n 50. - A
502athttps://my-app.exe.xyz/: the same: the web process isn't answering on port 8000. 400 Bad RequestorBlocked host: add the VM's name or your domain toALLOWED_HOSTSorconfig.hosts.- Too many redirects: the app redirects to HTTPS without trusting
X-Forwarded-Proto. Django needsSECURE_PROXY_SSL_HEADER; Rails,config.assume_ssl. restore-dbstops with an error: the PostgreSQL recipe's list covers the usual ones. Forextension "..." is not available, install its package, such aspostgresql-18-postgis-3, and restore again. The app stays stopped until a restore succeeds.curl: (22) ... 403downloading the backup: the link expired after an hour. Ask for a fresh one.- Connecting to Valkey:
SSLorcertificateerrors: the app still forces TLS for Heroku Key-Value Store'srediss://URL. Valkey here is plainredis://on localhost; drop the TLS options. - Config changes don't show: units read
~/app.envwhen 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 whenallowScriptsinpackage.jsonallows them. Allow the ones the app needs withnpm 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