TL;DR

  • What you get: your Heroku app (web, worker, release phase, Postgres, Redis) running on Hubfly space, with a cutover plan that keeps downtime to the length of one database restore.
  • Who it's for: teams on Heroku Basic or Eco dynos with Heroku Postgres Essential and Key-Value Store.
  • Time needed: about an hour for the dry run, then a short maintenance window.
  • Cost: the example app below drops from $22 a month on Heroku to about $8.34 on Hubfly space.

Your app has run on Heroku for years. It works. But the bill keeps growing, and since February Heroku is in a "sustaining engineering model": it stays supported, with the focus on stability rather than new features. That's a fine reason to stay put, and an equally fine reason to plan your exit before you're forced to.

This guide moves a typical Heroku app, one with a Procfile, a worker, a release command, Postgres and Redis, to Hubfly space. You'll turn the Procfile into a Compose file, copy your config vars, move the database with a real backup, and switch DNS with a rollback plan. Every step says why it matters, so you can adapt it to your own app.

Try it on Hubfly space: the free tier gives you 360 compute hours a month on the Shared tier with no card. The example in this guide runs on Dedicated containers and a managed database, about $8.34 a month.

Should you move at all?

Be honest about this first. Heroku still does some things better than Hubfly space today:

  • Scheduled backups and continuous protection. On Heroku, one command (heroku pg:backups:schedule DATABASE_URL --at '02:00 UTC' --app your-app) gives you daily backups kept for 7 days on Essential plans, and Standard plans and up add Continuous Protection. Hubfly space Managed Databases have no automatic backups or point-in-time restore yet (the dashboard says "Coming soon"), so you must schedule your own dumps.
  • A built-in scheduler. Heroku Scheduler runs one-off jobs on a timer. Hubfly space doesn't document a built-in scheduler, so you'll run a small clock process instead (shown below).
  • Native Procfile and buildpacks. Heroku reads your Procfile directly. On Hubfly space, you describe processes in a Compose file. It's a one-time translation, but it is work.
  • Review apps, pipelines and the add-on marketplace. If your team leans on these, count them as real migration cost.

Move if the bill matters, if you want everything (apps, databases, storage, mail) in one place, or if you'd rather own a standard Compose file than a platform-specific setup. If you want to compare more destinations first, read Best Heroku alternatives in 2026.

What it costs: the same app on both platforms

The example app is a small online shop API: one web process, one background worker, a release command that runs migrations, Postgres and Redis.

Part Heroku (prices from heroku.com/pricing, 4 Nov 2026) Hubfly space
Web Basic dyno, 0.5 GB: $7 Dedicated 0.5 vCPU, 512 MB, 4 GB disk: $2.23
Worker Basic dyno, 0.5 GB: $7 Dedicated 0.5 vCPU, 512 MB, 4 GB disk: $2.23
Release command Runs in a one-off dyno during each release Compose job that runs for a few seconds per deploy (the Stack Composer estimate counts it as up to $2.23)
Postgres Essential-0 (1 GB disk): $5 Managed PostgreSQL Nano (0.25 vCPU, 512 MiB, 10 GiB): $2.02
Redis Key-Value Store Mini (25 MB): $3 Redis container, 0.5 vCPU, 256 MB, 1 GB disk, 1 GB volume: $1.85
Total per month $22 about $8.34

The Hubfly space numbers come from the published rates: $0.004 per vCPU-hour, $0.002 per GB-hour of RAM, $0.00001536 per GB-hour of storage, 730 hours a month.

  • Web or worker: 0.5 × 0.004 × 730 = $1.46 CPU, plus 0.5 × 0.002 × 730 = $0.73 RAM, plus 4 × 0.00001536 × 730 = $0.04 disk. Total $2.23.
  • Redis: $1.46 CPU, plus 0.25 × 0.002 × 730 = $0.37 RAM, plus 1 × 0.00001536 × 730 = $0.01 disk, plus a 1 GB Balanced volume at 1 × 0.00002304 × 730 = $0.02. Total $1.85.
  • Postgres Nano: 0.25 × 0.004 × 730 = $0.73 CPU, plus 0.5 × 0.002 × 730 = $0.73 RAM, plus a 30% management fee on that compute ($0.44), plus 11 GiB of storage (10 GiB data and a 1 GiB system disk) at $0.12. Total $2.02.
  • Sum: 2.2349 + 2.2349 + 1.8530 + 2.0213 = $8.34.

