← All migration guides

Migrate from Fly.io to Maritime

Migrate agent-shaped workloads from Fly.io to Maritime, including scheduled Machines, fly.toml worker processes, and bots or automations running as ordinary Fly apps. Inventories the Fly organization with a read-only token, redeploys each app's existing Dockerfile as a Maritime agent, verifies it, then hands the human a decommission checklist. Use when someone wants to move automations, bots, or cron jobs off Fly.io 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 Fly.io organization onto Maritime: scheduled Machines, worker processes declared in fly.toml, and bots or queue drainers running as ordinary Fly apps. Fly is one of the friendlier sources to migrate from: every Fly app is already a container, so the Dockerfile usually exists in the user's repo and maritime create <name> --repo <url> is nearly direct. Maritime hosts each workload as its own agent with a schedule, encrypted env vars, and logs, for a flat monthly price per agent.

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. Read-only Fly token only. Ask the user to mint a scoped read-only org token; that is all inventory needs:
    fly tokens create readonly
    
    The human runs that with their own login; you only ever receive the resulting token. If the user offers the output of fly auth token (a full-access personal token) or a deploy token, refuse it and show the readonly command instead. Remind them to revoke the migration token when the migration is done. If the subcommand errors, trust fly tokens create --help over this document.
  2. Credentials never travel through chat. Chat transcripts are logs. Running locally, the user exports FLY_API_TOKEN in their own shell. Running inside a Maritime agent, the user sets FLY_API_TOKEN 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 modify or delete anything on Fly. No fly apps destroy, no fly machine stop, no fly secrets unset, no fly scale, 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 shape (template or repo), and the price, and get a yes before each maritime create.

Prerequisites

  • fly CLI (flyctl), authenticated with the read-only token above via FLY_API_TOKEN. Verify with fly 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

All of these are read operations, safe under the readonly token:

# Every app in the organization
fly apps list

# Per app: overall state and the machines behind it
fly status -a <app>
fly machine list -a <app>

# The live app config as deployed (JSON by default; --toml prints fly.toml shape).
# Look at [processes] for worker processes beyond the default web process.
fly config show -a <app>

# Secret NAMES and digests only; values are not retrievable, by design
fly secrets list -a <app>

Scheduled Machines are Fly's cron analog: created with fly machine run <image> --schedule hourly|daily|weekly|monthly, they show up in fly machine list and carry a schedule field in the machine config (fly machine list --json includes it; if that flag is missing, check fly machine status <machine-id> -a <app> and trust fly machine list --help over this document).

Agent-shaped workloads are the ones that wake up, do a job, and go quiet: scheduled Machines, report generators, scrapers, Slack/Telegram bots, queue drainers, sync jobs, and any [processes] worker with no public traffic. Present the inventory to the user as a table (app, process or machine, schedule if any, what it appears to do) and let them pick what moves.

Step 2: Map

Fly concept Maritime concept
Fly app (container per Machine) Agent (bring-your-own code)
Scheduled Machine (--schedule) Agent + trigger (cron schedule)
fly.toml [processes] worker Its own agent, one per process
fly secrets Agent env vars, AES-encrypted at rest
fly logs Agent logs (maritime logs)
Machine auto stop/start Agent auto-sleep (default) or --always-on

Schedules translate up, not across. Fly offers only four coarse intervals (hourly, daily, weekly, monthly) and fires them relative to the Machine's own history; the user never chose a wall-clock time. Maritime triggers are real five-field cron, so this is an upgrade: ask the user when the job should actually run instead of transcribing blindly. Mechanical defaults if they have no preference: hourly becomes 0 * * * *, daily 0 0 * * *, weekly 0 0 * * 0, monthly 0 0 1 * *, all UTC.

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 the app's own containerized code, which is the common Fly case. The repo already has a Dockerfile because Fly deployed from one (hand-written or generated by fly launch), so this is close to a direct move:

    maritime create <name> --repo https://github.com/<user>/<repo> --json
    

    Before deploying, READ the Dockerfile. Two things bite:

    • Never a shell-string CMD like CMD ["sh", "-c", "python main.py $PORT"]. Maritime's micro-VM init flattens CMD to one string and the VM kernel-panics on boot. Launch a real program directly, exec form. Fly's builder tolerates shell-string CMDs, so a long-deployed Fly app proves nothing here; check anyway.
    • Install ca-certificates in slim images.

    fly.toml [processes] command overrides do not travel with the Dockerfile. Give each worker process its own agent, and make its Dockerfile CMD (in a branch or a small dedicated repo) launch that process's command directly.

    If only an image remains because the source repo is lost, fly image show -a <app> names the deployed image ref, but prefer recovering the source: registry.fly.io images need Fly credentials to pull, and rebuilding from source keeps the agent reproducible.

