How to Migrate Your App from Heroku to VPS

by David Park
How to Migrate Your App from Heroku to VPS

What You'll End Up With

By the end of this guide you will have your application — a Node.js or Ruby/Rails app is used as the reference, but the steps apply to any stack — running on a Ubuntu 24.04 VPS behind Nginx, managed by systemd, with your Postgres database migrated from Heroku's managed add-on to a self-hosted instance on the same server. You'll cut a typical $25–$50/month Heroku bill down to $6–$10/month on Hetzner or DigitalOcean.

Prerequisites

  • A Heroku account with a deployed app and a Heroku Postgres add-on
  • A fresh Ubuntu 24.04 VPS (minimum 2 GB RAM, 20 GB disk)
  • A non-root sudo user on the VPS
  • SSH access to the VPS
  • The Heroku CLI installed locally (heroku --version returns output)
  • A domain name pointed at the VPS IP (A record propagated)
  • Basic comfort with psql and your app's process manager

Step 1 — Harden the VPS and Install Dependencies

Log in and update the system before touching anything else.

1.1 — Update packages.

sudo apt update && sudo apt upgrade -y

1.2 — Install Nginx, PostgreSQL, and runtime dependencies.

Replace nodejs with your runtime (e.g., ruby-full, python3) if needed.

sudo apt install -y nginx postgresql postgresql-contrib nodejs npm git ufw

1.3 — Allow SSH and HTTP/HTTPS through the firewall, then enable it.

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Expected output:

Firewall is active and enabled on system startup

1.4 — Install the Node process manager (skip for non-Node stacks).

sudo npm install -g pm2

Step 2 — Export Your Data from Heroku

This is the highest-risk step. Do it during a low-traffic window and put your Heroku app in maintenance mode first.

2.1 — Enable maintenance mode on Heroku.

heroku maintenance:on --app YOUR_APP_NAME

2.2 — Capture a fresh Postgres backup.

heroku pg:backups:capture --app YOUR_APP_NAME

Expected output:

Backing up DATABASE to b001... done

2.3 — Download the backup to your local machine.

heroku pg:backups:download --app YOUR_APP_NAME

This creates latest.dump in your current directory.

2.4 — Copy the dump to your VPS.

scp latest.dump YOUR_USER@YOUR_VPS_IP:/home/YOUR_USER/latest.dump

2.5 — Export all Heroku config vars to a local file.

heroku config --app YOUR_APP_NAME --json > heroku_env.json

Keep this file off version control. You'll use it in Step 4.


Step 3 — Create the Postgres Database on the VPS

3.1 — Switch to the postgres system user.

sudo -i -u postgres

3.2 — Create a database user and database.

createuser --pwprompt appuser
createdb --owner=appuser appdb

Choose a strong password when prompted. Record it — you'll need it for your app's DATABASE_URL.

3.3 — Exit back to your sudo user.

exit

3.4 — Restore the Heroku dump into the new database.

pg_restore --verbose --clean --no-acl --no-owner \
  -h localhost -U appuser -d appdb \
  /home/YOUR_USER/latest.dump

Enter the password you set in 3.2 when prompted. Expect verbose table-by-table output ending without ERROR lines on critical objects.

3.5 — Verify row counts match Heroku.

On Heroku, run:

heroku pg:psql --app YOUR_APP_NAME -c "SELECT COUNT(*) FROM your_main_table;"

On the VPS:

psql -U appuser -d appdb -c "SELECT COUNT(*) FROM your_main_table;"

Both numbers must match before you proceed.


Step 4 — Deploy the Application Code

4.1 — Create a dedicated app user (no login shell).

sudo useradd --system --no-create-home --shell /usr/sbin/nologin apprunner

4.2 — Clone your repository.

sudo mkdir -p /var/www/myapp
sudo chown YOUR_USER:YOUR_USER /var/www/myapp
git clone https://github.com/YOUR_ORG/YOUR_REPO.git /var/www/myapp

4.3 — Install application dependencies.

cd /var/www/myapp
npm ci --production

For Rails: bundle install --without development test

4.4 — Write the environment file.

Create /var/www/myapp/.env and populate it from heroku_env.json. At minimum set:

sudo nano /var/www/myapp/.env
DATABASE_URL=postgres://appuser:YOUR_PASSWORD@localhost:5432/appdb
NODE_ENV=production
PORT=3000
SECRET_KEY_BASE=<value from heroku_env.json>

Add every other key from heroku_env.json. Save and restrict permissions:

