Skip to main content
LabsArchitecture Notes6 min read

Architecture Notes: One VPS, Rootless Everything — The FR4M3W0RK Household Platform

DockerWireGuardLibreChatn8nrestic
Architecture Notes: One VPS, Rootless Everything — The FR4M3W0RK Household Platform
Six services, two of them gears, and no robot head. Z-Image-Turbo 1.0 6-bit apparently believes every unattached process is a configuration problem. It is not wrong.

FR4M3W0RK is my household self-hosting platform: a single Debian VPS running an AI chat platform with retrieval and custom tool servers, a conversational data-extraction app, workflow automation, uptime monitoring, and two LLM-powered butler agents reachable over Telegram as two bot identities. Everything runs as rootless Docker containers under an unprivileged user, all administration flows through a WireGuard tunnel, and the whole stack is defined declaratively in a Git repository with per-stack Compose files and encrypted offsite backups.

The platform's most important design decision is what it doesn't have: no orchestrator, no Kubernetes, no CI/CD pipeline, no private registry. A single box, six independent stacks, and procedures one person can understand end to end.

The shape of the system

Six independent Docker Compose stacks, each in its own directory with its own .env file and volumes, connected through one shared external Docker network that the reverse proxy owns. The infra repository is a thin parent; the application code for three of the stacks lives in private Git submodules, pulled onto the server by per-repo read-only deploy keys.

The component inventory:

ComponentWhat it isRole
Reverse proxynginx-proxy-managerTLS termination, per-subdomain routing; admin UI bound to the tunnel interface only
AI chat stackLibreChat, MongoDB, Meilisearch, pgvector + RAG APIChat UI and API with document retrieval
Custom MCP serversTwo in-house tool servers (private submodule)Document generation and image/video media generation, delivered to the chat stack via named volumes
Interview appNode.js (Hono) + PostgreSQLConversational data extraction with SSE streaming; dedicated isolated database
Automationn8nCalendar, tasks, and spreadsheet workflows; webhooks called by the agent stack
MonitoringUptime Kuma + NetdataService uptime checks with 2FA; host metrics UI reachable only over the tunnel
Household agentsOpenClawTwo Telegram butler agents plus a sandboxed read-only research agent; a policy plugin gates sensitive actions
Backupsmongodump/pg_dump + volume-export sidecar → restic → rclone → cloud driveDaily systemd user timer; 7 daily + 4 weekly snapshots retained

Three planes

The clearest way to read the architecture is as three traffic planes, each with a different trust boundary.

The public plane. Public requests hit a CDN (DNS-proxied) and forward to the VPS on the web ports. A reverse proxy terminates TLS with a CDN origin certificate and routes each public subdomain to a container by its Docker DNS name on the shared network. Only web-facing services join that network — joining the proxy network is the only way a container becomes publicly reachable, so the exposure decision is made per stack, deliberately.

The admin plane. SSH, the proxy's admin UI, and the host metrics UI never touch the public internet. All of it flows through a WireGuard tunnel terminated in the host kernel, with the firewall enforcing interface-scoped rules: web ports on the public interface, admin ports bound to the tunnel interface only. The public attack surface for shell access is a UDP port that requires a cryptographic handshake before anything else happens. That story — including the incident that shaped it — is its own post: VPN-only SSH and the anti-lockout runbook.

The internal data plane. Databases don't join the proxy network. Each stack keeps its stateful services on per-stack internal bridges that the proxy cannot reach — the chat stack's MongoDB and Meilisearch, the interview app's Postgres, all isolated per stack. Agent-to-automation traffic (butlers → n8n) also stays internal, signed with HMAC-SHA256 rather than protected by network position alone. The reasoning behind that signing decision is covered in the prompt-injection hardening post.

The host model

Three users, with exactly one privileged path between them:

  • root — no SSH access at all.
  • a sudo admin user — SSH reachable only via the tunnel, key-only.
  • appuser — no sudo, no SSH. Owns the rootless Docker daemon and every workload, including the backup pipeline.

Running the entire workload under an unprivileged user removes the classic container-escape-to-root-daemon scenario. Every container sets no-new-privileges:true; the agent container additionally drops NET_RAW and NET_ADMIN and publishes no inbound ports — it reaches its bot identities, its LLM provider, and the automation webhook via outbound connections only.

Secrets live exclusively in per-stack .env files (mode 0600, gitignored), and the repository ships .env.example templates documenting every required key — so the configuration surface is reviewable without exposing values.

How it was provisioned

The system was stood up manually, following a 14-phase walkthrough, deliberately hand-executed rather than automated. The sequence: base system and the unprivileged user; WireGuard; SSH lockdown; kernel tuning; rootless Docker; per-repo deploy keys; firewall; stacks; reverse proxy config; monitoring; backups. Two details worth calling out because they're easy to miss:

  • Kernel tuning interacts with the tunnel. Loose-mode reverse-path filtering is required for the tunnel's NAT'd asymmetric routing — strict mode silently breaks it.
  • Rootless Docker still needs to bind the web ports. A single setcap on the rootless runtime lets the unprivileged daemon bind ports 80/443 for the public proxy; nothing else gets that treatment.

Day-2 operations stay manual on purpose. Image tags are pinned (not digests), bumps are recorded in a changelog, and updates are a pull and up after release-note review. A pre-deploy backup is mandatory before every deploy, and a short household smoke-test list runs after restarts. There's no Watchtower, no automatic anything.

Ops philosophy: household-scale, deliberately

The repository states the maintenance goal explicitly: procedures one person can understand and execute. The proxy's admin GUI is the authoritative source of truth for routing — pulling its generated config into Git would break certificate management, so it stays operational rather than declarative. Application changes flow through reviewed submodule commits. And every operational surprise gets written into an ops-notes document as symptom → cause → fix.

For a platform of this size, documentation is the automation. The two systems that needed deeper writeups of their own are the agent hardening pass and the SSH and lockout runbook. The butlers' longer history — how this agent platform was chosen after several failed migrations — is recorded in the Agent Notes series, starting with three failed agent migrations and the shared-soul butler design.