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 --versionreturns output) - A domain name pointed at the VPS IP (A record propagated)
- Basic comfort with
psqland 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_dumpvia cron and ship dumps to Backblaze B2 or an S3-compatible bucket. - Set up log rotation: add a
/etc/logrotate.d/myappconfig 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
mainreplaces 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.