Don't containerize the agent (and don't hide it in a tmux either)

Two deployment mistakes I made in one afternoon, and the boring setup that replaced both.

War story 1: I almost ran a server inside byobu

My first instinct was to start opencode serve in a byobu/tmux session and detach. It works — until you think about it. byobu is an interactive workspace. It is not a supervisor:

  • no restart when the process dies
  • no start at boot
  • it dies with the session

If a long-running service needs “a terminal someone remembers to keep open,” that’s the signal it should be a systemd unit. A commenter put it perfectly: “If your README says ‘open a tmux session and run this,’ that process wants to be a service.”

The correction: systemd, not byobu. Which systemd, though?

War story 2: my containerization instinct was wrong twice

My reflex was “containerize it.” Then I listed what the server actually needs:

NeedContainer implication
project trees under /home/<user>/projects and a second mountbind mounts at identical paths, or every session path breaks
run shell, git, LSPs, MCP toolsevery toolchain baked into the image
~/.config/opencode + the shared 1.2 GB opencode.db + auth.jsonmore mounts, shared DB with host TUIs
LAN model servers, MCP gateway, SSH, docker stacksdocker.sock (a trivial root escape), key mounts

There’s no isolation to be had: the moment you grant the server what it needs, it’s “host access with extra mount plumbing.”

But the stronger argument is version skew. Every opencode instance on a box shares one config and one database, and migrations run on version change. A container built from a different opencode version than the host TUIs can migrate that shared state into a shape the other can’t read. Corruption.

Rule I landed on: every opencode instance sharing one DB/config must be the same version. Since my TUIs are native, the server must be native too.

The useful reframe: the bridge (the thing that talks to chat channels) is a good container — it’s thin, HTTP-only, and needs no project mounts. The server is not. Process-to-process over HTTP means the bridge never touches your files; only the server does.

bridge container ──HTTP(x-opencode-directory)──► native server ──► projects
   (thin, no mounts)                              (owns the files)

The boring setup that won

A native systemd unit, running as the same user as my other sessions:

[Service]
ExecStart=%h/.opencode/bin/opencode serve --hostname 0.0.0.0 --port 4096
EnvironmentFile=%h/.config/opencode/server.env
Restart=always
RestartSec=3

Two subtleties:

  1. System unit vs user unit. A user unit (in ~/.config/systemd/user/) needs loginctl enable-linger <user> or it dies at logout — a headless box doesn’t keep your session alive. I enabled linger and kept everything in my home directory, no root needed for the day-to-day.
  2. Hardening you can’t apply. The usual ProtectHome=yes / ProtectSystem=strict would break the server — it must read and write your project trees. For a single-user homelab, “run as a user” is about identity sharing (same config/DB/auth), not privilege separation.

Takeaways

  • A daemon belongs to systemd, not a terminal multiplexer.
  • Don’t containerize the thing that needs host access; containerize the thin thing that talks to it.
  • Shared config + shared DB = shared version. This one bites silently.
  • Linger=yes is the difference between “runs at boot” and “runs until you log out.”