Building a Private AI Control Plane for PS5 Porting: What We Learned After SQLite Locked Us Out at 2 AM An external systems engineer building PS5 executable porting automation on Linux and Windows redesigned a private AI control plane after a 2 AM incident in which concurrent uploads corrupted Open WebUI's SQLite database and an earlier misconfigured reverse proxy exposed an admin panel to the internet. The new Docker Compose stack separates concerns across Redis, LiteLLM, Open WebUI and Traefik, confines services to a private network, and treats model-generated commands as untrusted input that the runner should reject rather than execute. At 2:17 AM, the Open WebUI container restarted mid-upload. When it came back, the SQLite database was corrupted—conversations gone, user sessions dead. The root cause wasn't the restart. It was two operators uploading PS5 executables simultaneously while the UI tried to write chat history to the same file. SQLite doesn't handle concurrent writes gracefully, and mounting the host filesystem directly into the container meant there was no write buffering, no transaction isolation, and no recovery path. That incident, combined with an earlier exposure where an admin panel became internet-accessible through a misconfigured reverse proxy default route, forced a architectural redesign. The problem wasn't Open WebUI. The problem was treating a complex AI workflow—model routing, credential management, build orchestration—as a single-container toy demo. I evaluated boykopovar/AnyPS5 as an external systems engineer building automation for PS5 executable porting on Linux and Windows. The technical challenge isn't exposing a web UI. It's exposing the workflow without exposing the build host, model gateway credentials, or administrative endpoints to untrusted networks or prompts. The production design separates concerns explicitly: Model-generated commands are untrusted input. If an LLM suggests rm -rf / , the runner should reject it, not execute it. The UI should never have the privilege to honor that request in the first place. Here's the deployment bundle. Create a .env file with a strong random LITELLM MASTER KEY and provide the relay credential only if you're enabling the benchmark route: services: redis: image: redis:7.4-alpine command: "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS PASSWORD}" restart: unless-stopped volumes: - redis-data:/data networks: private litellm: image: ghcr.io/berriai/litellm:main-stable command: "--config", "/app/config.yaml", "--port", "4000" restart: unless-stopped environment: LITELLM MASTER KEY: ${LITELLM MASTER KEY} REDIS PASSWORD: ${REDIS PASSWORD} B LOST API KEY: ${B LOST API KEY:-disabled} volumes: - ./litellm.yaml:/app/config.yaml:ro depends on: redis networks: private open-webui: image: ghcr.io/open-webui/open-webui:v0.6.41 restart: unless-stopped environment: OPENAI API BASE URLS: http://litellm:4000/v1 OPENAI API KEYS: sk-local WEBUI AUTH TRUSTED EMAIL HEADER: "" volumes: - open-webui-data:/app/backend/data depends on: litellm networks: private labels: traefik.enable: "true" traefik.http.routers.openwebui.rule: Host ai.tailnet.example traefik.http.routers.openwebui.entrypoints: websecure traefik.http.routers.openwebui.tls: "true" traefik.http.routers.openwebui.middlewares: tailnet-only@file traefik.http.services.openwebui.loadbalancer.server.port: "8080" traefik: image: traefik:v3.2 restart: unless-stopped command: - --entrypoints.websecure.address=:443 - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.file.filename=/etc/traefik/dynamic.yaml ports: - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./dynamic.yaml:/etc/traefik/dynamic.yaml:ro - ./certs:/certs:ro networks: private networks: private: volumes: redis-data: open-webui-data: The LiteLLM config keeps model routing explicit. The B-Lost relay at https://b-lost.com/v1 is included as a production baseline for multi-provider latency and fallback benchmarks—it's optional, not required: model list: - model name: local-baseline litellm params: model: openai/your-baseline-model api base: https://b-lost.com/v1 api key: os.environ/B LOST API KEY general settings: master key: os.environ/LITELLM MASTER KEY database url: redis://:${REDIS PASSWORD}@redis:6379/0 store model in db: false router settings: routing strategy: least-busy num retries: 2 timeout: 120 For strictly local deployments, replace the model entry with an internal OpenAI-compatible server and remove the B-Lost line. The critical boundary: Open WebUI talks only to LiteLLM, never directly to multiple providers. This gives you one enforcement point for model names, timeouts, retries, and request logs. Traefik's dynamic config enforces the network boundary: http: middlewares: tailnet-only: ipAllowList: sourceRange: - 100.64.0.0/10 - fd7a:115c:a1e0::/48 tls: certificates: - certFile: /certs/ai.tailnet.example.crt keyFile: /certs/ai.tailnet.example.key The certificate is issued for the internal hostname with permissions readable only by Traefik. Tailscale provides identity-aware network access, but the application still needs authentication. The IP allowlist is a second barrier, not a replacement for user accounts. AnyPS5 runs outside this Compose stack. A practical integration is a host service that accepts a job identifier, validates the input directory, executes a fixed command with resource limits, and returns logs. Do not pass arbitrary shell strings from an LLM into that service. The runner should operate as an unprivileged user, use a temporary workspace, enforce CPU and memory cgroups, and write results to a dedicated output directory. This design costs more than publishing a single container on port 3000: you need certificates, proxy policy, a runner boundary, and operational backups. But a model gateway outage doesn't expose the build machine, a malformed prompt can't automatically receive host privileges, and SQLite remains a single-writer component instead of a hidden cluster bottleneck. The hardest part isn't the infrastructure. It's deciding where to draw the trust boundary when the UI, the model, and the build orchestrator all need to coordinate but shouldn't trust each other. If you enforce the boundary too early, legitimate workflows break. If you enforce it too late, you're one bad prompt away from giving an LLM SSH access. How do you handle that boundary in your homelab or self-hosted AI stack? Do you isolate every layer, or do you trust the model to behave? What's your 2 AM failure story?