Escaping Heroku
Heroku made deployment feel like magic: git push heroku main and you were live. That magic now costs real money — the cheapest usable setup with a database and a worker runs well over $25/month, and it climbs fast. The good news is that everything Heroku gave you maps cleanly onto a VPS you fully control for a fraction of the price. Here’s the migration.
What Heroku actually gave you (and its VPS equivalent)
| Heroku concept | What it did | Self-hosted equivalent |
|---|---|---|
| Procfile | Declared your process types | An oxfile.toml |
web dyno | Ran your web server | A supervised web process |
worker dyno | Ran background jobs | A supervised worker process |
| Dyno restart | Kept processes alive | Crash recovery |
| Config vars | Env-based config | An env file |
| Postgres add-on | Managed database | Postgres or SQLite on the box |
| Router + TLS | HTTPS, routing | Caddy / nginx |
git push heroku | Build + deploy | A git webhook |
Every row on the right is something you already know how to do or can learn in an afternoon. There’s no missing capability — just a few pieces you assemble yourself.
Step 1: Translate your Procfile
A Heroku Procfile like:
web: node dist/server.js
worker: node dist/worker.js
release: node dist/migrate.js becomes an oxfile.toml:
[processes.web]
command = "node dist/server.js"
restart_on_exit = true
[processes.web.health_check]
endpoint = "http://localhost:3000/health"
interval_secs = 30
[processes.worker]
command = "node dist/worker.js"
restart_on_exit = true The release phase (migrations) becomes a step in your deploy script, run before the reload — not a managed process.
Step 2: Provision the box
A $5–10/month VPS (1–2 GB RAM) comfortably runs a small app plus Postgres. Basic hardening on day one:
adduser deploy && usermod -aG sudo deploy # don't run as root
ufw allow OpenSSH && ufw allow 80,443/tcp && ufw enable Run your app as a non-root user from the start. Install Node (or your runtime), your database, and Oxmgr (npm i -g oxmgr).
Step 3: Move the database
This is the only genuinely careful step. For a small app:
- Put your app in maintenance mode (or accept a short window).
pg_dumpfrom Heroku Postgres,pg_restoreinto your VPS Postgres.- Update
DATABASE_URLin your env file.
For zero-downtime you can set up logical replication, but for most apps migrating off Heroku, a few minutes of downtime at 3 a.m. is entirely acceptable and far simpler. Many small apps also discover they can run on SQLite once they’re on a single box — no separate DB process at all.
Step 4: Config vars → env file
Export your Heroku config and drop it into an env file:
heroku config -s --app your-app > .env.production Load it via env_file in your process config, and keep it out of git. See managing secrets properly.
Step 5: TLS + routing
Caddy gives you automatic HTTPS in three lines:
yourapp.com {
reverse_proxy 127.0.0.1:3000
} That replaces Heroku’s router and SSL entirely. The nginx + SSL guide covers the nginx path if you prefer it.
Step 6: Reproduce git push deploys
You’ll miss git push heroku main, so recreate it. A git webhook auto-deploy listens for pushes, pulls, builds, runs migrations, and oxmgr reload web worker for a zero-downtime rollover. Now git push origin main deploys — same ergonomics, your infrastructure.
Step 7: Replace the dashboard
Heroku’s dashboard showed dyno status, logs, and restarts. You get the same three things: oxmgr ls --json for status (scriptable), per-process log files, and the event bus for real-time restarts and crashes — plus Slack alerts that Heroku never gave you for free.
The honest trade
You’re trading convenience for cost and control. Heroku handles the box; you handle it now — reboots, upgrades, backups. But that’s a few hours of setup and maybe an hour a month of maintenance, versus a bill that’s often 5–10× a VPS. For a side project or a small SaaS, self-hosting is frequently the difference between “the hosting eats the revenue” and “the hosting is a rounding error.” And unlike Heroku, nothing about a plain VPS + process manager stack will surprise you with a pricing change.
If Render or Railway (not Heroku) is your current platform, the same migration applies — see Railway & Render are getting expensive.