sudo chown apprunner:apprunner /var/www/myapp/.env
sudo chmod 600 /var/www/myapp/.env

4.5 — Run database migrations.

cd /var/www/myapp
npm run db:migrate

For Rails: RAILS_ENV=production bundle exec rails db:migrate


Step 5 — Create a systemd Service Unit

Using systemd keeps your app alive across reboots without a separate process manager.

5.1 — Create the unit file.

sudo nano /etc/systemd/system/myapp.service

Paste the following, adjusting ExecStart for your runtime:

[Unit]
Description=My Application
After=network.target postgresql.service

[Service]
Type=simple
User=apprunner
WorkingDirectory=/var/www/myapp
EnvironmentFile=/var/www/myapp/.env
ExecStart=/usr/bin/node /var/www/myapp/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

For Rails replace ExecStart with:

ExecStart=/usr/local/bin/bundle exec puma -C config/puma.rb

5.2 — Reload systemd, enable, and start the service.

sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp

5.3 — Confirm the service is running.

sudo systemctl status myapp

Expected output includes Active: active (running).


Step 6 — Configure Nginx as a Reverse Proxy

6.1 — Create a server block for your domain.

sudo nano /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

6.2 — Enable the site and remove the default.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo rm /etc/nginx/sites-enabled/default

6.3 — Test and reload Nginx.

sudo nginx -t
sudo systemctl reload nginx

Expected output from nginx -t:

nginx: configuration file /etc/nginx/nginx.conf test is successful

6.4 — Issue a TLS certificate with Certbot.

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot rewrites the Nginx block to redirect HTTP to HTTPS automatically.


Verify It Works

Run each check in order. Fix any failure before moving to the next.

Check 1 — App process is running.

sudo systemctl is-active myapp

Expected: active

Check 2 — App responds locally.

curl -I http://127.0.0.1:3000

Expected: HTTP/1.1 200 OK (or a redirect, not a connection refused).

Check 3 — Nginx proxies correctly.

curl -I https://yourdomain.com

Expected: HTTP/2 200 with your app's response headers.

Check 4 — Database connectivity.

psql -U appuser -d appdb -c "\dt"

Expected: your app's table list.

Check 5 — Logs show no errors.

sudo journalctl -u myapp -n 50 --no-pager

Look for unhandled exceptions or missing environment variables.

Once all five checks pass, turn off Heroku maintenance mode and update your DNS A record to the VPS IP if you haven't already. Monitor for 24 hours before scaling down the Heroku dyno.


Troubleshooting

pg_restore exits with errors about roles or extensions. The --no-owner and --no-acl flags handle most permission errors. If you see extension "uuid-ossp" does not exist, install it: sudo -u postgres psql -d appdb -c "CREATE EXTENSION \"uuid-ossp\";" then re-run the restore.

systemctl start myapp fails immediately. Run sudo journalctl -u myapp -n 30 and look for the first ERROR line. The most common causes are a wrong path in ExecStart, a missing environment variable, or a port already in use (sudo ss -tlnp | grep 3000).

Nginx returns 502 Bad Gateway. The app isn't listening on the port Nginx expects. Confirm PORT=3000 in .env matches the proxy_pass address, then sudo systemctl restart myapp.

Certbot fails with Connection refused on port 80. Check sudo ufw status — port 80 must be open. Also verify your domain's A record resolves to the VPS IP: dig +short yourdomain.com.

App can't connect to Postgres after migration. Confirm pg_hba.conf allows local password auth. Open /etc/postgresql/16/main/pg_hba.conf and ensure the line for local connections uses md5 or scram-sha-256, not peer. Reload with sudo systemctl reload postgresql.

Environment variables missing at runtime. Heroku's config vars don't map 1-to-1 with variable names your app expects. Cross-reference heroku_env.json against your app's documented required variables and add any gaps to /var/www/myapp/.env.


Next Steps

With the migration to a VPS complete, consider these follow-up tasks:

  • Automate backups: schedule pg_dump via cron and ship dumps to Backblaze B2 or an S3-compatible bucket.
  • Set up log rotation: add a /etc/logrotate.d/myapp config so journal logs don't fill the disk.
  • Harden Postgres: move the database to a private network interface if your VPS provider supports it, and disable remote Postgres access in postgresql.conf (listen_addresses = 'localhost').
  • Add a deploy pipeline: a simple GitHub Actions workflow that SSH-deploys on push to main replaces Heroku's git push workflow cleanly.
  • Monitor uptime: a free UptimeRobot monitor and this guide on WordPress caching strategy testing give you the observability Heroku's dashboard provided.