The Stack Composer shows $8.54 for the four Compose services before the Redis volume is added. It counts the release job as if it ran all month, and it leaves out the managed database, which is priced on its own create page. In practice the job runs for a few seconds per deploy.

Two fair notes on this table. Heroku's Basic dyno includes a share of CPU described as "1x-4x" compute, so the CPU isn't a like-for-like match. And Heroku's Key-Value Store Mini is a managed service, while the Hubfly space Redis is a container you run yourself (with a lot more memory). Jobs queued in Heroku's Key-Value Store are not copied over, so you drain the queue during the cutover (Step 6).

The Stack Composer with the four services from the Procfile and a monthly estimate

Step 1: Take inventory of your Heroku app

You can't move what you haven't listed. Run these three commands and save the output:

heroku ps --app your-app # your dyno formation: which processes run, and how many heroku addons --app your-app # Postgres, Key-Value Store, Scheduler, logging, email... heroku config --shell --app your-app > heroku.env # every config var, in KEY=value form 

heroku.env now holds every secret your app uses. Treat it like a password file: don't commit it, and delete it when you're done.

Then open your Procfile. The example app has three lines:

release: node migrate.js web: node web.js worker: node worker.js 

Step 2: Turn the Procfile into a Compose file

Each Procfile line becomes one Compose service. This is the core of the migration, so here's the mapping:

Heroku Hubfly space Compose Why
web: process a service with ports and PORT set Heroku injects $PORT; on Hubfly space you set it yourself
worker: process a service with no ports Workers only talk to Redis and Postgres
release: process a service with restart: "no" It runs once and exits; Hubfly space imports it as a Job
Release must succeed before web boots depends_on: release: condition: service_completed_successfully A failed migration stops the deploy, just like Heroku's release phase
Key-Value Store add-on a redis:7-alpine service Reached on the private network by name
Heroku Postgres add-on Hubfly space Managed PostgreSQL Connection string goes into DATABASE_URL
clock: process or Heroku Scheduler a clock service running a scheduler library Hubfly space doesn't document a built-in scheduler

Here's the full file for the example app. Replace ghcr.io/your-org/shop-api:1.0.0 with your own image, and the DATABASE_URL values with the ones from Step 4:

services: cache: image: redis:7-alpine volumes: - redisdata:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 release: image: ghcr.io/your-org/shop-api:1.0.0 command: ["node", "migrate.js"] restart: "no" environment: DATABASE_URL: "postgres://admin:your-admin-password@your-db-host:your-port/app?sslmode=require" web: image: ghcr.io/your-org/shop-api:1.0.0 command: ["node", "web.js"] environment: PORT: "3000" DATABASE_URL: "postgres://app_user:your-password@your-db-host:your-port/app?sslmode=require" REDIS_URL: "redis://{{service:cache.host}}:6379" ports: - "3000:3000" depends_on: release: condition: service_completed_successfully cache: condition: service_healthy worker: image: ghcr.io/your-org/shop-api:1.0.0 command: ["node", "worker.js"] environment: DATABASE_URL: "postgres://app_user:your-password@your-db-host:your-port/app?sslmode=require" REDIS_URL: "redis://{{service:cache.host}}:6379" depends_on: release: condition: service_completed_successfully cache: condition: service_healthy volumes: redisdata: 

