Mirroring My AI Workspace to a 1GB VPS: A Weekend Migration

Mirroring My AI Workspace to a 1GB VPS: A Weekend Migration
There is a moment in every power user's life when they realize their local setup is too good to leave stranded on one machine. For me, that moment came when I caught myself SSHing into my own laptop from across the room because I had left a research agent running.
The fix was obvious: mirror the workspace to a small VPS, run it 24/7, and let Telegram be the front door. The constraint was also obvious: I had a 1GB RAM, 1 vCPU Ubuntu 24.04 box and a wallet that did not want to upgrade.
Here is the runbook I followed, the parts that bit me, and what I would do differently next time.
The Target Architecture
I drew the target before touching a single config file. The VPS would run two systemd services:
opencode.service— the OpenCode agent server on port 4096, with the Bun runtime, the orchestrator subagent, 105+ skills, 27 agent definitions, the rtk token-saver plugin, and ten MCP clients (filesystem, playwright, git, paper-search, memory, sequential-thinking, tavily, fetch, github, google-workspace).opencode-telegram.service— a small Node 20 bot that bridges Telegram messages to the OpenCode HTTP API, with a SQLite session store.
A Telegram bot (@something) was the only external surface. No exposed ports, no public dashboards, no auth gateway to maintain. Just a chat thread and a 1GB box.
What I Verified Before Touching Anything
I am allergic to migrating blind. Before moving a single byte, I tested every credential that mattered.
The results were sobering: of my four configured providers, only one was alive. My OpenAgentic key was healthy and exposed 30 models on the /api/v1/models endpoint. My Sumopod key returned a 401 with "Invalid token." My BluesMinds keys had been dead since April and I had not noticed. 9Router was off the table by request.
So the new VPS was going to be OpenAgentic-only, with Sumopod kept in the config as a disabled optional layer for future reactivation. No dead keys, no half-broken providers, no mystery errors at 2am.
The local Windows state I was bringing over included a 294-line opencode.jsonc with 4 providers and 10 MCPs, 27 agent .md files, an rtk.ts plugin, and 105+ skill directories. Not huge by absolute standards, but enough that I wanted a deterministic copy rather than ad-hoc rsync.
Step 1: Stage the Local Workspace
I started by dumping everything into a clean staging directory on my Windows machine, so I could inspect, sanitize, and re-zip in one shot.
Set-Location "D:\programming\automation\openfang-agent"
$stage = "D:\programming\automation\openfang-agent\opencode-export"
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item "$env:USERPROFILE\.config\opencode\opencode.jsonc" "$stage\opencode.json"
Copy-Item "$env:USERPROFILE\.config\opencode\agents" "$stage\agents" -Recurse
Copy-Item "$env:USERPROFILE\.config\opencode\plugins" "$stage\plugins" -Recurse
Copy-Item "$env:USERPROFILE\.agents\skills" "$stage\skills" -Recurse
Step 2: Scrub Every Secret
This is the part most people skip and regret. I rewrote every hardcoded sk-… key in opencode.json to a {env:OPENAGENTIC_API_KEY} placeholder, then grepped the result to confirm zero leaks.
$json = Get-Content "$stage\opencode.json" -Raw
$json = $json -replace '"apiKey":\s*"sk-[a-zA-Z0-9]{40,}"', '"apiKey": "{env:OPENAGENTIC_API_KEY}"'
$leaks = Select-String -Path "$stage\opencode.json" -Pattern "sk-[a-zA-Z0-9]{30,}" | Measure-Object
Write-Host "Key leaks found: $($leaks.Count)"
$json | Set-Content "$stage\opencode.json" -NoNewline
The real keys live only in a separate opencode.env file that I would hand-edit on the VPS and chmod 600. Two files, one config, one secret store. The classic twelve-factor split.
Step 3: Zip and Ship
$zip = "D:\programming\automation\openfang-agent\opencode-export.zip"
Compress-Archive -Path "$stage\*" -DestinationPath $zip -Force
scp -i "<your-ssh-key>" $zip root@<your-vps-ip>:/tmp/
Step 4: Provision the VPS
Once the zip was on the box, I SSHed in and treated it like a fresh server: update, add 2GB of swap, install the runtime stack.
ssh -i "<your-ssh-key>" root@<your-vps-ip>
apt update && apt upgrade -y
apt install -y curl wget git unzip jq build-essential sqlite3 file
timedatectl set-timezone Asia/Jakarta
The 1GB RAM constraint was real, so I carved out 2GB of swap and tuned the kernel to use it sparingly. The system would rather spill to disk than OOM an agent mid-task.
fallocate -l 2G /swapfile
chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
cat >> /etc/sysctl.conf <<'EOF'
vm.swappiness=10
vm.vfs_cache_pressure=50
EOF
sysctl -p
free -h
Then the runtime stack: Bun for OpenCode, uv for the Python-based MCP servers, Node 20 for the Telegram bot, OpenCode CLI itself, and the rtk binary that powers my token-saver plugin.
curl -fsSL https://bun.sh/install | bash
export PATH="$HOME/.bun/bin:$PATH"
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
curl -fsSL https://opencode.ai/install | bash
export PATH="$HOME/.opencode/bin:$PATH"
npm install -g opencode-telegram-bot
RTK_VERSION="0.23.0"
curl -fsSL "https://github.com/rtk-ai/rtk/releases/download/v${RTK_VERSION}/rtk-x86_64-unknown-linux-gnu.tar.gz" -o rtk.tar.gz
tar -xzf rtk.tar.gz
mv rtk ~/.local/bin/rtk && chmod +x ~/.local/bin/rtk
Step 5: Restore the Workspace
mkdir -p ~/.config/opencode
cd /tmp
unzip -o opencode-export.zip -d opencode-restore/
cp -r opencode-restore/skills/* ~/.agents/skills/
cp -r opencode-restore/agents/* ~/.config/opencode/agents/
mkdir -p ~/.config/opencode/plugins
cp -r opencode-restore/plugins/* ~/.config/opencode/plugins/
cp opencode-restore/opencode.json ~/.config/opencode/opencode.json
chmod 700 ~/.config/opencode ~/.agents
chmod 600 ~/.config/opencode/.env 2>/dev/null
chmod 644 ~/.config/opencode/opencode.json
Step 6: Translate the Local Provider Names
This was the step I almost forgot. My local agent definitions referenced models through a 9Router path like 9router/openagenic/claude-opus-4.7-thinking. The VPS talks to OpenAgentic directly, so I had to rewrite every reference with sed.
cd ~/.config/opencode/agents/
cp -r . /tmp/agents-original-backup/
for f in *.md; do
sed -i \
-e 's|9router/openagenic/|openagentic/|g' \
-e 's|9router/kr/|openagentic/|g' \
-e 's|9router/cx/|openagentic/|g' \
-e 's|9router/gh/|openagentic/|g' \
-e 's|9router/claude-combo|openagentic/claude-sonnet-4.5|g' \
"$f"
done
grep "9router/" *.md | head -5 # Expected: no output
A backup of the original is sitting in /tmp/agents-original-backup/ in case I ever need to roll back. Sed is irreversible; backups are not optional.
Step 7: Patch the JSON Config
Then I used jq to remove the 9Router provider block, retarget the filesystem MCP to a Linux path, and switch the default model to OpenAgentic.
jq 'del(.provider["9router"])' ~/.config/opencode/opencode.json > /tmp/opencode.tmp.json
jq '.provider.openagentic.options.apiKey = "{env:OPENAGENTIC_API_KEY}"' /tmp/opencode.tmp.json > /tmp/opencode.tmp2.json
jq '.mcp.filesystem.command = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/root"]' /tmp/opencode.tmp2.json > /tmp/opencode.tmp3.json
jq '.permission.external_directory = {"/root/**": "allow", "/tmp/**": "allow", "*": "allow"}' /tmp/opencode.tmp3.json > /tmp/opencode.tmp4.json
jq '.model = "openagentic/claude-sonnet-4.5" | .small_model = "openagentic/claude-sonnet-4.5"' /tmp/opencode.tmp4.json > ~/.config/opencode/opencode.json
jq '.plugin = ["file:///root/.config/opencode/plugins/rtk.ts"]' ~/.config/opencode/opencode.json > /tmp/opencode.tmp5.json
mv /tmp/opencode.tmp5.json ~/.config/opencode/opencode.json
jq . ~/.config/opencode/opencode.json > /dev/null && echo "JSON valid"
The final config had openagentic, bluesminds (kept but disabled), and sumopod (kept but disabled) as provider keys, ten MCPs wired, and the rtk plugin loaded.
Step 8: systemd, the Real Difference-Maker
A 1GB VPS that survives a reboot is the whole point of the migration. I dropped two service units into /etc/systemd/system/.
The first, opencode.service, runs the OpenCode server with memory caps (MemoryMax=800M, MemoryHigh=600M), explicit PATH, and ReadWritePaths limited to the directories it actually needs. The second, opencode-telegram.service, runs the bot, depends on the first being up, and sleeps 5 seconds before starting so the HTTP endpoint has time to bind.
[Service]
Type=simple
User=root
WorkingDirectory=/root
EnvironmentFile=/etc/opencode/opencode.env
ExecStart=/root/.opencode/bin/opencode serve --port 4096 --hostname 127.0.0.1
Restart=always
RestartSec=5
MemoryMax=800M
MemoryHigh=600M
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/root/.config/opencode /root/.agents /root/.local/share/opencode /tmp
After systemctl daemon-reload and systemctl enable, both services came up cleanly. The first time I rebooted the VPS and saw them both come back online on their own, I knew the migration was real.
Step 9: Telegram First-Run Wizard
opencode-telegram configure walked me through token, API URL, my numeric user ID, default agent, and default model. I replied to a few messages in Telegram to confirm the round trip, then ran the full smoke test matrix: /start, what is 2+2, /model, web research, paper search, plan generation, file write, and image generation.
Every test passed within ten seconds. The bot was alive.
Risks I Documented in the Runbook
- Sumopod and BluesMinds will fail if used; the default model keeps you on OpenAgentic.
- Google Workspace MCP needs re-auth on the VPS because OAuth tokens are device-bound.
- The first call to paper-search or tavily triggers a 30-60 second download.
- If the rtk binary moves, the plugin silently disables. The systemd PATH must include
~/.local/bin. - 1GB RAM plus 2GB swap is enough for one chat, but a subagent burst will spill to disk and slow down.
Key Learnings From the Weekend
Time estimate was honest. Ninety to one hundred twenty minutes is realistic if you already have the local setup stable. If you are debugging the local setup at the same time, double it.
Pre-verification saved me hours. Knowing only one provider was alive before I started prevented the classic "why is everything 401" rabbit hole on the new box.
The sed transform is the linchpin. Without it, every agent prompt silently routes to a dead provider. The grep-after is mandatory.
Memory caps in systemd are non-negotiable on 1GB. Without MemoryHigh=600M and swap, a runaway subagent will OOM-kill the daemon. With them, the worst case is slow rather than dead.
Telegram as the only surface changed how I use the system. I no longer "open the IDE to ask the agent a question." I just send a message from anywhere. The friction of context-switching is gone.
If you have a working local agent setup and a small VPS, this migration pays for itself in a single week of saved context switches. The hardest part is the discipline of pre-verification, not the shell commands.
Related Posts
KISS Architecture for AI Agent Stacks: One Provider, One Fallback
Every AI agent tutorial shows a dozen providers, six models, and three proxies. Here is the boring, reliable version: one provider that works, one fallback that probably does not.
When Your AI API Keys Die at 2 AM: A Recovery Playbook
Every API key expires eventually. Here is the exact sequence I follow when a provider goes dark: diagnose, isolate, switch, verify, document. No panic, no guessing.
Migrating from Docker Desktop to Native WSL Docker: A Complete Guide
Tired of Docker Desktop eating up your RAM? Discover how to migrate to a native WSL Docker setup on Ubuntu 24.04. This complete guide covers everything from installation and DNS tweaks to seamless Windows CLI integration for a leaner, faster dev environment