How to Migrate from Heroku to VPS in 2024

by David Park
How to Migrate from Heroku to VPS in 2024

What You'll End Up With

By the end of this guide you'll have your Heroku application running on a self-managed Ubuntu 24.04 VPS — complete with a process manager, reverse proxy, SSL certificate, and a deploy workflow that mirrors what Heroku gave you. A $6/month Hetzner CAX11 handles most Node.js or Python apps that previously ran on a single Heroku dyno. You'll keep environment variables, a Postgres database, and zero-downtime restarts.

Prerequisites

  • A Heroku account with at least one deployed app
  • A VPS running Ubuntu 24.04 LTS (root or sudo access)
  • A domain name pointed at the VPS IP (A record propagated)
  • The Heroku CLI installed locally
  • Basic familiarity with SSH and a terminal

1. Export Everything from Heroku

Before touching the VPS, pull every artifact you need off Heroku.

1.1 — Capture environment variables

Run this locally to dump all config vars into a .env file you'll transfer to the server:

heroku config -a YOUR_APP_NAME --shell > .env.heroku

Expected output (one line per variable):

DATABASE_URL='postgres://user:pass@host:5432/db'
SECRET_KEY_BASE='abc123...'

Store this file securely — it contains secrets.

1.2 — Export the Postgres database

Capture a Heroku Postgres backup and download it:

heroku pg:backups:capture -a YOUR_APP_NAME
heroku pg:backups:download -a YOUR_APP_NAME -o latest.dump

You now have latest.dump (pg_dump custom format) on your local machine.

1.3 — Clone the application repository

If your code lives only on Heroku's git remote, clone it now:

git clone https://git.heroku.com/YOUR_APP_NAME.git myapp

2. Prepare the VPS

SSH into the server and configure a non-root deploy user.

2.1 — Create a deploy user

adduser deploy
usermod -aG sudo deploy

This creates deploy with a home directory at /home/deploy.

2.2 — Update packages and install core dependencies

apt update && apt upgrade -y
apt install -y git curl build-essential ufw nginx certbot python3-certbot-nginx

Installs Nginx, Certbot, and build tools in one pass.

2.3 — Configure the firewall

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable

Verify:

ufw status

Expected output:

Status: active
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
Nginx Full                 ALLOW       Anywhere

3. Install the Language Runtime

Match the runtime version your Heroku app declares.

For Node.js (check your engines field in package.json)

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
apt install -y nodejs
node --version

Expected: v20.x.x

For Python (check runtime.txt on Heroku)

apt install -y python3.12 python3.12-venv python3-pip
python3.12 --version

Expected: Python 3.12.x


4. Install and Restore Postgres

4.1 — Install Postgres 16

apt install -y postgresql postgresql-contrib
systemctl enable --now postgresql

4.2 — Create a database and user

Switch to the postgres system user and run:

sudo -u postgres psql -c "CREATE USER appuser WITH PASSWORD 'strongpassword';"
sudo -u postgres psql -c "CREATE DATABASE appdb OWNER appuser;"

4.3 — Copy and restore the dump

From your local machine, upload the dump:

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

Back on the VPS, restore it:

sudo -u postgres pg_restore --no-owner --role=appuser -d appdb /home/deploy/latest.dump

Expected: no output on success (errors print to stderr).


5. Deploy the Application

5.1 — Copy code to the VPS

From your local machine:

rsync -avz --exclude='.git' ./myapp/ deploy@YOUR_VPS_IP:/home/deploy/myapp/

5.2 — Transfer environment variables

scp .env.heroku deploy@YOUR_VPS_IP:/home/deploy/myapp/.env

On the VPS, update DATABASE_URL to point at the local Postgres instance:

sed -i "s|DATABASE_URL=.*|DATABASE_URL='postgres://appuser:strongpassword@localhost:5432/appdb'|" /home/deploy/myapp/.env

5.3 — Install dependencies

Node.js:

cd /home/deploy/myapp && npm ci --omit=dev

Python:

cd /home/deploy/myapp
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

5.4 — Run database migrations

Node.js (example with Knex or Prisma):

cd /home/deploy/myapp && npx prisma migrate deploy

Python (Django):

cd /home/deploy/myapp && source .venv/bin/activate && python manage.py migrate

6. Configure a Process Manager

Heroku's dyno manager kept your process alive. On a VPS, use PM2 (Node.js) or systemd (Python).

Node.js — PM2

Install PM2 globally:

npm install -g pm2

Start your app and save the process list:

cd /home/deploy/myapp
pm2 start npm --name myapp -- start
pm2 save
pm2 startup systemd -u deploy --hp /home/deploy

Run the sudo env ... command PM2 prints — it registers a systemd unit so the app survives reboots.

Python — systemd unit

Create /etc/systemd/system/myapp.service:

[Unit]
Description=myapp gunicorn
After=network.target

[Service]
User=deploy
WorkingDirectory=/home/deploy/myapp
EnvironmentFile=/home/deploy/myapp/.env
ExecStart=/home/deploy/myapp/.venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8000 \
    myapp.wsgi:application
Restart=always

[Install]
WantedBy=multi-user.target

Enable and start it:

systemctl daemon-reload
systemctl enable --now myapp

7. Configure Nginx and SSL

7.1 — Create an Nginx server block

Create /etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}

For Node.js apps, change proxy_pass to http://127.0.0.1:3000 (or whatever port your app binds to).

7.2 — Enable the site

ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
nginx -t
systemctl reload nginx

Expected from nginx -t:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

7.3 — Issue an SSL certificate

certbot --nginx -d yourdomain.com -d www.yourdomain.com --non-interactive --agree-tos -m you@example.com

Certbot rewrites the Nginx block to redirect HTTP to HTTPS and installs the certificate. Auto-renewal is already configured via the certbot.timer systemd unit installed with the package.


Verify It Works

Run each check in order. A failure stops you here — don't proceed until it passes.

1. App process is running:

# Node.js
pm2 list

# Python
systemctl status myapp

Expected: status online (PM2) or active (running) (systemd).

2. App responds locally:

curl -I http://127.0.0.1:8000

Expected: HTTP/1.1 200 OK (or a redirect — anything except Connection refused).

3. Nginx proxies correctly:

curl -I http://yourdomain.com

Expected: HTTP/1.1 301 redirect to HTTPS, then HTTP/2 200 on the HTTPS URL.

4. SSL certificate is valid:

curl -vI https://yourdomain.com 2>&1 | grep 'SSL certificate verify'

Expected: SSL certificate verify ok.

5. Database connection works:

sudo -u postgres psql -d appdb -c "\dt"

Expected: your application's tables listed.


Troubleshooting

Connection refused on port 8000/3000 The app process is not running or bound to the wrong interface. Check pm2 logs myapp or journalctl -u myapp -n 50. Confirm the app listens on 127.0.0.1, not 0.0.0.0:PORT behind a firewall rule that blocks it.

Nginx returns 502 Bad Gateway Nginx is running but can't reach the upstream. Verify the proxy_pass port matches what the app actually binds to. Run ss -tlnp | grep LISTEN to see active listeners.

pg_restore reports role errors The dump contains role names from Heroku. The --no-owner flag suppresses most errors; --role=appuser reassigns objects. If errors persist, inspect them with pg_restore --list latest.dump.

Certbot fails with DNS problem Your A record hasn't propagated yet. Run dig +short yourdomain.com and confirm it returns your VPS IP before retrying.

App crashes on startup with missing env vars The .env file path in the systemd EnvironmentFile directive must be absolute. Confirm the file exists at /home/deploy/myapp/.env and has no syntax errors (no export prefix — plain KEY=value per line).

PM2 process doesn't survive reboot You must run the sudo env PATH=... pm2 startup ... command that PM2 printed, then run pm2 save again after starting your app.


Next Steps

You've completed the migrate from Heroku to VPS process. The app is live, the database is local, and SSL is active. From here:

  • Set up automated backups — run pg_dump via a daily cron job and ship the output to Backblaze B2 or an S3-compatible bucket.
  • Add a deploy script — a 20-line bash script that runs git pull, installs deps, migrates, and calls pm2 reload myapp gives you the same one-command deploy Heroku offered.
  • Monitor the process — install Netdata (free, single command) to get CPU, memory, and disk graphs without paying for an APM tool. You can also explore optimasi performa development environment to ensure your local setup mirrors production performance characteristics.
  • Harden SSH — disable password authentication, move SSH to a non-standard port, and configure fail2ban to block brute-force attempts. For added security, consider implementing two-factor authentication on your VPS access.

A single Hetzner CAX11 ARM instance at €3.79/month runs this stack comfortably for most early-stage apps. That's the budget reality Heroku's free tier removal forced on indie hackers — and it's genuinely better once you own the machine.