The redisdata volume keeps queued jobs when Redis restarts or redeploys; everything outside a volume is wiped on redeploy. {{service:cache.host}} is a Hubfly space reference: it resolves to the Redis container's private hostname, so you never hard-code an address.

Your web process must listen on 0.0.0.0, not 127.0.0.1. Heroku hides this detail; on any container platform an app bound to localhost returns a 502 Bad Gateway.

Need a Dockerfile? Heroku built your app with a buildpack. Hubfly space can auto-detect common stacks from Git, or you can add a small Dockerfile so the build is the same everywhere. For a Node app:

FROM node:22-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . USER node CMD ["node", "web.js"] 

The command lines in the Compose file override CMD, so one image serves web, worker and release, exactly like one Heroku slug did.

Make your release command safe to run twice. Heroku already asks for this in its release phase guide: migrations should check whether they've already run and take an advisory lock. The same command now runs on every Hubfly space deploy, so the same rule applies:

await client.query("SELECT pg_advisory_lock(42)"); await client.query("CREATE TABLE IF NOT EXISTS orders (id serial PRIMARY KEY, item text NOT NULL, done boolean DEFAULT false)"); await client.query("SELECT pg_advisory_unlock(42)"); 

Replacing Heroku Scheduler. Add a clock service that runs a scheduler library (node-cron, APScheduler, sidekiq-cron) and enqueues jobs for the worker. Heroku documents the same pattern as custom clock processes. A 0.5 vCPU / 256 MB clock container with a 1 GB disk costs about $1.84 a month.

Step 3: Import the stack

  1. In the dashboard, click Create Project, choose Cell and a region. Under Advanced Options, set a monthly budget cap with headroom (for example $50 for this stack). The dashboard says that reaching the cap stops the project's containers and blocks deploys, and they don't restart on their own, so don't set it too tight.
  2. Open the project, click Create → Compose Stack, open the YAML tab, paste your file and click Import YAML.
  3. Check each service. release shows Kind: Job, Restart Policy: Never and Deploy Stage 2, after cache and before web and worker.
  4. Imported services start on the Shared tier, which sleeps when idle and only runs within your free hours. A worker that sleeps stops processing jobs, so switch all four services, release included, to Dedicated under Resources, with Auto Sleep off for web, worker and cache. If the release job can't start, the whole stack rolls back.

Service settings for the release job: Kind Job, Restart Policy Never, Deploy Stage 2

Don't deploy yet. The database comes first.

Step 4: Create the database and copy your config vars

  1. Create the database. Create Project → Managed service → PostgreSQL, size Nano. Provisioning takes a minute or two. The host ends in .db.hubfly.app and the port is the one the Connect page shows (not 5432).
  2. Restrict access. In Network, add your own IP to the allowlist. An empty allowlist means anyone with the password can try to connect.
  3. Create an app user. In Users, create a user with the Read & write preset and store the password; it's shown once.
  4. Copy the config vars. Open heroku.env. Drop the lines Heroku add-ons manage (DATABASE_URL, REDIS_URL and any HEROKU_POSTGRESQL_*), because they now point at new services. Add each remaining variable to your services in the Compose file, or in each container's Configuration → Environment Variables after deploy, with Secret on for anything sensitive.

Put variables on the containers, not only at project level: containers created from a Compose stack don't pick up project-level variables.

Use the admin connection string from the Connect page for the restore in Steps 5 and 6, because restoring creates schemas and tables. Your app then connects as the Read & write user. Give the release service the admin DATABASE_URL too: the restored tables belong to admin, so a migration that alters them as another user fails with must be owner.

Step 5: Rehearse the database move while Heroku stays live

A rehearsal tells you exactly how long your maintenance window must be. Heroku backups use pg_dump's custom format, so standard tools restore them:

