Pluggable Deploy Backends: Docker, VMs, and Nothing at All
Part of the AI Software House series.
In short: My deploy-and-test stage quietly assumed every project used Docker. I replaced that assumption with a
DeployBackendinterface, so the same pipeline can use Docker, a clean libvirt VM, or no deployment step at all.
Before a generated PR gets merged, I want more than passing unit tests. I want the pipeline to prove that the application can actually start.
For a while, my definition of "starts" was simply "docker compose up doesn't fail." That happened to match the first few projects. It stopped matching as soon as I tried a project without Docker, a host where Docker was unavailable, and a target that needed a full VM.
The pipeline had confused the job—deploy and test—with one particular way of doing it.
The problem with a single deploy path
The DeploymentTesterAgent was really doing two jobs:
- Orchestrating the test — calling the backend, capturing output, deciding pass/fail
- Knowing how to deploy —
subprocess.run(["docker", "compose", "up", "-d"])
The second part was hardcoded. There was no way to change the deployment mechanism without changing the agent. Running tests on a bare-metal server, a libvirt VM, or a local process required forking the code or adding if branches.
The abstraction
DeployBackend is an abstract base class with three methods:
class DeployBackend(ABC):
@abstractmethod
def deploy(self, project_dir: str) -> DeployResult:
"""Start the application."""
@abstractmethod
def test(self, project_dir: str) -> DeployResult:
"""Run the test suite against the deployed application."""
@abstractmethod
def teardown(self, project_dir: str) -> None:
"""Stop and clean up."""
Every deployment mechanism implements those three methods. The agent only sees the interface.
DeployResult carries what the agent needs:
@dataclass
class DeployResult:
success: bool
output: str = ""
error: str = ""
Three backends
NoneBackend — does nothing, always succeeds. Used during development and for pipelines where deployment isn't relevant:
class NoneBackend(DeployBackend):
def deploy(self, project_dir: str) -> DeployResult:
return DeployResult(success=True, output="NoneBackend: no-op")
def test(self, project_dir: str) -> DeployResult:
return DeployResult(success=True, output="NoneBackend: tests skipped")
def teardown(self, project_dir: str) -> None:
pass
This is the default. If you don't configure a backend, you get NoneBackend. The deploy and test stages still run — they just always pass, and the pipeline continues.
DockerBackend — wraps docker compose:
class DockerBackend(DeployBackend):
def deploy(self, project_dir: str) -> DeployResult:
result = subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=project_dir, capture_output=True, text=True,
)
return DeployResult(
success=result.returncode == 0,
output=result.stdout,
error=result.stderr,
)
def teardown(self, project_dir: str) -> None:
subprocess.run(["docker", "compose", "down"], cwd=project_dir)
The agent passes the cloned project directory. The backend brings up the stack, runs the tests, and tears it down. The agent never touches Docker directly.
LibvirtBackend — for VM-based environments. Clones a base snapshot, boots the VM, runs commands via SSH, then destroys the clone:
class LibvirtBackend(DeployBackend):
def __init__(self, base_snapshot: str, ssh_host: str, ssh_user: str = "root"):
self.base_snapshot = base_snapshot
self.ssh_host = ssh_host
self.ssh_user = ssh_user
self._clone_name: Optional[str] = None
def deploy(self, project_dir: str) -> DeployResult:
self._clone_name = f"test-clone-{uuid.uuid4().hex[:8]}"
# Clone the base snapshot
subprocess.run(["virsh", "snapshot-revert", self.base_snapshot, "--running"])
subprocess.run(["virt-clone", "--original", self.base_snapshot,
"--name", self._clone_name, "--auto-clone"])
# Copy project files to the VM
subprocess.run(["rsync", "-a", project_dir,
f"{self.ssh_user}@{self.ssh_host}:/tmp/project/"])
return DeployResult(success=True, output=f"VM clone {self._clone_name} ready")
Each test run gets a fresh VM. There's no contamination between pipeline runs — the base snapshot is read-only, and the clone is destroyed in teardown().
The factory
One function maps config to backend:
def build_backend(config: dict) -> DeployBackend:
kind = config.get("backend", "none")
if kind == "none":
return NoneBackend()
elif kind == "docker":
return DockerBackend()
elif kind == "libvirt":
return LibvirtBackend(
base_snapshot=config["base_snapshot"],
ssh_host=config["ssh_host"],
ssh_user=config.get("ssh_user", "root"),
)
else:
raise ValueError(f"Unknown deploy backend: {kind!r}")
The orchestrator calls build_backend() at startup. The DeploymentTesterAgent receives the backend as a constructor argument.
Configuration
In config.yaml:
deploy:
backend: docker # none | docker | libvirt
# For libvirt:
# deploy:
# backend: libvirt
# base_snapshot: ubuntu-24-clean
# ssh_host: 192.168.100.10
# ssh_user: ubuntu
Changing backend is now just a config edit. The pipeline YAML and agent code stay the same.
What changed in the agent
The DeploymentTesterAgent had a section that looked roughly like:
# Before
result = subprocess.run(["docker", "compose", "up", ...], ...)
if result.returncode != 0:
return PipelineResult(verdict="failed", ...)
After:
# After
deploy_result = self._backend.deploy(project_dir)
if not deploy_result.success:
return PipelineResult(verdict="failed", output=deploy_result.error, ...)
The control flow is the same. The mechanism is hidden behind the interface. Adding a new backend — a Kubernetes deployment, a systemd service, a bare make test — requires implementing three methods, not reading the agent code.
The VM model: clean images
The libvirt base snapshot is deliberately plain: a clean OS image with no project files, previous build output, or cached dependencies. Every run starts from the same place.
This is different from Docker, where layer caching can hide environment issues. A container that builds successfully on a cached base image might fail on a clean machine. The VM approach eliminates this class of false positives at the cost of slower startup — booting a VM takes longer than pulling an image.
For projects where environment fidelity matters more than speed, LibvirtBackend is the right choice. For projects where fast iteration matters more, DockerBackend works better. For projects where deployment testing isn't the bottleneck, NoneBackend keeps the pipeline moving.
The configuration choice reflects a tradeoff, not a hierarchy.
What I took from this refactor
This ended up looking a lot like the LLM relay and the configurable pipeline. In each case, I found a choice that had been hardcoded early, put the changing part behind a small interface, and moved the final choice into configuration.
Each of these made the system more capable without changing the pipeline abstraction that sits above them. The orchestrator didn't need to learn about Docker or libvirt. The agent didn't need to change. The configuration changed.
For me, that is the useful test for "pluggable": the caller neither knows nor cares which implementation is underneath it.
Related reading: From Pipeline to API: The REST and MCP Integration Layer
Related reading: Knowing What's Broken: Prometheus Metrics and an 88% Test Coverage Sprint