April 2, 2026 — Matteo Merola
Running a Full Agent Stack in a Single Container
Architecture decisions that make it possible to run everything in one Docker container.
The conventional wisdom is that each concern gets its own service. Web server, database, task queue, model inference — four containers minimum, plus orchestration to keep them talking. For a personal agent that one person runs on a VPS, this is overkill. humux ships as a single Docker image that bundles LLM orchestration, speech-to-text, text-to-speech, email, calendar, browser automation, and a web admin UI. Here’s how, and the tradeoffs involved.
SQLite over Postgres
The single biggest decision that enables the single-container approach is the database. Postgres is fantastic software, but it’s also an entire server: its own process tree, its own memory management, its own configuration surface. For a personal agent with one concurrent user, SQLite in WAL (Write-Ahead Logging) mode gives you everything you need.
WAL mode allows concurrent readers while a single writer commits. Since the agent’s write pattern is “one conversation turn at a time,” writer contention is effectively zero. Meanwhile, the admin UI can read chat history, the scheduler can read job definitions, and the memory system can query embeddings — all without blocking each other.
humux uses four separate SQLite databases rather than one monolith:
Separate databases mean separate WAL files, separate lock domains, and separate backup granularity. You can cp memory.db memory.db.bak without pausing chat writes. Each database is small enough that VACUUM takes milliseconds. And migration is straightforward: each database has its own schema version tracked in a _meta table.
Python + CLI Architecture
The second key decision is how to handle protocol-specific work. Email via IMAP/SMTP is complex. Browser automation needs a full Chromium instance. Voice transcription requires native C++ inference. Embedding all of these as Python libraries would mean a massive dependency tree, C extension build headaches, and version conflicts.
Instead, humux uses a Python orchestrator that shells out to purpose-built CLI tools:
async def run_command(cmd: list[str], timeout: int = 30) -> str: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for( proc.communicate(), timeout=timeout ) if proc.returncode != 0: raise ToolError(stderr.decode()) return stdout.decode()
Email goes through Himalaya, a Rust CLI that handles IMAP, JMAP, and SMTP. Browser automation runs through Playwright’s CLI. Each tool is a statically linked binary (or self-contained runtime) that the Docker image bundles. Python never touches email parsing or browser DevTools directly — it just assembles the command, runs it, and parses the output.
This architecture has a real benefit: each CLI tool can be tested, updated, and replaced independently. When Himalaya ships a new release, you update the binary. No Python dependency graph to untangle.
Process Management Without a Process Manager
A common pattern in multi-service containers is to use supervisord or s6 as PID 1, spawning and watching child processes. humux doesn’t need this because Python’s asyncio is already a cooperative scheduler. The main process runs:
async def main(): async with TaskGroup() as tg: tg.create_task(start_web_server()) # FastAPI admin UI tg.create_task(start_telegram_polling()) # per-agent bots tg.create_task(start_scheduler()) # cron-like jobs tg.create_task(start_cli_server()) # Unix socket for CLI
TaskGroup (Python 3.11+) gives you structured concurrency: if any task crashes with an unhandled exception, the group cancels the others and propagates. This is actually better than supervisord for this use case — you get clean shutdown semantics for free, and all tasks share the same event loop, which means they can share in-memory state like the agent registry without IPC.
Resource Budgeting
The trickiest part of the single-container approach is memory. The web server and Telegram poller are cheap — a few MB each. But loading a Whisper model for speech-to-text can consume 200MB–1.5GB depending on the model size. Loading it at startup when most users may never send a voice message is wasteful.
humux uses lazy loading: the Whisper model loads on the first voice message and stays resident. The model size is selected based on available memory:
import psutil def select_whisper_model() -> str: avail_gb = psutil.virtual_memory().available / (1024**3) if avail_gb >= 4: return "small" # ~460MB, best accuracy if avail_gb >= 2: return "base" # ~140MB, good tradeoff return "tiny" # ~75MB, adequate for clear audio
TTS is lighter. Kokoro 82M uses about 350MB but generates speech much faster than real-time, so inference is bursty: it allocates, generates a clip, and releases. In practice, voice and LLM API calls rarely overlap because they’re part of the same turn pipeline: transcribe → think → synthesize.
The Data Directory
Everything stateful lives under a single mount point:
services: humux: image: ghcr.io/mattmezza/humux:latest volumes: - ./data:/app/data ports: - "8000:8000"
Inside /app/data you’ll find the SQLite databases, agent workspaces (where the coding harness writes files), voice cache (generated TTS clips, keyed by text hash), skill definitions, and configuration. Backup is tar czf backup.tar.gz ./data. Restore is the reverse. No pg_dump, no redis-cli, no multi-step coordination.
When to Split
The single-container approach works because of three properties: single user, moderate throughput, and stateful-by-nature workloads. It stops working when any of these change.
High voice throughput is the first bottleneck. If you’re processing dozens of voice messages per minute, the Whisper model will saturate a single CPU. At that point, you want a dedicated transcription service that can scale horizontally — or a GPU-backed instance.
Multi-user deployments break the SQLite assumption. WAL mode supports one writer at a time. With multiple users making concurrent writes, you’ll hit SQLITE_BUSY. This is where Postgres earns its keep.
Horizontal scaling isn’t possible with a single container by definition. If you need the agent to survive a node failure, you need replication, which means external state stores and a load balancer.
For a personal agent, none of these apply. One container, one volume, one backup. The operational surface area is minimal, and that’s the point. You spend your time teaching the agent new skills, not debugging Kubernetes manifests.
humux runs the full agent stack in a single container out of the box. Try it →