Leaving Heroku for a server you own

What Heroku's 2026 change means, and how to move a Laravel or Node app to a VPS: Postgres, Redis, workers, scheduler, config vars and DNS, in order.

Jacob Søndergaard ·

Moving a Laravel or Node app from Heroku to a server you run is mostly translation. Each Heroku concept has a plain Linux counterpart: a dyno becomes a systemd service, an add-on becomes a package on the box, a config var becomes a line in a file. This post walks through the move in the order you would do it, with the database restore in detail because that is where migrations go wrong.

What Heroku announced

On 6 February 2026, Heroku wrote that it is “transitioning to a sustaining engineering model focused on stability, security, reliability, and support” (An Update on Heroku). The same post says the emphasis is on quality rather than new features, that nothing changes for customers paying by credit card, and that Enterprise Account contracts are no longer offered to new customers, while existing ones are honoured and can renew. It names no end date. Heroku has kept shipping since: its October 2026 update lists Postgres 18 as the default and new access controls.

Nothing forces a move this month. What changed is the plan: new features are no longer it, so if you were waiting for one, decide without it. If cost is part of the decision, the comparison page prices the same app on Heroku, Render, Fly.io and a server you own, with every rate card linked.

What a dyno, an add-on and a config var become

Start with an inventory of the app on Heroku:

heroku apps:info -a example-app
heroku ps -a example-app
heroku addons -a example-app
heroku pg:info -a example-app
heroku pg:psql -a example-app -c '\dx'
heroku pg:psql -a example-app -c 'SHOW search_path'
heroku config --shell -a example-app > heroku.env

Then map each piece. A typical Node Procfile:

Procfile
web: npm start
worker: node worker.js
release: npx prisma migrate deploy
  • web dyno. A systemd service listening on 127.0.0.1, with Nginx in front for TLS. For Node, see deploying a Node.js app with systemd; for Laravel, Nginx and PHP-FPM as in deploying Laravel to Ubuntu. Heroku’s PHP buildpack starts its own web server per dyno; on a server that job is Nginx and one PHP-FPM pool.
  • worker dyno. Another systemd service, below.
  • release phase. A step in your deploy script after the build and before the switch, such as php artisan migrate --force.
  • Heroku Postgres. PostgreSQL from Ubuntu’s archive, on the same server or a second one.
  • Heroku Key-Value Store. Redis from Ubuntu’s archive.
  • Heroku Scheduler. A systemd timer, or a cron entry.
  • Config vars. An environment file.
  • The router and ACM certificates. Nginx and certbot.

The examples below continue the layout from the Node post: the app in /srv/web/current, running as the web account, with its environment in /etc/web/env.

Postgres: dump, restore, grants

Install PostgreSQL and check its major version against what heroku pg:info reported. pg_restore refuses an archive written by a newer pg_dump, and Heroku now defaults to Postgres 18. Ubuntu 26.04 ships 18; 24.04 ships 16, and a Postgres 18 dump fails there before it reads a single table. Install 18 from the PostgreSQL project’s own apt repository instead:

$ pg_restore -l latest.dump
pg_restore: error: unsupported version (1.16) in file header
Ubuntu 24.04 only
apt-get install -y postgresql-common
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
apt-get install -y postgresql-18

Do this before installing Ubuntu’s own postgresql package, so 18 is the only cluster on the server and listens on the default port, 5432.

Then, on either release, create the role the app will connect as, and a database it owns:

apt-get install -y postgresql
pg_restore --version
sudo -u postgres psql -c "CREATE ROLE web LOGIN PASSWORD 'change-me'" -c "CREATE DATABASE web OWNER web"

The dump comes later, at cut-over, from Heroku’s backup tool, which writes pg_dump’s custom format. Do a full rehearsal with a fresh backup now, though; the restore is the step to practise.

Restore as the app’s own role, not as postgres. Every table then belongs to web, as it belonged to Heroku’s generated role, and migrations keep working. Restore as postgres and the tables belong to postgres: the app can be granted access to them, but the first migration that alters one fails with must be owner of table.

Two things in a Heroku dump need a superuser. Extensions PostgreSQL does not mark as trusted, such as pg_stat_statements, cannot be created by an ordinary owner:

pg_restore: error: could not execute query: ERROR:  permission denied to create extension "pg_stat_statements"
HINT:  Must be superuser to create this extension.
Command was: CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public;

