Files
hkuds--deeptutor/CONTAINERIZATION.md
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

17 KiB

DeepTutor Containerization

This document covers deploying DeepTutor from a container image: the recommended docker run path, the hardened rootless-Podman path with a read-only root filesystem, runtime configuration, the optional PocketBase sidecar, and the security notes that motivate the default posture.

For PyPI / source installs, see the main README.md. This file is only about running the published image.


Overview

The published ghcr.io/hkuds/deeptutor image runs both the FastAPI backend (:8001) and the Next.js frontend (:3782) under supervisord inside a single container, on top of python:3.11-slim. There is one data tree (/app/data inside the container) that holds settings, workspaces, memory, knowledge bases, and logs. Bind-mount that tree to the host to make state survive container restarts.

The image is built so it works under three deployment shapes:

  1. docker run — the easy path. Rootful, writable rootfs, single bind mount on /app/data.
  2. docker compose (docker-compose.yml) — same image plus the PocketBase sidecar and the sandbox-runner sidecar. Still rootful, writable rootfs.
  3. podman compose -f compose.yaml — the hardened path. Rootless (userns_mode: keep-id), read-only rootfs, tmpfs in place of writable system dirs, bind mount on ./data.

The architectural change that makes shape 3 work is that URL knowledge no longer lives in the frontend bundle. Concretely:

  • The bundle is built with no NEXT_PUBLIC_API_BASE placeholder and no sed -i of the build output. web/lib/api.ts exports apiUrl and wsUrl as one-line pass-throughs, so the browser fetches relative paths through the frontend (:3782/api/...).
  • web/proxy.ts catches /api/* and /ws/* and rewrites them to DEEPTUTOR_API_BASE_URL at request time. That env var is set by the container entrypoint on every start, read from data/user/settings/system.json.
  • start-frontend.sh is now 12 lines: it sets PORT/HOSTNAME and execs node /app/web/server.js. No mutations of the bundle.
  • supervisord runs as root (PID 1) and drops each program (backend, frontend) to a non-root deeptutor user (UID 1000) via its per-program user= directive, so the app processes stay non-root. With userns_mode: keep-id on the host that UID maps to your host UID; with a regular docker run it's a normal unprivileged user inside the container.

The full per-installation guide follows.


Docker (default)

The simplest possible deployment. One container, one volume, two port mappings.

docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 \
  -v deeptutor-data:/app/data \
  ghcr.io/hkuds/deeptutor:latest

Open http://127.0.0.1:3782. The container creates /app/data/user/settings/*.json on first boot; configure model providers from the Web Settings page. Config, API keys, logs, workspace files, memory, and knowledge bases persist in the deeptutor-data named volume.

Notes:

  • Only 3782 needs to be published. The browser talks exclusively to the frontend origin (:3782); all /api/* and /ws/* traffic is forwarded to the FastAPI backend inside the container by the Next.js middleware (web/proxy.ts), which reads DEEPTUTOR_API_BASE_URL (http://localhost:8001 by default) at request time. You do not need to expose :8001 to the host for the UI to work. Publishing :8001 (-p 127.0.0.1:8001:8001) is optional — handy only for hitting the API directly (curl, scripts) or debugging.
  • Different host ports: change the left side of each -p host:container mapping (e.g. -p 127.0.0.1:8088:3782). If you change container-side ports in data/user/settings/system.json (backend_port, frontend_port), restart the container and update the right side of each mapping to match.
  • Detached: add -d, then docker logs -f deeptutor to follow, docker stop deeptutor to stop, docker rm deeptutor before reusing the name. The deeptutor-data volume keeps your settings and workspace across restarts.

Remote / reverse-proxy deployments

For the common single-container case (this image), you do not need to configure an API base at all. The browser issues relative /api/* and /ws/* requests against whatever origin serves the UI (https://deeptutor.example.com), and the in-container Next.js middleware forwards them to the backend on localhost:8001. Just point your reverse proxy / TLS terminator at the published :3782 and you're done.

You only need to set an API base for a split deployment where the backend runs in a separate container. Edit data/user/settings/system.json on the host (inside the deeptutor-data volume — docker volume inspect deeptutor-data to find its mountpoint) and set the in-network address the frontend container uses to reach the backend container:

{
  "next_public_api_base": "http://backend:8001"
}

The entrypoint reads this on every start and exports DEEPTUTOR_API_BASE_URL for proxy.ts (precedence: next_public_api_base, then next_public_api_base_external, then http://localhost:8001). Note that because the proxy is server-side, DEEPTUTOR_API_BASE_URL is the address the frontend server uses to reach the backend — not a URL the browser ever sees. public_api_base is accepted as a compatibility alias and normalized into next_public_api_base_external on save.

CORS uses frontend origins, not API URLs. With auth disabled, DeepTutor permits normal HTTP/HTTPS browser origins by default. With auth enabled, add exact frontend origins:

{
  "cors_origins": ["https://deeptutor.example.com"]
}

Host LLM providers (Ollama / LM Studio / llama.cpp / vLLM / Lemonade)

Inside Docker, localhost is the container itself, not your host machine. To reach a model service running on the host, use the host gateway (recommended):

docker run --rm --name deeptutor \
  -p 127.0.0.1:3782:3782 -p 127.0.0.1:8001:8001 \
  --add-host=host.docker.internal:host-gateway \
  -v deeptutor-data:/app/data \
  ghcr.io/hkuds/deeptutor:latest

Then in Settings → Models, point the provider Base URL at host.docker.internal:

  • Ollama LLM: http://host.docker.internal:11434/v1
  • Ollama embedding: http://host.docker.internal:11434/api/embed
  • LM Studio: http://host.docker.internal:1234/v1
  • llama.cpp: http://host.docker.internal:8080/v1
  • Lemonade: http://host.docker.internal:13305/api/v1

Docker Desktop (macOS/Windows) usually resolves host.docker.internal without --add-host. On Linux, the flag is the portable way.

Linux alternative — host networking: add --network=host and drop the -p flags. The container shares the host network directly, so open http://127.0.0.1:3782 (or the frontend_port in system.json), and host services can be reached with normal localhost URLs.

In host-network mode the processes bind directly on the host interfaces (there is no -p 127.0.0.1: prefix to scope them). To keep them off the LAN, set BACKEND_HOST=127.0.0.1 and FRONTEND_HOST=127.0.0.1 — they override uvicorn's --host and Next.js's HOSTNAME (both default to 0.0.0.0). Only use these with --network=host: in bridge mode binding to loopback breaks the published -p port forward.


Podman / rootless / read-only rootfs

For users who want the strongest default posture — rootless, with a read-only root filesystem — compose.yaml is the supported starting point. It pulls the same ghcr.io/hkuds/deeptutor:latest image and relies on the entrypoint chown + supervisord's per-program privilege drop, the URL-forwarding proxy.ts, and host-side bind mounts to make it all work.

cp .env.example .env       # then edit if needed
podman compose -f compose.yaml up -d
podman compose -f compose.yaml ps
podman compose -f compose.yaml logs -f deeptutor

Verify rootless is active (podman info | grep -i rootless should report true).

What compose.yaml does, and why:

  • read_only: true on every service. The container's rootfs is read-only. The only writable surface is the tmpfs: mounts listed per service plus the bind-mounted ./data directory.
  • userns_mode: keep-id. The container's UID 0 maps to your host UID; the container's UID 1000 (the deeptutor user inside the image) maps to your host UID 1000 (which most distros reserve for the first human user). The :U suffix on every volume mount tells podman to chown the bind-mount target to that mapped UID.
  • tmpfs: mounts for the system dirs the runtime expects to write. /tmp (Python/Node scratch), /run and /var/run (pidfiles), /var/log, /root, /home. Sizes are intentionally generous; trim to taste.
  • No named volumes. Podman auto-creates named volumes with the userns-mapped root (UID 100000), so 755 perms + wrong owner = PermissionError on the first JSON write. Bind mounts on a host directory you own work cleanly.
  • Loopback-only port bindings. 127.0.0.1: prefix on every ports: entry. Drop the prefix to expose on all interfaces.
  • No sandbox-runner sidecar. The docker-compose.yml shape includes a hardened sidecar that runs untrusted model-generated code in a least-privileged container. The podman shape does not — the main app falls back to bwrap (Linux, if installed in the image) or the restricted subprocess backend controlled by sandbox_allow_subprocess in system.json.

Running outside compose.yaml

compose.yaml is a starting point, not the only shape. The same invariants apply if you want to drive podman run directly:

mkdir -p data/user/settings
echo '{}' > data/user/settings/system.json

podman run --rm -d --name deeptutor \
  -p 127.0.0.1:8001:8001 \
  -p 127.0.0.1:3782:3782 \
  -v $(pwd)/data:/app/data:U \
  --read-only \
  --tmpfs /tmp:size=512m,mode=1777 \
  --tmpfs /run:size=32m,mode=0755 \
  --tmpfs /var/run:size=8m,mode=0755 \
  --tmpfs /var/log:size=64m,mode=0755 \
  --tmpfs /root:size=16m,mode=0700 \
  --tmpfs /home:size=16m,mode=0755 \
  --userns=keep-id \
  ghcr.io/hkuds/deeptutor:latest

After the container is up, the backend and frontend always run as the non-root deeptutor user (UID 1000) — podman exec deeptutor ps -o user,pid,comm shows the uvicorn/node children as deeptutor. supervisord itself (PID 1) runs as whatever UID the runtime started it with: root under rootful Docker/Podman, or the host user under rootless podman + userns_mode: keep-id.

Supervisord pidfile

The [supervisord] section carries no user= directive, so supervisord runs as PID 1's UID and never tries to drop its own privilege; only its child programs are dropped to deeptutor via the per-program user= directives. Pinning user=root here (an earlier design) broke rootless keep-id, where PID 1 is the non-root host user and lacks CAP_SETUID: supervisord refuses to drop privilege and exits at startup with Can't drop privilege as nonroot user (see supervisord's options.py).

The pidfile is written to /tmp/supervisord.pid. /tmp is mode=1777 (world-writable) in every run configuration above, so the pidfile is writable whether PID 1 is root or the host UID, and regardless of who owns /var/run. An earlier build pointed the pidfile at the root-owned /var/run/supervisord.pid; under rootless keep-id the non-root PID 1 couldn't write it and logged a cosmetic CRIT could not write pidfile on every start. Putting it in /tmp removes that dependency on the /var/run owner and mode entirely.


Runtime configuration

Almost everything you tune lives under data/user/settings/ inside the data tree. The container entrypoint unsets a list of related env vars (BACKEND_PORT, FRONTEND_PORT, NEXT_PUBLIC_API_BASE, NEXT_PUBLIC_API_BASE_EXTERNAL, AUTH_ENABLED, POCKETBASE_URL, etc.) on every start and re-exports values from the JSONs. So: edit the JSONs, restart, do not try to drive these with compose env vars.

File Purpose
system.json Backend/frontend ports, public API base, CORS, SSL verification, attachment directory
auth.json Optional auth toggle, username, password hash, token/cookie settings
integrations.json Optional PocketBase and sidecar integration settings
model_catalog.json LLM, embedding, and search provider profiles; API keys; active models
interface.json UI language / theme / sidebar preferences
main.yaml Runtime behavior defaults and path injection
agents.yaml Capability/tool temperature and token settings

The two settings most relevant to a fresh install:

  • system.jsonnext_public_api_base (in-network) and next_public_api_base_external (cloud/external override). The entrypoint reads these and exports DEEPTUTOR_API_BASE_URL, which web/proxy.ts consumes. public_api_base is accepted as a compatibility alias and is normalized into next_public_api_base_external on save.
  • system.jsonbackend_port / frontend_port. The container ports the supervisor binds inside the container. If you change these, update the right side of every -p host:container mapping (or the HOST_PORT_* env var that compose.yaml reads) to match.

Project-root .env files are intentionally ignored as application config. The Web Settings page is the recommended editor for the JSON/YAML files; deep links to each section live in the page sidebar.


PocketBase

PocketBase is an optional auth + storage sidecar. Activate it by setting integrations.pocketbase_url to http://pocketbase:8090 in data/user/settings/integrations.json and bringing the pocketbase service up alongside the main deeptutor service. With it running, the main app stores user accounts and sessions in PocketBase instead of falling back to the SQLite single-user layout.

The pocketbase service in compose.yaml (and the corrected mount in docker-compose.yml) bind-mounts three subdirectories of ./data/pb_data, /pb_public, /pb_hooks — matching the upstream ghcr.io/muchobien/pocketbase:latest image's entrypoint, which uses absolute paths. The earlier docker-compose.yml example mounted /pb/pb_data and crashed on first start with mkdir /pb_data: read-only file system; this PR fixes that.

PocketBase stays a single-user integration — keep integrations.pocketbase_url blank for multi-user deployments unless you've wired up an external user store.


Troubleshooting

CRIT could not write pidfile /var/run/supervisord.pid on container start. Only on images built before the pidfile moved to /tmp/supervisord.pid (mode=1777, always writable); current images don't emit it. The supervised children come up either way — the line was always cosmetic. Fix: pull a current image (or, on an old one, set the /var/run tmpfs to mode=1777).

Page loads but Settings says "Backend unreachable". The UI reaches the backend through the in-container proxy, not a host port, so this is almost always a backend that failed to start (check docker logs deeptutor for the [program:backend] lines) or a wrong DEEPTUTOR_API_BASE_URL in a split deployment — not a missing :8001 host mapping (which the UI does not need).

Cannot connect to the Docker daemon on a podman host. Run systemctl --user start podman.socket (rootless) or set DOCKER_HOST=unix:///run/user/$UID/podman/podman.sock for the docker-compose CLI to use the podman socket.

Permission denied on first JSON write under a named volume. This is the userns-mapped root problem; switch to a bind mount on a host directory you own, or use :U on the volume mount.

sed -i errors on a fresh image. There shouldn't be any — the runtime no longer mutates the bundle. The URL is forwarded at request time. If you see one, you are probably on an older image; pull ghcr.io/hkuds/deeptutor:latest again.

Settings page won't accept the API base URL. Open data/user/settings/system.json on the host and set next_public_api_base_external directly. The page UI is wired to public_api_base (the legacy alias) and the legacy field will be renormalized on save.


Security notes

  • The image drops privileges to a non-root deeptutor user (UID 1000) before starting supervisord. Anything that runs as root is the entrypoint, the chown, and the env-var export.
  • read_only: true plus tmpfs: for the expected writable system directories means the container's root filesystem is immutable at runtime. A process that tries to write outside the listed tmpfs paths or the bind-mounted ./data tree will fail.
  • userns_mode: keep-id on the host means a container escape lands with your host user's permissions, not root.
  • The sandbox-runner sidecar (in docker-compose.yml, not in compose.yaml) is the strongest posture for untrusted model-generated code: a sandbox escape lands in a stripped, unprivileged container with no app secrets, not in the main app. The podman shape trades that for the rootless-podman shape; the main app falls back to bwrap or the restricted subprocess backend controlled by sandbox_allow_subprocess.
  • Auth (data/user/settings/auth.jsonauth_enabled = true) gates /api/* and /ws/* via the dt_token cookie. web/proxy.ts reads DEEPTUTOR_AUTH_ENABLED (exported by the entrypoint on every start) to decide whether to require the cookie.
  • CORS uses frontend origins, not API URLs. With auth enabled, set cors_origins in system.json to the exact frontend origins the deployment serves.