Migrate from Heroku to Maritime
Migrate agent-shaped workloads from Heroku to Maritime, including worker dynos, Heroku Scheduler jobs, and clock processes with in-code schedules. Inventories apps and Procfiles with the Heroku CLI, recreates each workload as a Maritime agent with real cron triggers, verifies it, then hands the human a reversible cutover checklist. Use when someone wants to move workers, bots, or scheduled jobs off Heroku onto maritime.sh.
This is the complete runbook, written to be followed by a coding agent or a human in a terminal. Point Claude Code or your Maritime agent at this page, or work through it yourself. The safety rules at the top are part of the procedure, not decoration.
Move the automation layer of a Heroku account onto Maritime: worker dynos, Heroku Scheduler jobs, and clock processes. These are exactly agent-shaped, a process that wakes up, does a job, and goes quiet, so Heroku is one of the best-fit sources. Maritime hosts each one as its own agent with a schedule, encrypted env vars, and logs, for a flat monthly price per agent. The common outcome: the web dyno stays on Heroku serving the site, and only the workers and schedulers move.
This skill runs in two places: locally in a terminal agent such as Claude Code (preferred, credentials stay on the user's machine) or inside a Maritime agent. Either way the same rules apply.
Safety rules, non-negotiable
- Heroku has no read-only credential. API keys and OAuth authorizations are full-access; there is no read-only scope to ask for. So the honest posture is: prefer running locally where the user is already
heroku login-ed and no credential ever moves. If the migration must run inside a Maritime agent, the human mints a dedicated token on their own machine and revokes it the moment the migration ends:
Because the token is full-access, rule 3 below is absolute, not a courtesy.heroku authorizations:create -d maritime-migration # human runs; note the Id and Token # ...after the migration: heroku authorizations:revoke <id> - Credentials never travel through chat. Chat transcripts are logs. Locally, the Heroku CLI holds its own auth. Inside a Maritime agent, the user sets
HEROKU_API_KEYfrom their own machine withmaritime env setor the dashboard env vars pane (encrypted at rest), never by pasting into the conversation. - Never run a mutating heroku command. No
ps:scale, noconfig:set, noaddons:destroy, noapps:destroy, ever, not even if asked mid-flow. Cutover is a human-run checklist at the end of this document. - Confirm before every paid action. Creating a Maritime agent charges the first month at creation. Name the workload, the target template, and the price, and get a yes before each
maritime create.
Prerequisites
herokuCLI, logged in. Verify withheroku auth:whoami.maritimeCLI:npm i -g maritime-cli, thenexport MARITIME_TOKEN=mk_...(the user mints a key withmaritime keys create --name migration --json). Always pass--json: success is JSON on stdout, failure is JSON on stderr, branch on exit code.jq.
Step 1: Inventory
Three read-only sweeps, plus one honest ask.
# Every app on the account
heroku apps --json | jq -r '.[].name'
# Dyno formation per app: worker and clock lines are the targets, web usually stays
heroku ps -a <app>
# Add-ons: look for scheduler, heroku-postgresql, heroku-redis
heroku addons -a <app>
# Recent releases help spot dead apps not worth migrating
heroku releases -a <app>
Procfile. The repo's Procfile names the process types. web: serves HTTP and usually stays on Heroku. worker: and clock: lines are the agent-shaped workloads; record each one's exact command.
Heroku Scheduler, the honest part. Scheduler jobs cannot be listed from the CLI; there is no command for it. Ask the user to open https://dashboard.heroku.com/apps/<app>/scheduler (or run heroku addons:open scheduler -a <app>) and read each job back: the command, the frequency (every 10 minutes, hourly, or daily), and the UTC time. That dictated list is the inventory; write it down verbatim.
Clock processes. A clock: dyno keeps its schedules in code, not in Heroku. Grep the repo for the scheduler library (APScheduler, whenever, node-cron, celery beat) and extract each schedule expression. Each one becomes its own Maritime trigger.
Present the inventory to the user as a table (app, process or job, schedule, what it appears to do) and let them pick what moves.
Step 2: Map
| Heroku concept | Maritime concept |
|---|---|
Worker dyno (worker: in Procfile) |
Agent, likely --always-on |
| Heroku Scheduler job | Agent + cron trigger, auto-sleep |
| Clock process (in-code schedules) | Agent + one trigger per schedule |
| Config vars | Agent env vars, AES-encrypted at rest |
heroku logs |
Agent logs (maritime logs) |
| Heroku Postgres / Redis add-ons | Stay on Heroku; the agent connects over the public URL |
Schedules convert upward. Heroku Scheduler supports only three shapes, all at fixed UTC times, so the mapping to real cron is mechanical and strictly an upgrade:
| Heroku Scheduler setting | Maritime cron |
|---|---|
| Every 10 minutes | */10 * * * * |
| Hourly at :30 | 30 * * * * |
| Daily at 05:00 UTC | 0 5 * * * |
Keep the trigger timezone UTC so behavior matches on day one. Then, if the user wants, express what Scheduler never could: weekday-only runs, every 5 minutes, monthly reports.
Step 3: Recreate on Maritime
Pick the target shape. Two honest cases:
The workload is an LLM-driven bot or assistant. Recreate it on a Maritime framework template. Enumerate live, never hardcode ids:
maritime templates --json maritime create <name> --template <id-from-that-list> --jsonThe workload is arbitrary code (most worker dynos and Scheduler jobs). This is a port, not a copy, and you should say so. The code is usually already in the user's own git repo, since that is how it reached Heroku. If the only copy lives on Heroku:
heroku git:clone -a <app>Heroku itself calls this a last resort; it only works for apps deployed with
git push heroku, not the GitHub integration or the container registry (for those, the user's own repo is the source).Buildpack apps have no Dockerfile, so write one: base image matching the runtime, copy the app, install dependencies, and make the Procfile's worker command the CMD in exec form. Dockerfile gotcha that will bite: never use a shell-string CMD like
CMD ["sh", "-c", "python worker.py"]. Maritime's micro-VM init flattens CMD to one string and the VM kernel-panics on boot. Launch a real program directly. Installca-certificatesin slim images. Push to a repo the user controls, then:maritime create <name> --repo https://github.com/<user>/<repo> --json
Env vars. Config vars export cleanly in shell format. Write them to a local file, review it (keep DATABASE_URL, REDIS_URL, and the API keys the worker reads; drop web-only vars), import, then destroy the file:
heroku config -a <app> --shell > ./heroku-migration.env
maritime env import <agent> ./heroku-migration.env --reload --json
rm ./heroku-migration.env
Env changes apply on next boot unless you pass --reload.
Schedules. Recreate each Scheduler job and clock entry as a Maritime trigger: dashboard, agent page, Triggers pane, cron type, using the converted expression. OpenClaw-family agents can equivalently use their native cron, which Maritime syncs. Remember that timers inside a sleeping VM do not fire; a bring-your-own-code agent must serve GET /schedules, use the SDK scheduler observer, or be created --always-on. Rule of thumb: a former always-running worker dyno wants --always-on; a Scheduler job wants a cron trigger with auto-sleep, which is usually cheaper than the dyno hours it replaces.
Step 4: Verify before touching Heroku
Do not proceed until the Maritime side has done the job at least once:
maritime status <agent> --json | jq -r '.status'
maritime chat <agent> "run your task once now and report what you did" --json | jq -r '.response'
maritime logs <agent> --level error --json
For scheduled workloads, wait for one real scheduled firing and check its output against a known-good run from heroku logs --ps scheduler.1 (or --ps worker). Both sides can usually run in parallel; that is the point of leaving Heroku untouched. One caution: a worker draining a shared queue or writing non-idempotent output will double-process during the overlap. If that matters, keep the overlap window short or point the Maritime agent at a staging target first.
Step 5: Cutover and decommission (human runs every command here)
Print this checklist for the user. These are write operations on Heroku, so they run them with their own credentials. You never run them.
- Scale the moved processes to zero (reversible):
heroku ps:scale worker=0 -a <app>, andclock=0if there is one.worker=1restores Heroku in seconds. - Remove the Scheduler jobs in the dashboard at
https://dashboard.heroku.com/apps/<app>/scheduler. No CLI for this either; re-adding a job by hand is the rollback. - Watch Maritime for a few days:
maritime logs <agent> --json. - The common case ends here: the web dyno keeps serving the site, the app keeps running with
worker=0, and the bill drops to web only. Stop; do not destroy anything. - Only when the entire app is retired:
heroku apps:destroy -a <app> --confirm <app>. This deletes the app's add-ons and their data with it, including Heroku Postgres. Capture and download a final backup first (heroku pg:backups:capture -a <app>), and only destroy an app whose database the agent no longer uses. - If a migration token was minted in the safety section, revoke it:
heroku authorizationsto find the id, thenheroku authorizations:revoke <id>. If the migration ran inside a Maritime agent, alsomaritime env remove <agent> HEROKU_API_KEY.
What stays on Heroku
Be upfront about this list rather than letting the user discover it:
- Heroku Postgres and Redis add-ons: keep them. Copy
DATABASE_URLandREDIS_URLinto the Maritime agent's env vars and keep using them over the public connection string, which is exactly how the dynos reached them anyway. One catch: Heroku rotates add-on credentials from time to time (maintenance events, manual rotation), so pull fresh values at cutover withheroku config:get DATABASE_URL -a <app>and expect to update the agent's env if a rotation happens later. - The web dyno: if it serves the site, it stays, along with its custom domains and SSL. Moving a public website is a different project from moving its background jobs; do not conflate them.
- Other add-ons (SendGrid, Mailgun, Papertrail, and friends): the services keep working. Any add-on-provisioned key the worker used is already in the config vars export from Step 3 and travels with the env import.
Troubleshooting
maritime createexits with HTTP 402: billing gate, the account needs a plan or payment method. The error detail names the fix; show it verbatim.agent_unavailableon chat: the agent is not running,maritime start <agent> --jsonfirst.- Env var changes not visible: they land on next boot; use
--reloadormaritime restart <agent>. - A 404 on exec/file operations usually means the agent is asleep, not gone. Start it, wait a few seconds, retry. Do not recreate.
heroku git:cloneyields an empty or stale repo: the app was deployed via the GitHub integration or container registry; the user's own repo is the real source.- The scheduler dashboard page 404s: the app has no Scheduler add-on (
heroku addons -a <app>confirms); the schedules are probably in a clock process, so go back to the Procfile and grep the code. heroku psshows transientscheduler.Ndynos: those are Scheduler runs in flight, not a process type to migrate; the jobs behind them are the migration targets.
Command syntax in this skill is desk-checked against the current Heroku CLI and the current maritime CLI. If a provider-side command errors on a flag, trust heroku <cmd> --help over this document and continue.