Create those first, as postgres, from the list \dx printed on Heroku. Trusted ones such as pgcrypto and citext the restore creates by itself:

sudo -u postgres psql -d web -c 'CREATE EXTENSION pg_stat_statements'

The other is COMMENT ON EXTENSION, which only an extension’s owner may run and which carries nothing you need. Drop those entries from the archive’s table of contents and restore from the edited list. With both handled, the restore finishes with no errors and exit status 0:

pg_restore -l latest.dump | grep -v 'COMMENT - EXTENSION' > restore.list
PGPASSWORD=change-me pg_restore --no-owner --no-acl -L restore.list -h 127.0.0.1 -U web -d web latest.dump
PGPASSWORD=change-me psql -h 127.0.0.1 -U web -d web -c '\dt'

--no-owner --no-acl is what Heroku’s own instructions use: the dump’s owner and grants name Heroku’s roles, which do not exist here. Heroku’s docs also restore with --clean, which drops each object before creating it; on a new, empty database that only adds an error for every object that is not there yet.

Databases with a heroku_ext schema

Between August 2022 and August 2023, Heroku installed extensions into a schema called heroku_ext, and databases from that period may still have them there. The dump recreates the schema, but your new database’s search path does not include it. On the test restore, a citext email column from heroku_ext silently became case-sensitive: WHERE email = 'USER1@example.com' matched nothing, with no error, until the search path was fixed. Set it to what SHOW search_path printed on Heroku:

sudo -u postgres psql -c 'ALTER DATABASE web SET search_path = "$user", public, heroku_ext'

Grants for other roles

The app’s role owns everything, so it needs no grants. Any other role does, for example a read-only one for reporting. ALTER DEFAULT PRIVILEGES covers tables the app’s migrations create later, which a one-time grant does not:

sudo -u postgres psql -d web <<'SQL'
CREATE ROLE reporting LOGIN PASSWORD 'change-me-too';
GRANT CONNECT ON DATABASE web TO reporting;
GRANT USAGE ON SCHEMA public TO reporting;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO reporting;
ALTER DEFAULT PRIVILEGES FOR ROLE web IN SCHEMA public GRANT SELECT ON TABLES TO reporting;
SQL

Point DATABASE_URL at postgres://web:change-me@127.0.0.1:5432/web. Settings that forced SSL for Heroku can stay: Ubuntu’s PostgreSQL has SSL on with a self-signed certificate, and a connection over 127.0.0.1 with sslmode=require works.

Redis

apt-get install -y redis-server
redis-cli ping

Ubuntu’s Redis listens on 127.0.0.1 only. Change REDIS_URL to redis://127.0.0.1:6379 and remove the client options that skipped certificate checks for Heroku’s self-signed TLS. A leftover rediss:// URL does not fail quickly against a server without TLS: in testing, redis-cli --tls hung until it was killed.

Heroku documents no export for Key-Value Store data, only forking into another Heroku instance or replication. If Redis holds only cache and queue data, drain the queues before the cut-over and start empty. If it holds anything you cannot rebuild, copy it with a script that reads from the old instance and writes to the new one.

Worker dynos as systemd services

A worker: line becomes a template unit; the instance number takes the place of the dyno count:

/etc/systemd/system/web-worker@.service
[Unit]
Description=web worker %i (was the worker dyno)
After=network.target postgresql.service redis-server.service

[Service]
Type=simple
User=web
Group=web
WorkingDirectory=/srv/web/current
EnvironmentFile=/etc/web/env
Environment=NODE_ENV=production
ExecStart=/srv/web/current/.node/bin/node worker.js
Restart=always
RestartSec=3
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now web-worker@1 web-worker@2
systemctl restart 'web-worker@*'     # in the deploy script, after the web service

TimeoutStopSec=60 is the grace period after SIGTERM, the counterpart of the 30 seconds a dyno gets on Heroku; handle SIGTERM in the worker by finishing the current job and exiting. Restart the workers on every deploy, or they keep running the old release. For Laravel, the units are in queue workers and Horizon with systemd.

Heroku Scheduler as cron, or a timer

Heroku Scheduler runs a command every 10 minutes, every hour or once a day. On a server, a systemd timer does the same job and starts the command with the same user, environment file and logging as the app, which is where cron jobs usually go wrong. A service that runs once, and a timer that starts it:

/etc/systemd/system/web-cleanup.service
[Unit]
Description=Nightly cleanup (was a Heroku Scheduler job)