heroku pg:backups:capture --app your-app heroku pg:backups:download --app your-app # saves latest.dump time pg_restore --verbose --no-owner --no-acl --exit-on-error \ -d "postgres://admin:your-admin-password@your-db-host:your-port/app?sslmode=require" \ latest.dump 

Use PostgreSQL 18 client tools (pg_restore --version), because Hubfly space creates PostgreSQL 18 by default and older clients can refuse newer formats. --no-owner --no-acl skips Heroku's role names, which don't exist on the new server.

Then compare row counts on both sides for your busiest tables:

SELECT count(*) FROM orders; 

On a local machine, a 100,000-row, 16 MB database dumps and restores in under a second each. Over the internet, download and upload time adds to that, so write down your own timing: that's your downtime.

Now deploy the stack against this restored copy and load a few pages through an Auto Endpoint (under the web container's Network tab). Keep the rehearsal from touching the real world: leave worker and clock stopped, or swap payment, email and webhook keys for test keys. This shows your containers can reach the database and that the app works on the new platform before any user sees it.

When you're done, stop the stack, drop and recreate the target database (or create a fresh one) so the real run starts clean, and delete latest.dump.

Step 6: The cutover

  1. Lower your DNS TTL to 60 seconds a day before, so the switch spreads fast.
  2. Turn on maintenance mode: note your formation from heroku ps, then run heroku maintenance:on --app your-app. Visitors see Heroku's maintenance page, and no new jobs are queued from web requests.
  3. Drain the queue, then stop workers and the clock. Maintenance mode doesn't stop workers or scheduler jobs. Wait until your queue is empty, then run heroku ps:scale worker=0 clock=0 --app your-app (list only the process types you have) and pause your Heroku Scheduler jobs.
  4. Capture, download and restore the final backup with the Step 5 commands.
  5. Deploy the stack in the Stack Composer. The release job migrates, then web and worker start.
  6. Smoke test through the Auto Endpoint: try the paths your users hit most.
  7. Switch DNS. Add your domain in the web container's Custom Domain tab, complete the verification the dashboard shows, and point your DNS at the target it gives you (CNAME for www, ALIAS or ANAME for the apex).

Rollback plan: if something breaks before DNS spreads, point DNS back to Heroku, run heroku maintenance:off --app your-app, restore the formation you recorded in step 2 with heroku ps:scale, and resume your Heroku Scheduler jobs. Writes made on Hubfly space after the switch won't be on Heroku, so decide within the first hour.

Keep Heroku for a week, scaled down. Before destroying the app, make sure no DNS record still points at the Heroku app, then remove the domain (heroku domains:remove www.your-domain.com --app your-app). Heroku warns that a DNS record still pointing at a deleted app invites subdomain takeover.

What can go wrong

  1. Jobs vanish during the switch. A Heroku worker left running keeps pulling jobs and writing results to the old database, so they never show up in the new one. Jobs still sitting in Heroku's queue are lost too. Fix: turn on maintenance mode, let the queue drain, then scale workers and clock to zero before the final backup, and only start the new worker after the restore.
  2. pg_restore fails on heroku_ext. Heroku databases can have extensions in a heroku_ext schema. Fix: connect as the admin user, run CREATE SCHEMA heroku_ext; on the target, then restore again. This is Heroku's own advice for restoring elsewhere.
  3. 502 after deploy. The app listens on 127.0.0.1 or on a port other than PORT. Fix: bind 0.0.0.0 and make sure the endpoint port matches PORT (3000 in the example).

Takeaway

A Heroku app is three things: a Procfile, config vars and add-ons. The Procfile becomes a Compose file with a job for release. Config vars move once by hand. Postgres moves with the same backup file Heroku already makes. Rehearse the restore, stop the workers before the final dump, and keep Heroku as your rollback for a week.

Next reads: How to migrate from Render to Hubfly space and How to move your app from Railway to Hubfly space.

Go further on Hubfly space

Start your migration on Hubfly space: rehearse on a copy first, and switch only when the numbers check out.