DeepTutor Docker & Self-Hosted Deployment

GitHub

DeepTutor: Lifelong Personalized Tutoring.

RAW Compose
1. Pull Image (GHCR) docker pull ghcr.io/hkuds/deeptutor
2. Docker Run (1-Click Launch) docker run -d --name deeptutor -p 8090:8090 --restart always ghcr.io/hkuds/deeptutor:latest
3. Podman Run podman run -d --name deeptutor -p 8090:8090 --restart always ghcr.io/hkuds/deeptutor:latest
4. Skopeo Inspect (Zero-Pull Manifest) skopeo inspect docker://ghcr.io/hkuds/deeptutor:latest
5. ORAS Pull (OCI Artifact) oras pull ghcr.io/hkuds/deeptutor:latest
6. Compose Deployment docker compose up -d
compose.yaml
Download .yml
# ============================================
# DeepTutor โ€” Podman Compose (rootless, read-only rootfs)
# ============================================
# Requires:
#   * podman 4.1+ (rootless)
#   * a compose provider. On podman 5.x without the native Go-based
#     compose plugin, `podman compose` is a thin wrapper around
#     `podman-compose` (Python). Both work; the `docker-compose` CLI
#     also works if it's installed and pointed at the podman socket.
#     This file is `podman-compose` 1.5+ compatible.
#   * Verify with:  `podman compose version` and  `podman info | grep -i rootless`
#
# Bring up (this file lives next to docker-compose.yml, so always pass -f):
#   cp .env.example .env       # then edit
#   podman compose -f compose.yaml up -d
#   podman compose -f compose.yaml ps
#   podman compose -f compose.yaml logs -f deeptutor
#
#   Tip: alias it for the session โ€”  alias dc='podman compose -f compose.yaml'
#   or export COMPOSE_FILE=compose.yaml so the default `podman compose`
#   picks this file up without -f.
#
# Tear down (keeps volumes):
#   podman compose -f compose.yaml down
# Wipe data (DESTRUCTIVE):
#   podman compose -f compose.yaml down -v
#
# Top-level choices (see git log / PR for full rationale):
#   * All services run with `read_only: true`. The `tmpfs:` mounts below are
#     the only writable surface inside each container's rootfs.
#   * `userns_mode: keep-id` plus the `:U` suffix on every volume mount
#     implements rootless-keep-uid: host UID $UID maps to container UID $UID.
#     Files you create on the host are visible as your own user inside.
#   * Host-side port bindings are LOOPBACK ONLY (127.0.0.1:). Drop the
#     `127.0.0.1:` prefix to expose on all interfaces.
#   * Two services: `deeptutor` (backend+frontend in one GHCR image, run
#     under supervisord) and `pocketbase` (optional auth/storage sidecar).
#     The sandbox-runner sidecar from docker-compose.yml is intentionally
#     NOT included: the main app falls back to bwrap (Linux, if installed
#     in the image) or the restricted subprocess backend controlled by
#     the `sandbox_allow_subprocess` setting in system.json. See
#     deeptutor/services/sandbox/config.py:build_backend().
#   * The backend and frontend run as a non-root `deeptutor` user (UID
#     1000). supervisord runs as root (PID 1), the entrypoint chowns
#     `/app/data`, and each program is dropped to `deeptutor` via its
#     per-program `user=` directive, so the app processes are UID 1000
#     inside the container. Under `userns_mode: keep-id` that maps to the
#     host user.
#   * URL knowledge lives in `data/user/settings/system.json` (in-network
#     `next_public_api_base` for the typical case, or
#     `next_public_api_base_external` for cloud/external). The entrypoint
#     reads the JSON on every start and exports `DEEPTUTOR_API_BASE_URL`,
#     which `web/proxy.ts` reads to rewrite `/api/*` and `/ws/*` to the
#     configured backend at request time. There is no build-time
#     placeholder, no runtime `sed -i` on the bundle, and no compose env
#     var for the API base.
#   * Runtime settings (ports, auth, model catalog, integrations) live in
#     data/user/settings/*.json INSIDE the deeptutor-data volume. The
#     entrypoint unsets BACKEND_PORT, FRONTEND_PORT, NEXT_PUBLIC_API_BASE,
#     NEXT_PUBLIC_API_BASE_EXTERNAL, AUTH_ENABLED, POCKETBASE_URL, etc.
#     and re-exports them from the JSONs on every start. So: edit JSONs
#     + `podman compose restart deeptutor`, NOT compose env vars.
# ============================================

name: deeptutor

