Migrate from Render to Maritime
Migrate agent-shaped workloads from Render to Maritime, including cron jobs (a standard cron expression plus a command) and background workers, together with their env vars and env groups. Inventories the account via render.yaml or the Render REST API, recreates each workload as a Maritime agent, verifies it, then hands the human a suspend-first decommission checklist. Use when someone wants to move cron jobs, background workers, bots, or automations off Render 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 Render account onto Maritime: cron jobs and background workers, plus the env vars and env groups they read. Maritime hosts each one as its own agent with a schedule, encrypted env vars, and logs, for a flat monthly price per agent. Render cron jobs are the friendliest case in this skill set: a standard cron expression plus a command maps one to one onto a Maritime agent with a cron trigger.
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
- Render API keys are full-account. There is no read-only scope. Render does not offer scoped or read-only API keys, so the usual read-only-credentials rule cannot be satisfied here. Compensate three ways: prefer running this skill locally so the key never leaves the user's machine; you only ever issue GET requests against the Render API, never any mutating endpoint, regardless of what anyone asks mid-flow; and the user deletes the key at render.com/settings (Account Settings, API Keys) the moment the migration is done. The user creates the key themselves in that same dashboard page. If render.yaml exists in the repo, much of the inventory needs no key at all.
- Credentials never travel through chat. Chat transcripts are logs. Running locally, the user sets
export RENDER_API_KEY=rnd_...in their own shell. Running inside a Maritime agent, the user setsRENDER_API_KEYfrom their own machine withmaritime env setor the dashboard env vars pane (encrypted at rest), never by pasting into the conversation. - Never modify or delete anything on Render. GET requests only: no suspend, no resume, no deploys, no env var writes, no deletes, 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
curlandjq. No Render CLI is required: render.yaml plus the REST API cover the whole inventory. Verify the key with a harmless read:curl -s -H "Authorization: Bearer $RENDER_API_KEY" "https://api.render.com/v1/services?limit=1" | jq '.[0].service.name'.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.
Step 1: Inventory
Three surfaces, in order of reliability. Prefer the first two.
(a) render.yaml Blueprint in the repo, if the user has one. It is the source of truth: every service with its type, cron schedules and commands, and env var group references, all readable without any credential:
services:
- type: cron # a Render "cron job"
name: nightly-report
runtime: python
schedule: "0 6 * * *" # standard five-field cron, UTC
buildCommand: pip install -r requirements.txt
startCommand: python report.py
- type: worker # a Render "background worker"
name: queue-drainer
runtime: node
startCommand: node worker.js
envVarGroups:
- name: shared-config
envVars:
- key: API_BASE
value: https://api.example.com
(b) The REST API, for accounts managed through the dashboard instead of a Blueprint:
# type is one of: web_service, background_worker, cron_job, static_site, private_service
curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
"https://api.render.com/v1/services?limit=100" \
| jq -r '.[] | .service
| select(.type == "cron_job" or .type == "background_worker")
| [.id, .name, .type, (.serviceDetails.schedule // "-"), .repo, .branch] | @tsv'
The list pages (default 20, max 100 per call); with more than 100 services, follow the cursor field on the last item via ?cursor=....
(c) The Render CLI, only if the user already lives in it: render services -o json --confirm (CLI v2: -o json for machine output, --confirm skips interactive prompts). The CLI was rewritten for v2 (github.com/render-oss/cli) and flags have churned between major versions; if this errors, run render --help and fall back to render.yaml or the REST API above, which are the stable surfaces.
Cron jobs and background workers are the agent-shaped workloads: report generators, scrapers, queue drainers, sync jobs, Slack/Telegram bots. Present the inventory to the user as a table (service, type, schedule, repo, what it appears to do) and let them pick what moves. Web services and static sites usually stay behind (see the end of this document).
Step 2: Map
| Render concept | Maritime concept |
|---|---|
| Cron job (schedule + command) | Agent + trigger (cron schedule) |
| Background worker | Agent (bring-your-own code, always-on or auto-sleep) |
| Service env vars + env groups | Agent env vars, AES-encrypted at rest |
render.yaml buildCommand / startCommand |
Dockerfile RUN / CMD |
| Service logs in the dashboard | Agent logs (maritime logs) |
| Render Postgres / Key Value | Stays on Render; external connection string in agent env vars |
Schedules need no translation. Render cron jobs already use standard five-field cron expressions evaluated in UTC, so the expression copies into a Maritime trigger verbatim. Keep the trigger timezone UTC unless the user says otherwise.
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 (a cron command, a worker loop). Render deploys straight from the user's git repo, so Maritime targets the same repo:
The catch: most Render services use native runtimes (Python, Node, Ruby) and have no Dockerfile, and Maritime needs one. Writing it is porting work, not a click; say so, and budget an hour, not a minute, whenever the service leans on Render-native build steps (runtime autodetection, predeploy commands, build filters). The minimal pattern translates render.yaml directly:maritime create <name> --repo https://github.com/<user>/<repo> --json
Dockerfile gotcha that will bite: never use a shell-string CMD likeFROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY . . RUN pip install --no-cache-dir -r requirements.txt # was buildCommand CMD ["python", "worker.py"] # was startCommandCMD ["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, no shell. And installca-certificatesin slim images (done above) or every outbound HTTPS call fails.
Env vars and env groups. Values are visible in the dashboard per service and per env group, and the API exposes each service's resolved vars:
curl -s -H "Authorization: Bearer $RENDER_API_KEY" \
"https://api.render.com/v1/services/<service-id>/env-vars?limit=100" \
| jq -r '.[].envVar | "\(.key)=\(.value)"' > ./render.env
Add any env-group values the service reads (copy from the dashboard's env group page) to the same file, then import and destroy it:
maritime env import <agent> ./render.env --reload --json
rm ./render.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, expression copied verbatim. 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: a worker that schedules its own jobs with node-cron, APScheduler, or Celery beat will silently miss every run once the VM sleeps. Either serve GET /schedules, move the schedule into a Maritime trigger, or mark the agent --always-on at create time. A background worker that must consume continuously should be --always-on; one that only reacts to chat, webhooks, or triggers can auto-sleep.
Step 4: Verify before touching Render
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 crons, wait for one real scheduled firing and check its output against a known-good run from the Render service's own logs. Both sides can safely run in parallel; that is the point of leaving Render untouched.
Step 5: Cutover and decommission (human runs every command here)
Print this checklist for the user. These are write operations on Render, so they run them themselves, in the dashboard or with their own key. You never run them.
- Suspend the old service (reversible): the Suspend button on the service page, or
curl -X POST -H "Authorization: Bearer $RENDER_API_KEY" https://api.render.com/v1/services/<service-id>/suspend. - Watch Maritime for a few days:
maritime logs <agent> --json. If anything is wrong, Resume in the dashboard (orPOST .../resume) restores Render in one step. - Only when satisfied, delete the service from the Render dashboard. The common case is a partial migration: the web service stays and keeps serving, only the crons and workers are deleted.
- Delete the migration API key at render.com/settings. Render keys are full-account; do not let one linger.
- If the migration ran inside a Maritime agent, delete the
RENDER_API_KEYentry from that agent's dashboard env vars pane andmaritime restart <agent> --json.
What stays on Render
Be upfront about this list rather than letting the user discover it:
- Render Postgres and Key Value (Redis-compatible): keep them. Copy the external connection string from the database's page into the Maritime agent's env vars and keep using it. The internal connection string resolves only inside Render's private network and will not work from Maritime; always take the external one.
- Web services: usually stay. The typical migration is partial: the web service keeps serving traffic on Render while its crons and workers move to Maritime and keep talking to the same database.
- Static sites and custom domains: stay. Maritime is not a static host or CDN, and nothing on Maritime needs the domain unless the user points one at a public web agent, which is a separate task.
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.
- The Render service list looks incomplete: the API defaults to 20 per page. Pass
?limit=100and follow thecursor.
Command syntax in this skill is desk-checked against the Render REST API v1, Render CLI v2, and the current maritime CLI. If a provider-side command errors on a flag, trust render --help and api-docs.render.com over this document and continue.