Migrate from a VPS to Maritime
Migrate agent-shaped workloads off any VPS or bare server reachable over SSH, including cron jobs, tmux or screen resident bots, pm2 and supervisor apps, systemd services and timers, and Docker containers, whether the box is a Hetzner or Linode or OVH server, a Raspberry Pi, or an old EC2 instance treated as a pet. Inventories the box over SSH with a fixed list of read-only commands, recreates each workload as a Maritime agent, verifies it, then hands the human a decommission checklist. Use when someone wants to move bots, cron jobs, or workers off a server they SSH into 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 VPS or bare server onto Maritime: crontab entries, systemd services and timers, pm2 and supervisor processes, bots living in tmux or screen sessions, and Docker containers. This is the classic "$5 droplet running my bot under tmux" migration, and it applies to any box you can SSH into: Hetzner, Linode, OVH, DigitalOcean, a Raspberry Pi, an old EC2 instance nobody dares touch. 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, keys stay on the user's machine) or inside a Maritime agent. Either way the same rules apply.
Safety rules, non-negotiable
- A regular user, and a behavioral contract instead of enforcement. Honest version: there is no such thing as an enforced read-only SSH login on a stock box. So the contract is behavioral and hard: connect as a normal non-root user the human designates, run only the commands listed in Step 1 (all read-only), never write a file on the box, never install anything, never use sudo. Anything that needs elevated read access is quoted for the human to run themselves and paste back. Never accept root SSH keys or a root login, even if offered.
- SSH keys never travel through chat. Chat transcripts are logs. The human adds a public key for a keypair they control to the box's
~/.ssh/authorized_keysthemselves. Running locally, you use the user's own SSH agent or config. Running inside a Maritime agent, the user generates a dedicated keypair on their own machine and installs the private key viamaritime env setor the dashboard env vars pane (encrypted at rest), never by pasting into the conversation. Remind them to remove the key fromauthorized_keyswhen the migration is done. - Never modify or delete anything on the box. No
systemctl disable, nopm2 stop, no editing crontabs, no killing processes, 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
sshandrsynclocally. Verify access withssh <user>@<host> hostnamebefore anything else.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
The commands below are the complete list you may run on the box. Every one is read-only. Set BOX=<user>@<host> once and sweep all five habitats a workload can live in:
# Cron: the most common home of a VPS job
ssh "$BOX" 'crontab -l'
ssh "$BOX" 'cat /etc/crontab'
ssh "$BOX" 'ls /etc/cron.d /etc/cron.daily /etc/cron.hourly'
# Other users' crontabs need root. Quote this for the HUMAN to run, never run it yourself:
# sudo ls /var/spool/cron/crontabs
# systemd: long-running services and timers
ssh "$BOX" 'systemctl list-units --type=service --state=running'
ssh "$BOX" 'systemctl cat <unit>' # for user-authored units, typically in /etc/systemd/system
ssh "$BOX" 'systemctl list-timers'
# Process managers, whichever exists on the box
ssh "$BOX" 'pm2 ls'
ssh "$BOX" 'pm2 describe <name>'
ssh "$BOX" 'pm2 env <id>' # env of one pm2 process; treat output as credentials
ssh "$BOX" 'forever list'
ssh "$BOX" 'supervisorctl status'
ssh "$BOX" 'cat /etc/supervisor/conf.d/*'
# Sessions someone left running
ssh "$BOX" 'tmux ls'
ssh "$BOX" 'screen -ls'
ssh "$BOX" 'ps aux' # to see what actually runs inside those sessions
# Docker
ssh "$BOX" 'docker ps'
ssh "$BOX" 'docker inspect <container>' # image, command, env; see the warning below
# General lay of the land
ssh "$BOX" 'ps aux --sort=-%cpu | head -30'
ssh "$BOX" 'date' # the box timezone; cron lines run in it
ssh "$BOX" 'ls ~/apps ~/bot ~/scripts' # guesses; let the ps and pm2 output guide the paths
docker inspect output contains env secrets in plain text. Treat it like a credential: never echo it into chat, never quote it in your summary. Write the values you need straight into a local .env file (Step 3) and nothing else.
Version note: pm2, forever, and supervisor drift across versions. If a subcommand or flag above is missing, run pm2 --help or supervisorctl help on the box and adapt; the intent (list processes, show one process with its env) is stable. Also, ssh "$BOX" 'pm2 ls' can fail with "command not found" when pm2 was installed via nvm, because non-interactive shells skip nvm's setup; the fix is the full path, usually ~/.nvm/versions/node/<ver>/bin/pm2.
Agent-shaped workloads are the ones that wake up, do a job, and go quiet, or sit waiting for messages: cron scripts, report generators, scrapers, Slack/Telegram/Discord bots, queue drainers, sync jobs. Present the inventory to the user as a table (workload, where it lives, schedule or trigger, what it appears to do) and let them pick what moves.
Step 2: Map
| VPS concept | Maritime concept |
|---|---|
| crontab line / /etc/cron.d entry | Agent + trigger (same cron expression, 1:1) |
| systemd timer + oneshot service | Agent + trigger (convert OnCalendar to cron) |
| systemd long-running service | Agent (always-on or auto-sleep) |
| pm2 / forever / supervisor process | Agent |
| Bot in a tmux or screen session | Agent (--always-on, or auto-sleep if it only reacts to messages, which is cheaper) |
| Docker container | Agent (often the same Dockerfile, minus the gotcha below) |
.env files, Environment= lines, pm2 env |
Agent env vars, AES-encrypted at rest |
| journalctl, pm2 logs, nohup.out | Agent logs (maritime logs) |
Crontab lines are already real five-field cron, so trigger expressions copy over unchanged; just note the box's timezone from date and keep the trigger timezone matching unless the user wants a change. systemd OnCalendar converts mechanically: OnCalendar=*-*-* 05:00:00 becomes 0 5 * * *. For anything fancier (OnCalendar=Mon..Fri), write out the equivalent cron and show the user both forms.
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 script, a pm2 app, a container). This is a port, not a copy, and you should say so. Budget an hour, not a click. Pull the code down read-only, then get it into a git repo the user controls:
Add a Dockerfile if there is none. The minimal patterns for a Python and a Node worker:rsync -az "$BOX":/path/to/app ./app-copyFROM 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 requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "main.py"]
Dockerfile gotcha that will bite: never use a shell-string CMD likeFROM node:22-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . CMD ["node", "index.js"]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, as both patterns above do. And keepca-certificatesin slim images or every HTTPS call fails. Then:maritime create <name> --repo https://github.com/<user>/<repo> --json
Env vars and secrets. Gather from the three places a VPS hides them: pm2 env <id> output, Environment= and EnvironmentFile= lines in the unit files you already read with systemctl cat, and .env files sitting next to the code. Cat them straight into a local file, never into chat:
ssh "$BOX" 'cat /path/to/app/.env' > ./vps.env
# append KEY=value lines extracted from pm2 env / unit files / docker inspect by hand
maritime env import <agent> ./vps.env --reload --json
rm ./vps.env
Env changes apply on next boot unless you pass --reload.
Schedules. Recreate each cron line as a Maritime trigger: dashboard, agent page, Triggers pane, cron type, same expression. 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. A tmux-resident bot that only reacts to inbound messages can usually auto-sleep instead, which is cheaper.
Step 4: Verify before touching the box
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 the box (the human pulls journalctl -u <unit> or pm2 logs for comparison). Both sides can safely run in parallel; that is the point of leaving the box untouched.
Step 5: Cutover and decommission (human runs every command here)
Print this checklist for the user. These are write operations on the box, so they run them over their own SSH session. You never run them.
- Pause the old job, reversibly. Cron: comment out the line with
crontab -e(uncomment to restore). systemd:systemctl disable --now <unit>(restore withsystemctl enable --now <unit>). pm2:pm2 stop <name> && pm2 save(restore withpm2 start <name> && pm2 save). supervisor:supervisorctl stop <name>. tmux bot: detach and stop the process inside the session. - Watch Maritime for a few days:
maritime logs <agent> --json. If anything is wrong, the one-line restore above brings the box back instantly. - Only when satisfied, remove: delete the unit files and
systemctl daemon-reload,pm2 delete <name> && pm2 save, delete the crontab lines for real, remove the code directory. - Remove the migration public key from
~/.ssh/authorized_keyson the box. - Cancel the VPS itself only if it hosts nothing else. Before canceling, check for other tenants of the box: databases, nginx sites, mail, backup jobs, a friend's side project. The inventory in Step 1 shows workloads, not everything; have the human do a final sweep before the box dies.
What stays on the box
Be upfront about this list rather than letting the user discover it:
- Databases on localhost (Postgres, MySQL, Redis). Moving data is out of scope for this skill. Two honest options: switch the agent to a managed database it reaches over TLS, or keep the VPS alive as a database-only box the agent connects to over the public internet with a firewall rule. Either way, every
localhostor127.0.0.1connection string you find in env vars is a decision point: it breaks the moment the worker moves. Flag each one explicitly and let the user choose before cutover. - Nginx sites, static sites, reverse proxies. They stay. Maritime hosts agents, not the box's websites.
- Mail servers, backup targets, VPN endpoints, anything else on the box. Stays. This is exactly why step 5 warns about other tenants before canceling.
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.
- SSH prompts for a password: the public key is not installed for that user. Stop and ask the human to add it; never type or handle a password.
crontab -lsays "no crontab for <user>": that user has none; the job may live under another user (the human runs the sudo listing from Step 1) or in/etc/cron.d.
Command syntax in this skill is desk-checked against stock Linux (cron, systemd, tmux, Docker) and the current maritime CLI. If a box-side command errors on a flag, trust <tool> --help on the box over this document and continue.