← Back to writing
·11 min read

Self-Hosted AI Orchestration: How I Set Up Ruflo on a DigitalOcean VM for a Client

aidevopsself-hostedclaudedocker

This post is a full narrative of building a self-hosted AI orchestration environment for a client. Not the polished version — the real one, including every dead end and the diagnosis that got past it.

The end result was two capabilities running on a DigitalOcean VM (Ubuntu, 2 vCPU / 4 GB RAM):

  1. A live web-based chat UI, backed by Claude models via Ruflo's MCP bridge, secured with HTTPS and basic auth.
  2. A remote MCP connection so the client's Claude Code desktop can reach the VM's MCP bridge directly — agentic access to the project codebase without SSH.

The platform was Ruflo — an AI orchestration tool that bundles a chat UI fork (called ruvocal), an MCP bridge with 200+ tools, and a Claude Code orchestration layer. My fork with the changes described here is at github.com/thinktanktom/ruflo.


Phase 1 — Understanding Ruflo (and Where the Docs Fall Short)

The npm Path Doesn't Work

The documented installation path is:

npm install -g ruflo@latest
ruflo init

I ran this. It produced deprecation warnings (inflight, npmlog) and — critically — did not generate the claude-flow.config.json that the documentation said it would create. The config file simply wasn't there after init.

This was the first sign that the npm package had drifted significantly from the actual repo. Rather than trying to reconcile them, I pivoted to cloning the repo and running via Docker Compose, which is the intended production method.

The Repo Structure Doesn't Match the Docs

Once I was in the repo, the documented layout didn't match what was actually there:

What the docs suggestActual location
scripts/src/scripts/
src/chat-ui/src/ruvocal/ (a separate fork)
Config directory existsMust be manually created
Docker Compose at repo rootNested at ruflo/ruflo/docker-compose.yml
OPENAI_BASE_URL baked at build timeInjected at runtime via entrypoint.sh using DOTENV_LOCAL

The chat UI wasn't a standard HuggingFace Chat UI install — it was ruvocal, a fork. That distinction mattered because the build and runtime behaviour differed in ways that caused several of the issues below.


Phase 2 — Getting the Stack Running

Auth: Caddy Basic Auth Over Google OIDC

Ruflo's documentation points to Google OIDC as the auth method. I chose Caddy basic auth instead.

The reasoning: OIDC requires a Google Cloud OAuth app registration, callback URL configuration, and ongoing credential management. For a single-client deployment, basic auth is faster to implement, easier to hand over, and has no external dependency. If the setup needed to scale to more users, that decision could be revisited.

For the subdomain, I used DuckDNS rather than a paid domain. Caddy handles the Let's Encrypt certificate automatically once the subdomain resolves — no further TLS work needed.

Getting the Stack Up

Three issues needed resolving before the stack was cleanly operational.

Issue: Claude models not appearing in the UI. Once the UI was accessible, the model selector was empty. The cause: the mcp-bridge does not auto-discover models from API providers. You have to explicitly register models in a KNOWN_MODELS array inside the bridge's source code at src/ruvocal/mcp-bridge/index.js:

// src/ruvocal/mcp-bridge/index.js
const KNOWN_MODELS = [
  "anthropic/claude-sonnet-4-5",
  "anthropic/claude-opus-4",
  // add others here as needed
];

This is not mentioned prominently in any docs. The UI loads fine without it — it just shows no models to select. (commit dbc60d9)

Issue: Stale shell variables silently overriding .env. This was the most time-consuming issue of the entire setup. Docker Compose was being given placeholder API keys (sk-or-your-key-here) even though the correct keys were in the .env file. Models registered, UI accessible, but every API call failed.

The root cause was stale export statements in ~/.bashrc from an earlier configuration attempt. Docker Compose loads shell environment variables before .env files — and shell variables take precedence with no warning.

# Diagnosis
printenv | grep OPENROUTER
# Output: OPENROUTER_API_KEY=sk-or-your-key-here  ← stale export from ~/.bashrc
 
# Fix: remove the export lines from ~/.bashrc, then:
source ~/.bashrc
docker compose up -d --force-recreate

Docker Compose's environment variable resolution order is: (1) shell environment → (2) .env file → (3) docker-compose.yml defaults. Stale shell exports will always win over .env values, silently. (commit 068a072)