Sizing and sleep. Fly Machines auto stop and start; Maritime's default is the same shape, agents auto-sleep when idle and wake on demand, so most workloads need no sizing flags at all. If a Fly Machine was pinned to a specific VM size (fly machine list shows each machine's size), mirror it with the optional create-time overrides:

maritime create <name> --repo <url> \
  --ram 2048 \        # MB, mirror the Fly Machine memory
  --cpu 1 \           # vCPU cores
  --idle 600 \        # sleep 600s after the last request
  --json

Use --always-on instead of --idle for bots that hold a persistent connection (Telegram, Discord, long-lived queue consumers); scheduled and polling jobs should keep the default auto-sleep. Resources apply at provision time; changing --ram/--cpu/--disk later needs a recreate, while idle settings update live.

Env vars and secrets. Say this plainly to the user up front: fly secrets list shows names and digests only, and secret values are not retrievable through flyctl by design; only the running app ever sees them. The values must come from the user's own records: a password manager, local .env files, or the dashboards of the upstream services the secrets belong to. Build the list of required names from fly secrets list and fly config show (the [env] section holds the non-secret vars), then have the user assemble a local file themselves:

# The user fills fly.env from their own records, one KEY=value per line.
maritime env import <agent> ./fly.env --reload --json
rm ./fly.env

If a value is genuinely unrecoverable, rotate it at the upstream service and use the new value; that is normal and safer than archaeology. Env changes apply on next boot unless you pass --reload.

Schedules. Recreate each scheduled Machine as a Maritime trigger: dashboard, agent page, Triggers pane, cron type, using the expression agreed in Step 2. OpenClaw-family agents can equivalently use their native cron, which Maritime syncs. For bring-your-own-code agents remember that timers inside a sleeping VM do not fire; either serve GET /schedules, use the SDK scheduler observer, or mark the agent --always-on at create time.

Step 4: Verify before touching Fly

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 trigger firing and compare its output against a known-good run from fly logs -a <app>. For auto-sleep agents, also confirm the job survives a sleep/wake cycle: let the agent go idle, then trigger it again and check the logs. Both sides can safely run in parallel; that is the point of leaving Fly untouched.

Step 5: Cutover and decommission (human runs every command here)

Print this checklist for the user. These are write operations on Fly, so they run them with their own full-access login. You never run them.

  1. Pause the old app (reversible): fly scale count 0 -a <app>, restored in one command with fly scale count 1 -a <app> (add -g <process> for one process group of a multi-process app). For a single scheduled Machine, fly machine stop <machine-id> -a <app> is the finer-grained equivalent, reversed with fly machine start.
  2. Watch Maritime for a few days: maritime logs <agent> --json. If anything is wrong, scale Fly back up and keep both running.
  3. Only when satisfied, retire the app: fly apps destroy <app>. This permanently removes its Machines, volumes, and IPs, so export any volume data first (see below).
  4. Revoke the migration token: fly tokens list, then fly tokens revoke <token-id>. If those subcommands have drifted, trust fly tokens --help over this document.
  5. If the migration ran inside a Maritime agent, remove FLY_API_TOKEN from that agent via the dashboard env vars pane.

What stays on Fly

Be upfront about this list rather than letting the user discover it:

  • Fly Postgres and anything else on the private network. Data does not move, and reachability is the hard part. fly proxy is a WireGuard tunnel on the user's local machine, so a Maritime agent cannot use it. If the database is reachable only over Fly's private 6PN network, the agent cannot connect at all. The honest options: enable Fly's public connection path for that Postgres (external port on a dedicated IP), move the database to a managed provider, or leave that workload on Fly. A private-network-only database is a genuine blocker to weigh, not a footnote.
  • Volumes and LiteFS. Fly volume data does not migrate; the human exports anything they need before step 3 above deletes it. LiteFS-replicated SQLite is Fly-specific, so an app built around it keeps its data plan on Fly or redesigns storage first.
  • Anycast IPs, certificates, custom domains. These stay with Fly. If a public web agent on Maritime replaces the app, re-pointing DNS is a separate task after the migration, not part of it.

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.
  • A secret nobody can find the value for: it is not retrievable from Fly, full stop. Rotate it at the upstream service and import the new value.
  • A fly read command returns 403 under the readonly token: some subcommands want broader scope. Have the human run that single read with their own login and paste the output; do not upgrade the migration token.

Command syntax in this skill is desk-checked against flyctl and the current maritime CLI. If a provider-side command errors on a flag, trust fly <cmd> --help over this document and continue.