Migrate from Heroku
Last updated: June 23, 2026
Heroku made deploying easy, and the dyno model made it expensive. Once you're past a hobby app, you're paying for a web dyno, one or more worker dynos, a Postgres add-on, a Redis add-on, and maybe Scheduler — each its own line on the bill, none of them a server you can size yourself.
Here's the reframe that makes the move tractable: a Heroku app is just processes, config, and a database. Your Procfile already lists the processes. Your config vars are environment variables. Your data lives in Postgres (and maybe Redis). None of it is Heroku-specific magic — it's a normal Linux deployment wearing a platform.
This recipe hands that translation to your AI assistant. With the American Cloud MCP server connected, your assistant reads your Procfile, turns each line into a systemd service on a VM you own, installs the runtime your app needs, migrates your Postgres data with pg_dump/pg_restore, points your domain at the new server, and turns on HTTPS — all from prompts you paste in. On American Cloud, the same workload that forced several dynos usually fits on one VM you can resize as you grow.
Why Claude Code for this
Any MCP client can create the infrastructure. But migrating an app also means running commands on the server — SSH in, install the runtime, restore a database dump, configure services. Claude Code combines the American Cloud tools with your terminal, so one session can provision the VM, read your local repo, and deploy to the server without switching tools. That's the setup this recipe assumes. Cursor and the other clients work too — you'll just run the SSH steps yourself when the assistant tells you to.
Provisioning and migrating are write operations. You'll need a read-write
API key from
console.americancloud.com/api-keys
and the --allow-writes flag on the MCP server. See the
overview for setup and safety details. Start read-only
while you take inventory and plan, then switch the key when you're ready to
build.
How a Heroku app maps to a VM
Every piece of a classic Heroku app has a direct, well-understood equivalent on a server. Your assistant works from this mapping:
| On Heroku | On your American Cloud VM |
|---|---|
Web dyno (web: in Procfile) | A systemd service, with nginx in front as a reverse proxy |
Worker dyno (worker: in Procfile) | A systemd service that restarts on crash and on boot |
| Heroku Postgres | PostgreSQL installed on the VM, migrated with pg_dump / pg_restore |
| Heroku Redis | Redis installed on the same VM (or a separate one) |
| Heroku Scheduler | cron jobs on the VM |
| Config vars | An environment file on the server that every service reads |
| Buildpacks | The assistant installs the runtime directly — it reads your Gemfile, package.json, or requirements.txt to know what's needed |
| Custom domain + Automated Certificate Management | DNS records via the hosted DNS tools plus a Let's Encrypt certificate from certbot |
The shape that fits this best is a classic app — Rails, Node, or Python — with a web process, one or more worker processes, Postgres, and maybe Redis. That's exactly the workload Heroku's dyno pricing punishes, and exactly the workload one VM handles comfortably.
Before you start
- Your app in a local git repo, with its
Procfile(andapp.jsonif you have one). - Access to your Heroku app so you can run
heroku pg:backups:capture/pg:dumpand read your config var names. - A domain you control, with the ability to point its DNS at American Cloud.
- The MCP server connected to your assistant with a read-write key and
--allow-writes.
Phase 1 — inventory your app (read-only)
Before anything is created or billed, have your assistant build a complete picture of what it's migrating. This phase is read-only on both sides — it reads your repo and lists American Cloud options without touching your Heroku app or your account.
Open your project in Claude Code and paste:
This is a Heroku app I want to migrate to a server I own. Take inventory
first — don't create anything yet:
1. Read the Procfile and list every process type (web, worker, release,
anything else) and the exact command each one runs.
2. Detect the runtime and version: check for a Gemfile / package.json /
requirements.txt / runtime.txt and tell me the language and version the
app expects, plus any system packages it implies (image libs, a JS build
step, etc.).
3. If there's an app.json, read it and list the addons and env declarations
it names.
4. List the NAMES of the config vars the app reads from the environment
(grep the code for env lookups). Do NOT ask me for their values — I'll set
those on the server myself.
5. Tell me which backing services this app needs: Postgres? Redis? anything
else?
Then summarize it back to me as a migration target: how many services I'll
run, what runtime to install, and what to install for the database and cache.What your assistant will do
This phase is your assistant reading files — your local repo and, through the MCP server, your American Cloud options:
- Read the
Procfile. Each line becomes one service to recreate. A typical Rails app hasweb: bundle exec puma -C config/puma.rbandworker: bundle exec sidekiq— two services. Arelease:line is a one-shot migration command to run on each deploy, not a long-running service. - Identify the runtime from the manifest. A
Gemfileplus.ruby-versionmeans Ruby;package.jsonmeans Node;requirements.txtorpyproject.tomlmeans Python. This is what replaces the buildpack — instead of Heroku auto-detecting, your assistant installs that exact runtime on the VM. - Read
app.jsonif present. Heroku'sapp.jsonoften declares the addons (heroku-postgresql,heroku-redis) and the env vars the app expects — a ready-made checklist of what to stand up. - Collect config var names. It greps your code for environment lookups (
ENV[...],process.env.*,os.environ[...]) so the server's environment file has every key the app reads — without ever needing the secret values.
Never paste secret config var values into the chat. Your assistant only
needs the names of your config vars to build the environment file's
structure. Put the real values — database passwords, API keys, SECRET_KEY_BASE
— directly into the server's env file over SSH, where they stay on the box you
own. Treat them like any other secret: not in the conversation, not in the repo.
Phase 2 — plan and price (read-only)
With the inventory in hand, ask for a sized plan and a cost estimate. This is still read-only — cost estimates create nothing.
Based on that inventory, plan the American Cloud side. List the available
regions and current Ubuntu images and the VM packages. Recommend ONE VM size
that comfortably runs the web process, the worker process, PostgreSQL, and
Redis together, with headroom for my database size. Show me the monthly cost
estimate before I approve anything, so I can compare it to my Heroku bill.What your assistant will do:
- Call
list_regions,list_images(filtered to Ubuntu), andlist_vm_packagesto find a region near you, a current Ubuntu LTS image, and a compute tier whose CPU/memory/disk limits fit your workload. - Call
get_cost_estimate_vmwith that exact region, package, size, and image, and show you the hourly and monthly numbers before creating anything. Nothing is billed yet. - Hand you a side-by-side: one VM running everything versus the stack of Heroku dynos and add-ons you're paying for today. You bring your Heroku bill; the assistant brings the American Cloud estimate.
Because your web, worker, database, and cache all share one machine, you're sizing one server instead of renting four metered products. Need more later? scale_vm resizes it in place.
Phase 3 — provision and deploy
Now the writes begin. This is the build prompt — paste it after you've approved the plan and cost from Phase 2. Read each step's output before approving the next.
Go ahead and build the new home for this app. Walk through it step by step
and wait for my confirmation on anything that costs money:
1. Check whether I have an SSH key registered; if not, create one and tell me
where the private key is saved.
2. Create the Ubuntu VM we sized, on an isolated network, with that SSH key,
and open inbound ports 22, 80, and 443.
3. Over SSH, install the runtime from my manifest (the exact language version),
plus the system packages the app needs, PostgreSQL, and Redis. Keep both
PostgreSQL and Redis listening on localhost only.
4. Clone this repo onto the VM and install dependencies.
5. Create the app's environment file from the config var NAMES we found, with
placeholder values — I'll fill in the real secrets myself afterward. Point
DATABASE_URL and REDIS_URL at the local PostgreSQL and Redis.
6. Turn each Procfile process into its own systemd service that reads that
environment file, restarts on crash, and starts on boot. Put nginx in front
of the web service as a reverse proxy on port 80.
7. Turn any Heroku Scheduler jobs into cron entries.
Don't start the app against real data yet — we'll migrate the database next.What your assistant will do:
- Sort out the SSH key. It calls
list_ssh_keys; if nothing fits,create_ssh_keygenerates a pair — the private key is returned once and never stored, so the assistant saves it locally and sets the right permissions. It needs this key to install on the VM and to SSH in afterward. - Create the server. On your go-ahead,
create_vmprovisions the Ubuntu VM. The same call carriesnetworkAccessto open ports 22, 80, and 443 on the network's public IP, andkeypairsto install your key. The VM provisions asynchronously, so the assistant pollsget_vmuntil its status reachesSTARTEDand it has a public IP. - Install the stack over SSH. It installs the exact runtime your manifest declares (this is the buildpack's job, done explicitly), the system packages your app needs, then PostgreSQL and Redis — both bound to
localhostso neither is exposed to the internet. Nothing new opens on the firewall for them. - Lay down the environment file. It writes one env file keyed by every config var name from Phase 1, with placeholders, and sets
DATABASE_URL/REDIS_URLto the local services. You fill in the real secret values over SSH after this step. - Recreate the processes. Each
Procfileline becomes asystemdunit reading that env file —webandworkereach get one, restarting on crash and on boot. nginx reverse-proxies the public ports to the web process. Arelease:command becomes the migration step your deploy runs. - Schedule the jobs. Heroku Scheduler entries become
cronjobs on the VM.
Tell the assistant to explain each step before it runs it if you want to follow along — "narrate what you're about to do and why." Destructive operations are flagged regardless, and clients that support confirmations prompt you before anything irreversible.
Phase 4 — migrate the data
This is the only phase with real downtime, so it's deliberately last and deliberately careful. The goal is a clean cutover: freeze writes on Heroku, take a final dump, restore it on the VM, and verify nothing was lost.
Now migrate the database with minimal downtime:
1. Put the Heroku app in maintenance mode so nothing writes to the database
while we copy it.
2. Take a final pg_dump of the Heroku Postgres database (custom format).
3. Copy the dump to the VM and pg_restore it into the local PostgreSQL.
4. Run the app's release/migration command against the restored database.
5. Verify the migration: compare row counts on the main tables between the
Heroku database and the restored one, and flag any mismatch.
6. Start the systemd services and confirm the app responds locally on the VM
(curl the health endpoint through nginx).
Report the row-count comparison and the local health check before we touch DNS.What your assistant will do:
- Freeze writes. It enables Heroku maintenance mode so the live app stops writing mid-copy — the dump is a consistent point-in-time snapshot.
- Take the final dump. A
pg_dumpin custom format captures the schema and data. - Restore on the VM. It copies the dump over and runs
pg_restoreinto the local PostgreSQL you installed in Phase 3. - Run migrations. Your
release:command (e.g.rails db:migrate) runs against the restored database so the schema matches the code being deployed. - Verify counts. It compares row counts on your key tables between source and target and surfaces any difference — your proof the data arrived intact before you commit to the cutover.
- Smoke-test locally. It starts the services and curls the app through nginx on the VM itself, confirming the stack works end to end before any public traffic hits it.
Redis is usually a cache or a job queue, not a system of record, so it rarely needs a data copy — a fresh Redis is fine, and the worker repopulates it. If yours holds data you can't lose, tell the assistant and it'll dump and restore Redis the same way.
Phase 5 — DNS cutover and HTTPS
With the app verified on the VM, point your domain at it and switch on TLS.
Cut my domain {your-domain.com} over to the new VM:
1. Check whether the domain is already a hosted DNS zone here; if not, create
it and tell me the nameservers to set at my registrar.
2. Lower the TTL first if it's high, then point the A record at the VM's
public IP.
3. Once DNS resolves to the VM, provision a Let's Encrypt certificate with
certbot and switch nginx to HTTPS with auto-renewal. Leave the Heroku app
in maintenance mode — anyone still on cached DNS should see the maintenance
page, not write to the old database.
Tell me when https://{your-domain.com} is live and served from the new server.What your assistant will do:
- Call
list_dns_zones; if your domain isn't hosted here,create_dns_zoneadds it and the assistant shows you the American Cloud nameservers to set at your registrar. - Call
create_dns_recordto add (or update) theArecord pointing your domain at the VM's public IP. Lowering the TTL beforehand shortens the cutover window. - Once DNS resolves to the VM, run certbot for a Let's Encrypt certificate, reconfigure nginx for port 443, and enable automatic renewal.
The Heroku app stays in maintenance mode from the final dump until you decommission it. While DNS propagates, some visitors still resolve to Heroku — in maintenance mode they see a holding page instead of silently reading and writing the old database. Switching it back on during propagation is how migrations lose data.
DNS changes take time to propagate — from a few minutes to a couple of hours, depending on your registrar and the record's TTL. Ask your assistant to "check what {your-domain.com} currently resolves to and tell me when it points at the VM's IP." certbot also needs DNS to resolve to the VM before it can issue the certificate.
Phase 6 — verify, then decommission
Run on the new server for a day or two before you delete anything on Heroku. Keep the Heroku app around — it's your rollback if something surfaces under real traffic.
The app's been live on the VM for a couple of days and looks healthy. Walk me
through decommissioning Heroku safely: confirm the VM is the only thing serving
the domain, remind me to download a final Heroku database backup to keep
offline, and give me the Heroku CLI commands to scale the dynos to zero and
then delete the app and its add-ons once I'm sure.The assistant can confirm the American Cloud side — the VM is STARTED, the services are running, the certificate is valid — but the actual Heroku teardown stays in your hands: download one last backup to keep offline, scale the dynos to zero, then delete the app and its add-ons when you're confident. From here, every future deploy is a push-and-restart on a server you own.
Follow-up: a one-prompt deploy command
The migration is the hard part. Make every future deploy trivial:
Set up a deploy script in this repo that ships updates to the VM: push the
latest committed code over SSH, install dependencies, run the release/migration
command, and restart the web and worker systemd services. Add a "deploy" entry
to my package.json or a Rake task, and document the one command I run from now on.After this, shipping a change is "run the deploy script" — or just "deploy the latest" and the assistant runs it.
Troubleshooting
A service won't start after migration. Ask your assistant to "show me the journalctl logs for the web (or worker) service and tell me what's failing." The usual culprit is a missing or placeholder value in the environment file — confirm every config var name from Phase 1 has its real value set on the server.
The app can't reach the database. Have the assistant confirm PostgreSQL is running and that DATABASE_URL in the env file points at localhost with the right database and user. Since Postgres listens only on localhost, nothing on the firewall needs to change — connections stay on the box.
The site is unreachable on 80 or 443. Ask your assistant to "list the firewall rules on the VM's public IP and confirm 80 and 443 are open." It can call list_firewall_rules and add any missing rule with create_firewall_rule. Have it also check the VM is STARTED with get_vm and that nginx and the web service are both running.
SSH connection refused. Confirm port 22 is open (same list_firewall_rules check) and that the private key from the create_ssh_key step is the one your assistant is using. If the key was lost, the assistant can reset access with reset_vm_password or open a browser console session with create_vm_console.
Row counts don't match in Phase 4. Don't cut DNS over. Ask the assistant to re-run the dump and restore — the most common cause is a write that slipped in before maintenance mode took effect. Re-freezing and re-dumping gives a clean snapshot.
Next steps
- Things to try with the MCP server — more prompt ideas across compute, networking, and DNS
- Use American Cloud with Claude Code — the provision-and-deploy client setup
- Deploy a Next.js app — the same VM-plus-systemd pattern, start to finish
- Object storage with your AI assistant — move user uploads and backups off the dyno filesystem and store them durably
- Run Kubernetes with your AI assistant — when one VM isn't enough
- Teach your AI agent to deploy here — add one file to your repo and your agent knows this whole playbook