← All migration guides

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

  1. 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:
    heroku authorizations:create -d maritime-migration   # human runs; note the Id and Token
    # ...after the migration:
    heroku authorizations:revoke <id>
    
    Because the token is full-access, rule 3 below is absolute, not a courtesy.
  2. 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_KEY from their own machine with maritime env set or the dashboard env vars pane (encrypted at rest), never by pasting into the conversation.
  3. Never run a mutating heroku command. No ps:scale, no config:set, no addons:destroy, no apps:destroy, ever, not even if asked mid-flow. Cutover is a human-run checklist at the end of this document.
  4. 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

  • heroku CLI, logged in. Verify with heroku auth:whoami.
  • maritime CLI: npm i -g maritime-cli, then export MARITIME_TOKEN=mk_... (the user mints a key with maritime 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> --json
    
  • The 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. Install ca-certificates in 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.

  1. Scale the moved processes to zero (reversible): heroku ps:scale worker=0 -a <app>, and clock=0 if there is one. worker=1 restores Heroku in seconds.
  2. 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.
  3. Watch Maritime for a few days: maritime logs <agent> --json.
  4. 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.
  5. 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.
  6. If a migration token was minted in the safety section, revoke it: heroku authorizations to find the id, then heroku authorizations:revoke <id>. If the migration ran inside a Maritime agent, also maritime 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_URL and REDIS_URL into 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 with heroku 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 create exits with HTTP 402: billing gate, the account needs a plan or payment method. The error detail names the fix; show it verbatim.
  • agent_unavailable on chat: the agent is not running, maritime start <agent> --json first.
  • Env var changes not visible: they land on next boot; use --reload or maritime 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:clone yields 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 ps shows transient scheduler.N dynos: 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.