Issue: Chat UI hitting HuggingFace's servers. Before the full Docker stack was adopted, ruvocal was running directly with PM2. The .env.local was configured to route through OpenRouter, but the chat UI kept responding with:

"You've reached your message limit. Upgrade to Hugging Face PRO..."

The cause: the SvelteKit build had baked HuggingFace URLs in at compile time. Setting environment variables after the build had no effect — the built JavaScript already contained the hardcoded endpoint URL.

The fix was to set the correct .env.local first, then rebuild from source:

npm run build
pm2 restart chat-ui

Two additional variables were needed to fully disable HuggingFace's auth flow:

USE_USER_TOKEN=false
OPENID_CLIENT_ID=

In the Docker stack, this is handled correctly — PUBLIC_ORIGIN, model URLs, and API keys are injected at runtime via entrypoint.sh using the DOTENV_LOCAL mechanism, not baked at build time.


Phase 3 — Exposing the MCP Bridge

With the stack running, the next step was giving the client's Claude Code desktop direct access to the MCP bridge on the VM — without SSH, without a VPN.

Caddy Route with Bearer Token Auth

A /mcp-bridge/* route was added to the Caddyfile. Bearer token auth protects the endpoint; basic auth continues to protect the chat UI separately:

your-domain.example.com {
    # MCP bridge — Bearer token auth
    @mcp path /mcp-bridge/*
    handle @mcp {
        @no_token {
            not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
        }
        respond @no_token "Unauthorized" 401
        uri strip_prefix /mcp-bridge
        reverse_proxy mcp-bridge:3001 {
            transport http {
                keepalive 30s
                keepalive_idle_conns 10
            }
            flush_interval -1
        }
    }
 
    # Chat UI — basic auth
    basicauth {
        {$BASIC_AUTH_USER} {$BASIC_AUTH_HASH}
    }
    reverse_proxy nginx:3000
}

flush_interval -1 is required for Server-Sent Events streaming to work correctly through the proxy. (commit c34fde2)

Client-Side Configuration

The client adds a single entry to ~/.claude/settings.json on their machine:

{
  "mcpServers": {
    "ruflo": {
      "type": "http",
      "url": "https://your-domain.example.com/mcp-bridge/mcp",
      "headers": {
        "Authorization": "Bearer <token>"
      }
    }
  }
}

All 204 MCP tools — including the custom workflow tools added below — show up natively in their Claude Code session.


Phase 4 — Wiring Up the Client Workflow

The client's project is a complex multi-component system. To give Claude agents the right context for each type of work, 12 specialist skill files were set up in .claude/skills/, each covering a different engineering domain: cryptography, gRPC API, EVM bridge, DAG persistence, admin auth, and so on. (commit c1314e5)

The Workflow Runner

A bash script, run-lockbox-workflow.sh, orchestrates Claude Code in headless mode across 12 sequential development passes. Each pass targets a specific area, loads the relevant skill files as context, and writes a timestamped log.

# Run all 12 passes
bash run-lockbox-workflow.sh
 
# Resume from a specific pass
bash run-lockbox-workflow.sh 3 6

Execution features: PID file locking to prevent concurrent runs, environment validation (API key, claude CLI, project directory), auto-resume on max-turns limit, and status JSON written to /tmp/workflow-status.json. (commit a752e76)

Custom MCP Tools

Two tools were added to BUILTIN_TOOLS in mcp-bridge/index.js to expose the workflow to any connected Claude agent:

case "run_lockbox_workflow": {
  const startPass = params.start_pass || 1;
  const endPass = params.end_pass || 12;
  const scriptPath = "/home/deploy/ruflo/ruflo/run-lockbox-workflow.sh";
  spawn("bash", [scriptPath, String(startPass), String(endPass)], {
    detached: true,
    stdio: ["ignore", fs.openSync("/tmp/lockbox-nohup.log", "a"), "ignore"],
  }).unref();
  return { content: [{ type: "text", text: `Workflow started (passes ${startPass}${endPass})` }] };
}
 
case "get_workflow_status": {
  const statusFile = "/tmp/lockbox-workflow-status.json";
  const logFile = "/tmp/lockbox-workflow.log";
  try {
    const status = JSON.parse(fs.readFileSync(statusFile, "utf8"));
    const logLines = fs.readFileSync(logFile, "utf8").split("\n").slice(-10).join("\n");
    return { content: [{ type: "text", text: JSON.stringify({ ...status, recentLog: logLines }) }] };
  } catch {
    return { content: [{ type: "text", text: "No workflow status found. Has a run been started?" }] };
  }
}

Both are registered in the core group so they're always active. One note: status files live in /tmp/ and won't survive a container restart — a more robust setup would write them to a mounted volume.

The Workspace Mount

The project directory is mounted into the mcp-bridge container at /workspace/project — without :ro, so agents can write files. Before enabling this, Git tracking was turned on inside the workspace. Any AI-made file changes show up as uncommitted diffs, giving the client a clear audit trail and an easy rollback path.

If you're giving an agent write access to a codebase, version control is the minimum viable safety net.


Security Posture

This is the section most self-hosted AI writeups skip. I'm not going to.

Is a bearer token a secure handover mechanism?

It's acceptable but not great. A static shared secret has two structural weaknesses: it never expires unless you manually rotate it, and it grants the same level of access every time it's used, from anywhere, forever. There's no per-session audit trail and no way to revoke it without changing it for everyone simultaneously.

A meaningfully better option is short-lived tokens — the client exchanges a long-lived credential for a time-bounded JWT before each session. If that token leaks it expires on its own. For a single trusted client on a fixed machine, that's non-trivial to set up.

A simpler improvement that requires no new infrastructure: if the client has a static IP, restrict the route to it in the Caddyfile:

handle /mcp-bridge/* {
    @wrong_ip not remote_ip 1.2.3.4
    respond @wrong_ip 403
 
    @unauthorized not header Authorization "Bearer {$MCP_BEARER_TOKEN}"
    respond @unauthorized 401
 
    uri strip_prefix /mcp-bridge
    reverse_proxy mcp-bridge:3001
}

A leaked token is useless from any other IP.

Blast radius if the token leaks

The honest answer: it's large. The mcp-bridge exposes 204+ tools including devtools, agents, memory, intelligence, and terminal_execute. Anyone with the token can:

  • Execute arbitrary shell commands on the VM as the Docker process user
  • Read and write anything in the mounted project workspace
  • Read the container's environment variables — which include your Anthropic and OpenRouter API keys
  • Drain those API keys by sending requests through the bridge
  • Exfiltrate the entire codebase

The API key exposure is probably the most immediately damaging. The right mitigation is to scope down what the remote Claude Code connection can actually do. The client doesn't need terminal execution or agent spawning — they need file access and the workflow trigger tools. Separate the client-facing route and restrict it to safe tool groups:

# Remote client — restricted to core tools only
handle /mcp-bridge/client/* {
    @unauthorized not header Authorization "Bearer {$CLIENT_TOKEN}"
    respond @unauthorized 401
 
    uri strip_prefix /mcp-bridge/client
    reverse_proxy mcp-bridge:3001
}

Two separate tokens — one for the internal chat UI, one for the remote client — means you can revoke them independently.

Git push access from the VM

The workspace mount gives the mcp-bridge container write access to the project directory on the VM's filesystem. I audited whether the VM could also git push to the remote repository — checking for SSH keys and credential helpers — and removed the credentials. The VM can read and modify files locally, but it cannot push to the remote repo. Any AI-generated changes have to go through a pull request like everything else.

Hardening checklist

In order of impact:

  1. Restrict /mcp-bridge/* to the client's static IP — eliminates the blast radius of a leaked token with no new infrastructure.
  2. Remove git push credentials from the VM — done; AI changes require a PR like everything else.
  3. Use two separate tokens — one for the chat UI, one for the remote client — so they can be revoked independently.
  4. Scope the client-facing MCP route to safe tool groups — no terminal_execute, no agents.
  5. Enable Caddy access logging — so you have an audit trail of every request that hits the bridge.
  6. Rotate the bearer token on a schedule — monthly at minimum, immediately if the client's machine is compromised.

The Things That Will Bite You

  1. npm install -g ruflo@latest won't generate the config file — go straight to Docker Compose.
  2. The Docker Compose file is at ruflo/ruflo/, not the repo root — check before running docker compose up.
  3. Models must be manually added to KNOWN_MODELS in mcp-bridge/index.js — they don't auto-discover.
  4. Stale export lines in ~/.bashrc silently override .env — run printenv | grep <key> before debugging API calls.
  5. The SvelteKit build bakes URLs at compile time — set .env.local before npm run build, not after.