DEV Community

Cover image for Hybrid Container Governance: Scaling Patterns That Work
Ev3lynx727
Ev3lynx727

Posted on

Hybrid Container Governance: Scaling Patterns That Work

Hook

The third WSL instance didn't need a reinstall. That's when I knew the pattern worked.

WSL 1 had the full stack — six ark-* MCP servers, oh-my-mcp gateway, server-commands-rtk, all configured, tested, running. WSL 2 was planned for the same setup. So was WSL 3. Each one would need npx @ev3lynx/oh-my-mcp, the npm dependencies, the config files, the supergateway wrappers, the port allocations, the CLI bridge, the permission schema in opencode.jsonc.

I started scripting the install. Halfway through, I stopped and deleted the script.

Installing the same stack N times is the wrong answer. The right answer is one installation, served.

The Container Decision

The architecture we'd built was already multi-process — oh-my-mcp managing six child servers, each with its own port, its own lifecycle, its own restart budget. The step from "multi-process on one machine" to "multi-WSL via one container" was smaller than expected:

FROM ghcr.io/anomalyco/opencode:dev-debian

# oh-my-mcp gateway
RUN npm install -g @ev3lynx/oh-my-mcp

COPY supervisord.conf /etc/supervisor/conf.d/opencode-stack.conf

CMD ["supervisord", "-c", "/etc/supervisor/conf.d/opencode-stack.conf"]
Enter fullscreen mode Exit fullscreen mode

The container runs two supervised processes:

  • opencode serve --hostname 0.0.0.0 --port 4096
  • oh-my-mcp --port 8090 (management API on 8080)

Ports exposed: 4096 (OpenCode SSE), 8080 (management), 8090 (MCP gateway).

Volume mounts for persistence:

volumes:
  - ~/.config/opencode:/home/ev3lynx/.config/opencode
  - ~/.local/share/opencode:/home/ev3lynx/.local/share/opencode
  - ~/server:/home/ev3lynx/server
  - ~/.ssh:/home/ev3lynx/.ssh
  - /var/run/docker.sock:/var/run/docker.sock
Enter fullscreen mode Exit fullscreen mode

From any WSL instance: docker exec -it opencode-stack opencode. Same env, same config, same servers. Zero npm installs per distro.

The Poorly-Designed Alternative

But I didn't start with a container. I started the wrong way.

The first WSL instance got the full manual setup: npx @ev3lynx/oh-my-mcp install, fnm multishell resolution, port allocation for each ark server, supergateway wrappers for every stdio→SSE bridge, systemd user service registration, and a fire-and-forget shell script that was already one edit away from breaking silently.

When WSL 2 needed the same thing, I ran the script again. It failed because systemctl --user doesn't behave the same across WSL instances — Debian's systemd vs Alpine's OpenRC vs Ubuntu's snap-wrapped systemd. The paths were different. The fnm node version was different. The /run/user/1000/ socket path was absent on the second instance because the user ID was 1001.

I spent an afternoon fixing environmental drift between two WSL instances that were supposed to be identical. I caught myself debugging the wrong thing: not the architecture, not the code — but the installation path.

That was the moment the container decision made itself. Not because containers are trendy. Because I was debugging installation scripts instead of shipping features.

The Conflict

The tension isn't technical. The container is trivially simple — a Dockerfile, a docker-compose.yml, a volume mapping. The tension is architectural: we had built an MCP server ecosystem without a deployment story. Every server in the ark-* family was designed as a standalone process with clear boundaries (focused tools, independent lifecycle). But standalone doesn't mean deployable. A process without a reproducible environment is a test that passes on your machine.

Here's the voice that argues against it:

"It's three WSL instances. You can script the install. Docker adds overhead, a learning curve for the next person, another moving part. A Makefile and a README note is cheaper."

This voice isn't wrong — it's looking at the right cost but the wrong time horizon. At WSL 3, the Makefile is still fine. At WSL 5, when they're different distros (Debian, Ubuntu, Alpine, Fedora, Arch), each with different systemd quirks, different package managers, different default shells — the Makefile becomes a liability audit. At a CI runner and a team member's machine and a deployment target, it's a full-time maintenance job.

The cost of the container is paid once. The cost of environment-specific setup scripts compounds with every new target.

The Governance

Six servers is a manageable number. Sixteen is not — unless you have a pattern that makes each one predictable.

Every ark-* server follows the same template:

  1. A focused tool set — 3-8 tools, one domain (memory, gists, exec, resolve, delegation)
  2. Supergateway stdin→SSE wrapper — port 8100-series, registered in oh-my-mcp
  3. Standalone process — systemd or supervised, never a plugin
  4. cacache or sqlite storage — disk-backed, survives restart
  5. Cache stats + prune tools — self-maintaining, no cron needed

Adding a new server is five files, not five hours:

server/ark-<name>/
├── package.json         # @ev3lynx/ark-<name>
├── tsconfig.json        # extends base
├── src/server.ts        # tool handlers
└── .github/workflows/   # CI (ci.yml, publish.yml)
Enter fullscreen mode Exit fullscreen mode

Build → oh-my-mcp add --path server/ark-<name> → registered. No config file edit. No port conflict check. The gateway discovers it, assigns a port, and routes traffic.

The governance rule that handles 90% of decisions:

  • Would this tool be used on most sessions? → Add it to an existing focused server (3→8 tools)
  • Would this tool be used rarely or by a specific client? → New ark-* server (1 domain, 3-8 tools)
  • Is this a write-once-read-never operation? → CLI bridge, no server needed

What the Container Makes Possible

The container stack isn't just about deployment repeatability. It changes the capability surface entirely:

From any WSL instance: docker exec -it opencode-stack opencode — same servers, same config, same memory graph. Sessions are portable across distros. Switch from Debian to Alpine to Arch without losing connection state.

From Windows: ark-delegator resolves via http://localhost:8106 — the port is mapped through Docker and accessible from the Windows host. The Windows OpenCode client sees the same MCP servers as every WSL instance. No network bridging. No WSL path translation.

From CI: The same container image. docker run opencode-stack opencode eval "run tests" — CI gets the exact same tool set as interactive sessions. No drift between development and pipeline environments.

This is the pattern that survives: one image, many clients, zero per-target configuration.

The Closing

The Makefile argument was right about the cost — just wrong about which cost compounds. The container is heavier on setup day. It's lighter on every subsequent day. And on the day WSL 5 shows up with a package manager you've never heard of, the container costs nothing at all.

The stack: oh-my-mcp gateway + commands-rtk CLI bridge. The template: five files per ark-* server. The rule: one image, many clients — install once, exec everywhere.

Build tools that survive restarts. Build processes that survive machine boundaries. Build stacks that don't care which WSL distro the terminal opened today.

Top comments (0)