# 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](../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 `exec`s `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. ```bash docker run --rm --name deeptutor \ -p 127.0.0.1:3782:3782 \ -v deeptutor-data:/app/data \ ghcr.io/hkuds/deeptutor:latest ``` Open . 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. ### Temporary local Codex OAuth bridge OpenAI Codex redirects the browser to fixed loopback ports `1455` or `1457`. The default container network is separate from the host loopback, so publish both ports to the Web frontend only while signing in. Both host ports must be free before starting the temporary bridge. For `docker run`, stop the normal container and temporarily rerun the same image and data volume with two extra loopback-only mappings: ```bash docker run --rm --name deeptutor \ -p 127.0.0.1:3782:3782 \ -p 127.0.0.1:1455:3782 \ -p 127.0.0.1:1457:3782 \ -v deeptutor-data:/app/data \ ghcr.io/hkuds/deeptutor:latest ``` For Compose, add the same temporary overlay to the base file you normally use: ```bash # Source build with sidecars python scripts/docker_compose.py \ -f docker-compose.yml -f compose.codex-oauth.yaml \ up -d --force-recreate deeptutor # Pre-built GHCR image python scripts/docker_compose.py \ -f docker-compose.ghcr.yml -f compose.codex-oauth.yaml \ up -d --force-recreate deeptutor # Rootless Podman podman compose -f compose.yaml -f compose.codex-oauth.yaml \ up -d --force-recreate deeptutor ``` Complete **Settings → Models → OpenAI Codex → Sign in with Codex**. After the status changes to **Connected**, stop the temporary `docker run` container and return to the normal command above. For Compose, rerun the same base command without `compose.codex-oauth.yaml`; keep `--force-recreate deeptutor` so the temporary port bindings are removed. This releases host ports `1455` and `1457`; credentials remain in the persistent `/app/data/system` tree. Bind every callback mapping to `127.0.0.1` and never expose it on a LAN or public interface — the overlay publishes the **whole** frontend on those two ports, not just `/auth/callback`. For a manual `docker run` whose container-side frontend port is not `3782`, change the right-hand `3782` targets; `scripts/docker_compose.py` handles configured custom ports. For single-container installs, set `sandbox_allow_subprocess` to `false` if model-generated code must not share the container trust boundary with secrets. ### One-time migration: `docker-compose.ghcr.yml` now mounts all of `./data` `docker-compose.ghcr.yml` used to bind-mount only three subtrees (`data/user`, `data/memory`, `data/knowledge_bases`), so everything else — `data/system` (the JWT signing secret, accounts, grants, audit log, per-owner Codex tokens), `data/users` (per-user workspaces), `data/partners`, `data/cli-apps` — lived in the container's writable layer and was discarded on every recreate. It now mounts the whole tree, matching `docker-compose.yml` and `compose.yaml`. **Before your first `up -d` after upgrading**, copy that state out of the running container, or the empty host directories shadow it and DeepTutor regenerates the auth secret (logging everyone out) and starts with no non-admin accounts: ```bash for tree in system users partners cli-apps; do docker cp "deeptutor:/app/data/$tree" "./data/$tree" 2>/dev/null || true done ``` Deployments using a named volume (`-v deeptutor-data:/app/data`) or the source-build Compose file were never affected — they already persisted the whole tree. ### 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: ```json { "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: ```json { "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): ```bash 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 (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. ```bash 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: ```bash 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.json` → `next_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.json` → `backend_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.json` → `auth_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.