[Service]
Type=oneshot
User=web
Group=web
WorkingDirectory=/srv/web/current
EnvironmentFile=/etc/web/env
Environment=NODE_ENV=production
ExecStart=/srv/web/current/.node/bin/node scripts/cleanup.js
/etc/systemd/system/web-cleanup.timer
[Unit]
Description=Run web-cleanup daily at 03:00 UTC

[Timer]
OnCalendar=*-*-* 03:00:00 UTC
Persistent=true

[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now web-cleanup.timer
systemctl list-timers web-cleanup.timer
systemctl start web-cleanup.service      # run it once now
journalctl -u web-cleanup.service -n 5

Persistent=true runs a missed job at boot if the server was down at 03:00. The three Scheduler frequencies as OnCalendar= values:

Every 10 minutes   OnCalendar=*:0/10
Every hour         OnCalendar=hourly
Daily at 03:00     OnCalendar=*-*-* 03:00:00 UTC

Laravel apps on Heroku often run php artisan schedule:run from Scheduler every 10 minutes, which skews every task to that grid. On a server, use the one crontab line the framework expects, every minute, as described in the Laravel scheduler post. Laravel reads its configuration from .env, so cron needs no environment file.

Config vars as an env file

heroku config --shell wrote every config var to heroku.env. Go through it line by line rather than copying it over. Drop what Heroku sets for itself or what now points at Heroku: DATABASE_URL and REDIS_URL get new values, add-on credentials for services you are leaving go, and PORT is set in the unit. The result goes into /etc/web/env:

/etc/web/env
DATABASE_URL='postgres://web:change-me@127.0.0.1:5432/web'
REDIS_URL='redis://127.0.0.1:6379'
SECRET_KEY_BASE='a$b`c'
APP_NAME='My App'

Single quotes keep $ and backticks literal, both for systemd and for a deploy script that sources the file in bash; the test app read a$b`c back exactly. For Laravel the same values go into the site’s .env instead, which the zero-downtime deploy post keeps in shared/.

Cut-over and DNS

A day before, lower the TTL on the records that point at Heroku to a few minutes, so the change propagates quickly when you make it. Then, on the day:

  1. Put the Heroku app in maintenance mode and stop its workers, so nothing writes to the old database, then take the final backup:
    heroku maintenance:on -a example-app
    heroku ps:scale worker=0 -a example-app
    heroku pg:backups:capture -a example-app
    heroku pg:backups:download -a example-app     # writes latest.dump
  2. Restore latest.dump on the server as above, start the services, and test the server directly, before DNS points at it:
    curl -sI --resolve example.com:80:203.0.113.10 http://example.com/
  3. Change the DNS records. A Heroku custom domain is usually a CNAME to a herokudns.com target, or an ALIAS at the apex; replace it with A and AAAA records for the server.
  4. Issue the certificate as soon as the name resolves to the server, with certbot. The HTTP-01 challenge needs DNS to point at the server, so there is a short window where visitors who already see the new address get no HTTPS. To close it, issue the certificate before the switch with a DNS-01 challenge.
  5. Leave the Heroku app in maintenance mode for a day or two, until the old TTLs have expired, then remove it and its add-ons.
Keep the last backup

Download one more backup before you delete the Heroku database and its add-ons, and keep it with your new backups. Heroku cannot recover a Key-Value Store instance once it is destroyed.

What you now own

Heroku did a set of jobs you no longer see, and each now needs an owner:

  • Patching. Ubuntu’s unattended upgrades install security updates; kernel updates still need a reboot you schedule. See hardening a new Ubuntu server.
  • Backups. Heroku Postgres took them for you. Now it is a scheduled dump to storage off the server, and a restore you have tested: backing up PostgreSQL to S3.
  • Certificates. certbot renews them on a timer. Check that the timer exists.
  • Capacity. There is no slider. Watch memory and disk, and resize the server before the build or the database runs out.
  • Monitoring. Something outside the server has to notice when the server itself is down.

How Provisore fits

Provisore does the server half of this move on a VPS you own. It installs PostgreSQL, MySQL and Redis per server, with users whose grants you set in the app; runs worker processes as daemons and scheduled commands as cron entries tied to a site; keeps each site’s environment file versioned and encrypted; and uploads scheduled database dumps to an S3-compatible bucket you own. Laravel and Node sites deploy on push behind a health check. The Heroku side stays yours: the backup download, the restore into the new database, and the DNS change.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.