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_dumpvia 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 callspm2 reload myappgives 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
fail2banto 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.