Eggi Satria Logo
Back to blog

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

June 20, 20268 min read5 views
linuxmigrationopencodevpsdevops
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.

powershell
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.

powershell
$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

powershell
$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.

bash
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.

bash
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.

bash
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

bash
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.

bash
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.

bash
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.

ini
[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.

Share this article:

Related Posts

Thanks for reading! If you found this helpful, feel free to share it.