Migrate from DigitalOcean to Maritime
Migrate agent-shaped workloads from DigitalOcean to Maritime, including App Platform workers and jobs, DO Functions with scheduled triggers, and droplet-hosted bots. Inventories the account with a read-only scoped API token, recreates each workload 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 DigitalOcean 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 DigitalOcean account onto Maritime: App Platform workers and jobs, DO Functions on cron triggers, and bots squatting on droplets. Maritime hosts each one 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
- Read-only DigitalOcean token only. DigitalOcean API tokens have supported custom scopes since 2023, so there is no excuse for a full-access token here. Ask the user to mint one at cloud.digitalocean.com/account/api/tokens (Account, API, Tokens tab, Generate New Token) and choose Read Only, which grants the
api:readalias scope, or Custom Scopes with only read scopes checked. If the user offers a legacy full-access token, refuse it and show the scoped flow instead; token scopes cannot be edited after creation, so a fresh token is the only path. The human authenticates it themselves:
Remind them to revoke this token in the control panel when the migration is done.doctl auth init --context maritime-migration # doctl prompts for the token directly doctl account get # verify the token works - Credentials never travel through chat. Chat transcripts are logs. Running locally, the user runs
doctl auth initthemselves and the token never appears in the conversation. Running inside a Maritime agent, the user setsDIGITALOCEAN_ACCESS_TOKENfrom their own machine withmaritime env setor the dashboard env vars pane (encrypted at rest), never by pasting into the conversation; doctl reads that env var directly. - Never modify or delete anything on DigitalOcean. No
apps update, noapps delete, nodroplet delete, nodroplet-action power-off, notriggers disable, 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
doctl, authenticated with the read-only token above. Verify withdoctl account get.- For Functions inventory only:
doctl serverless installonce (installs local support), thendoctl serverless connect. Both work under the read-only token. 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 places agent-shaped work hides on DigitalOcean, plus the data layer:
# App Platform: list apps, then read each spec
doctl apps list
doctl apps spec get <app-id> # YAML with services, workers, jobs, functions components
# DO Functions and their cron triggers
doctl serverless functions list
doctl serverless triggers list # shows name, cron expression, invoked function, enabled
# Droplets that might host bots
doctl compute droplet list
# Managed databases the workloads read (inventory only, the data stays)
doctl databases list
To pull just the agent-shaped components out of a busy app spec:
doctl apps spec get <app-id> --format json | \
jq '{workers: [.workers[]?.name], jobs: [.jobs[]? | {name, kind}], functions: [.functions[]?.name]}'
App Platform has no native cron component. A workers: entry is a long-running process; a jobs: entry runs around deployments (PRE_DEPLOY, POST_DEPLOY, FAILED_DEPLOY), not on a schedule. Scheduled work on App Platform is almost always a worker running its own scheduler loop, or an external pinger hitting a service endpoint. Read the spec and then the code to find the real schedule; do not assume the platform knows it.
Droplets: hand off, do not duplicate. What runs inside a droplet (systemd units, crontabs, pm2, tmux sessions) is exactly the SSH inventory the companion skill migrate-from-vps in this folder family already does. Use doctl compute droplet list to find candidate droplets and their IPs, then switch to that skill for everything inside the box. This document covers only the droplet's DigitalOcean-side lifecycle (power off, delete) in the cutover list.
Agent-shaped workloads are the ones that wake up, do a job, and go quiet: cron functions, report generators, scrapers, Slack/Telegram bots, queue drainers, sync jobs. Present the inventory to the user as a table (workload, trigger, runtime, what it appears to do) and let them pick what moves.
Step 2: Map
| DigitalOcean concept | Maritime concept |
|---|---|
| App Platform worker | Agent (always-on or auto-sleep) |
| App Platform job (deploy-time) | Fold into the agent's own startup or deploy; rarely its own agent |
| DO Function + cron trigger | Agent + trigger (cron schedule) |
| Worker running a scheduler loop | Agent + trigger, with the loop removed |
| Droplet-hosted bot | Agent via migrate-from-vps |
App spec envs, Functions env |
Agent env vars, AES-encrypted at rest |
App Platform runtime logs, doctl serverless activations logs |
Agent logs (maritime logs) |
DO Functions triggers use standard five-field cron in UTC, so expressions copy to Maritime triggers unchanged; keep the trigger timezone UTC unless the user says otherwise. For scheduler-loop workers the schedule lives in code; extract it and recreate it as a real trigger rather than porting the loop. App Platform instance sizes do not carry over: Maritime picks an applicable size at create, and per-agent --ram/--cpu overrides exist if one workload genuinely needs them.
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 (a worker, a job script, a function handler). App Platform already deploys from the user's git repo, so the source of truth is the repo itself; point Maritime at the same one:
maritime create <name> --repo https://github.com/<user>/<repo> --jsonIf the component built with a native buildpack (no Dockerfile in the repo), add one. That is porting work, not a click; say so and budget an hour. For DO Functions, fetch the source with
doctl serverless functions get <fn> --code(this does not work for functions deployed as a zip; take the source from the user's repo instead), wrap the handler in a small always-running entrypoint or HTTP server, and push to a repo the user controls.Dockerfile gotcha that will bite: never use 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. Installca-certificatesin slim images.
Env vars and secrets. Each component's envs: block in the app spec carries plain values inline, but anything typed as a secret comes back redacted from doctl apps spec get; DigitalOcean never returns App Platform secret values through any API. Those values must come from the user's own records (password manager, original .env file, the upstream service that issued them). Say that plainly up front instead of discovering it at import time. DO Functions env vars ARE retrievable: doctl serverless functions get <fn> --save-env <file> (or -E) writes them as key=value lines. Route everything into a local .env file, import, then destroy the file:
maritime env import <agent> ./do.env --reload --json
rm ./do.env
Env changes apply on next boot unless you pass --reload.
Schedules. Recreate each cron as a Maritime trigger: dashboard, agent page, Triggers pane, cron type, using the expression from doctl serverless triggers list or the one extracted from the worker's code. 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 DigitalOcean
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 App Platform runtime logs or doctl serverless activations logs --function <fn>. If the workload writes somewhere observable (a database row, a Slack message, a file in Spaces), check the artifact itself, not just the logs. Both sides can safely run in parallel; that is the point of leaving DigitalOcean untouched.
Step 5: Cutover and decommission (human runs every command here)
Print this checklist for the user. These are write operations on DigitalOcean, so they run them with their own credentials. You never run them.
- Back up every app spec before touching it:
doctl apps spec get <app-id> > backup-<app>.yaml. - Pause, reversibly. App Platform: edit the spec to remove the migrated worker component (or set its
instance_countto 0) and apply withdoctl apps update <app-id> --spec edited.yaml; applyingbackup-<app>.yamlthe same way reverses it. Functions:doctl serverless triggers disable <trigger>(enable to reverse). Droplets:doctl compute droplet-action power-off <droplet-id>(power-on to reverse), always before any destroy. - Watch Maritime for a few days:
maritime logs <agent> --json. - Only when satisfied, delete:
doctl apps delete <app-id>(or keep the app and apply the trimmed spec permanently),doctl compute droplet delete <droplet-id>, and undeploy retired functions withdoctl serverless undeploy <function>. - Revoke the migration token at cloud.digitalocean.com/account/api/tokens.
- If the migration ran inside a Maritime agent, delete
DIGITALOCEAN_ACCESS_TOKENfrom that agent in the dashboard env vars pane, thenmaritime env reload <agent> --json. The revoked token is dead either way; do not leave it sitting in config.
What stays on DigitalOcean
Be upfront about this list rather than letting the user discover it:
- Spaces buckets: S3-compatible object storage does not move and does not need to. The Maritime agent keeps using the bucket over the S3 API with a scoped Spaces access key pair in its env vars.
- Managed Databases: keep them. Put the public connection string (
doctl databases connection <db-id>) in the agent's env vars and update the database's trusted sources to admit the agent's traffic. A database with public access disabled (VPC-only) is unreachable from Maritime; flag each one you find and let the user decide (public endpoint with trusted sources, a proxy, or leave that workload on DigitalOcean). - Load Balancers: stay, serving whatever web services remain on DigitalOcean.
- Cloud Firewalls, reserved IPs, VPCs: droplet plumbing. It retires with the droplets it serves, in the cutover list, not before.
- DNS hosted at DO: stays. Nothing on Maritime needs it unless the user points a domain at a public web agent, which is a separate task.
Troubleshooting
doctl serverless ...says serverless support is not installed: rundoctl serverless installonce, thendoctl serverless connect.- Account has several Functions namespaces:
doctl serverless connect <namespace-hint>picks one; re-run connect to switch before listing another namespace's functions. - Secret env vars come back redacted from
doctl apps spec get: expected, not an error. Values come from the user's records. doctl apps spec getreturns the active spec; a deployment in progress can differ. Pass--deployment <id>to read a specific deployment's spec.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.
Command syntax in this skill is desk-checked against current doctl and the current maritime CLI. If a provider-side command errors on a flag, trust doctl <cmd> --help over this document and continue.