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

390 lines
17 KiB
Markdown

# 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 <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:
```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
<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.
```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.