services:
  # Internal-only coordination service for multi-worker mode.
  redis:
    image: docker.io/library/redis:7.4-alpine
    container_name: deeptutor-redis
    restart: unless-stopped
    userns_mode: keep-id
    command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"]
    volumes:
      - ./data/redis:/data:U
    networks:
      - deeptutor
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

  # ----------------------------------------------------------
  # PocketBase โ€” optional auth + storage sidecar
  # ----------------------------------------------------------
  # Activated by setting `integrations.pocketbase_url` to
  # `http://pocketbase:8090` in data/user/settings/integrations.json.
  # Leave blank to run with the SQLite fallback (single-user / invite-only).
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:latest
    container_name: deeptutor-pocketbase
    pull_policy: always
    restart: unless-stopped
    userns_mode: keep-id
    read_only: true
    tmpfs:
      - /tmp:size=64m,mode=1777
      - /run:size=16m,mode=0755
      - /var/run:size=8m,mode=0755
    ports:
      - "127.0.0.1:${HOST_PORT_POCKETBASE:-8090}:8090"
    volumes:
      # Bind mount a host directory so the host user (UID $UID) owns
      # the SQLite DB and pocketbase's public/hooks dirs. With
      # userns_mode: keep-id, the process inside is also UID $UID, so
      # reads and writes line up. (The pocketbase image's entrypoint
      # uses --dir=/pb_data --publicDir=/pb_public --hooksDir=/pb_hooks
      # โ€” absolute paths, no /pb/ prefix.)
      - ./data/pocketbase:/pb_data:U
      - ./data/pocketbase/public:/pb_public:U
      - ./data/pocketbase/hooks:/pb_hooks:U
    networks:
      - deeptutor
    healthcheck:
      test:
        - CMD
        - wget
        - --quiet
        - --tries=1
        - --spider
        - http://localhost:8090/api/health
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

  # ----------------------------------------------------------
  # DeepTutor โ€” backend (FastAPI :8001) + frontend (Next.js :3782)
  # ----------------------------------------------------------
  # Both run inside the same image, supervised by supervisord under the
  # unprivileged `deeptutor` user (UID 1000). The image's entrypoint loads
  # runtime settings from data/user/settings/*.json and exports them into
  # the supervisord children's environment, so changing ports/auth/providers
  # means editing JSONs + `podman compose restart`.
  deeptutor:
    image: ghcr.io/hkuds/deeptutor:latest
    container_name: deeptutor
    pull_policy: always
    restart: unless-stopped
    userns_mode: keep-id
    read_only: true
    # Writable surfaces inside the RO rootfs. Each tmpfs is scoped to a
    # specific need:
    #   /tmp        โ€” scratch for Python, uvicorn, Node, Next.js +
    #                 supervisord's pidfile (/tmp/supervisord.pid)
    #   /run        โ€” standard Linux runtime dir
    #   /var/run    โ€” standard runtime dir (kept writable for any tooling)
    #   /var/log    โ€” covers any supervisord child that defaults there
    #   /root       โ€” catches stray $HOME-style writes (image sets
    #                 PYTHONDONTWRITEBYTECODE=1, but be safe)
    #   /home       โ€” same, for any non-root code path
    tmpfs:
      - /tmp:size=512m,mode=1777
      - /run:size=32m,mode=0755
      - /var/run:size=8m,mode=0755
      - /var/log:size=64m,mode=0755
      - /root:size=16m,mode=0700
      - /home:size=16m,mode=0755
    ports:
      - "127.0.0.1:${HOST_PORT_BACKEND:-8001}:8001"
      - "127.0.0.1:${HOST_PORT_FRONTEND:-3782}:3782"
    volumes:
      # Bind mount a host directory so the host user (UID $UID) owns
      # the entire data tree. With userns_mode: keep-id the process
      # inside is also UID $UID, so writes from the FastAPI backend,
      # Next.js, and supervisord all line up. The same path was used
      # by the original docker-compose.yml; we keep it for consistency.
      # The data tree holds: admin workspace + runtime settings
      # (data/user), per-user workspaces (data/users), partners
      # (data/partners), accounts/grants/audit (data/system),
      # knowledge bases, memory, logs. One tree to back up.
      - ./data:/app/data:U
      # Stable content workspace shared by every installation path. Override
      # the host side with DEEPTUTOR_WORKSPACE_HOST=/absolute/folder.
      - ${DEEPTUTOR_WORKSPACE_HOST:-./data/user/workspace}:/workspace:U
    environment:
      # Time zone โ€” picked up by Python (time.tzset) and Next.js at boot.
      - TZ=${TZ:-UTC}
      - DEEPTUTOR_WORKSPACE_ROOT=/workspace
      - DEEPTUTOR_WORKSPACE_ALLOWED_ROOTS=/workspace
      # Local LLM (LM Studio / Ollama / vLLM) base URLs in
      # data/user/settings/model_catalog.json should use
      # `http://host.containers.internal:PORT` (podman) โ€” NOT localhost,
      # which inside the container is the container's own loopback.
      # Uncomment to enable PocketBase auth (also set integrations.json):
      #   - POCKETBASE_URL=http://pocketbase:8090
      #   - POCKETBASE_EXTERNAL_URL=http://localhost:8090
    healthcheck:
      test:
        - CMD
        - curl
        - -fsS
        - http://localhost:8001/health/ready
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    depends_on:
      redis:
        condition: service_healthy
      pocketbase:
        condition: service_healthy
    networks:
      - deeptutor
    # Optional hardening โ€” uncomment to taste. supervisord runs as root
    # (PID 1) and setuids each program down to UID 1000, which needs
    # CAP_SETUID/CAP_SETGID โ€” so do NOT `cap_drop: ALL` here, it would stop
    # the backend/frontend from spawning. `no-new-privileges` is safe (a
    # root process dropping its own privileges is not "gaining" any).
    # security_opt:
    #   - no-new-privileges:true
    # # cap_drop: ALL is unsafe โ€” supervisord needs CAP_SETUID/CAP_SETGID to
    # # drop its children to the deeptutor user.
    # Resource limits. Honored on cgroup v2 hosts; podman warns and skips
    # on cgroup v1 or no-cgroup environments.
    # pids_limit: 1024
    # mem_limit: 4g
    # cpus: 4.0

networks:
  deeptutor:
    driver: bridge

# Named volumes intentionally not used: with userns_mode: keep-id the
# container process runs as the host user (UID $UID), but podman
# auto-creates named volumes with UID 100000 (userns-mapped root),
# so 755 perms + wrong owner = PermissionError on the first JSON
# write. Bind mounts on a host directory you own work cleanly.