Skip to content

[Feature Request]: Support rootless podman with read-only rootfs for hardened deployments #576

Description

@enihcam

Tested on: podman 5.8.3 (rootless) + podman-compose 1.6.0 on Linux, image ghcr.io/hkuds/deeptutor:latest, image ghcr.io/muchobien/pocketbase:latest. Date: 2026-06-17. Target branch for any future PR: dev (per CONTRIBUTING.md).


Feature Request Description

Make the published ghcr.io/hkuds/deeptutor:latest image work cleanly when run with:

  • userns_mode: keep-id (host user identity inside the container; files the user touches on the host and inside the container line up)
  • read_only: true on the root filesystem
  • tmpfs: for the small set of paths the runtime actually needs to write to

The motivation is to let users (especially self-hosters, security-conscious operators, and anyone deploying on a read-only-base-image host) run DeepTutor in a hardened posture without having to fork the image or strip security from the compose file. Today the image works out-of-the-box with docker compose on a daemon, but breaks in two distinct ways under rootless podman with RO rootfs (see the blockers below). None of the bugs are catastrophic in isolation, but together they make the hardened deployment story effectively unbuildable against the GHCR image.

This issue is also a prerequisite for shipping a maintained compose.yaml next to the existing docker-compose.yml, so users have a one-command path under podman.


Related Module

Other (spans API/Backend, Frontend/Web, and Deployment).


Use Case

A self-hoster on a Fedora/Arch/Debian host runs DeepTutor via:

  • podman (no Docker daemon), userns_mode: keep-id so they can edit their knowledge bases, workspace, and settings directly from their home directory without going through podman unshare or podman exec indirection
  • A read-only rootfs with tmpfs: for the few scratch paths (mirrors the project's existing sandbox-runner hardening, but applied to the main app)
  • pull_policy: always against ghcr.io/hkuds/deeptutor:latest, so upgrades are an up -d away

Today this is blocked. The image's entrypoint assumes it can run as root with a writable rootfs. The bug fixes below are the unblockers.


Blockers found during testing

While validating a rootless-podman + RO-rootfs compose, three concrete issues surface. Each is small on its own, but together they are why no hardened compose exists today.

Blocker 1 — start-frontend.sh does sed -i on read-only image files

start-frontend.sh (in the image) replaces the build-time placeholder __NEXT_PUBLIC_API_BASE_PLACEHOLDER__ at container startup:

find /app/web/.next -type f \( -name "*.js" -o -name "*.json" \) -exec \
    sed -i -e "s|__NEXT_PUBLIC_API_BASE_PLACEHOLDER__|${API_BASE_ESCAPED}|g" \
           -e "s|__NEXT_PUBLIC_AUTH_ENABLED_PLACEHOLDER__|${AUTH_ENABLED_ESCAPED}|g" \
        {} \; 2>/dev/null || true

Under read_only: true, the image's /app/web/.next/* is not writable, so sed -i fails on every file. The trailing 2>/dev/null || true swallows the error silently. The placeholder never gets replaced. The frontend boots, then crashes the first time any API call is made:

Error: NEXT_PUBLIC_API_BASE is not configured. Please update
data/user/settings/system.json and restart.

This message is misleading — system.json is irrelevant; the sed never ran.

Fix path: the cleanest change is in the image, not the user config. Options:

  1. Mount /app/web/.next as a tmpfs (or copy from the image into a tmpfs at startup) and run the sed against that copy. The standalone node server.js reads from the same tmpfs.
  2. Bake a default API_BASE at build time (matches AUTH_ENABLED already being baked in), and only override at runtime if NEXT_PUBLIC_API_BASE_EXTERNAL is set explicitly. The runtime sed becomes a no-op for the default case.
  3. Move the placeholder replacement into a small Go/Bun-side middleware at runtime, instead of mutating the bundle.

Option 1 is the smallest change and preserves the current "configurable at runtime" contract.

Blocker 2 — init_user_directories and named volumes under userns_mode: keep-id

userns_mode: keep-id makes the container process run as the host user's UID (e.g. 1000) inside the namespace, not as root. The image's entrypoint.sh then calls:

python -c "
from pathlib import Path
from deeptutor.services.setup import init_user_directories
init_user_directories(Path('/app'))
" 2>/dev/null || echo "   ⚠️ Directory initialization skipped (will be created on first use)"

This runs as UID 1000 inside. The first JSON write (e.g. the atomic write to data/user/settings/system.json via _atomic_write_json) fails:

File "/app/deeptutor/services/config/runtime_settings.py", line 273, in _atomic_write_json
    with tempfile.NamedTemporaryFile(dir='/app/data/user/settings', ...):
PermissionError: [Errno 13] Permission denied: '/app/data/user/settings/tmpXXXXX'

The reason: the named volume deeptutor-data was auto-created by podman with 100000:100000 ownership (userns-mapped root) and mode 0755. UID 1000 inside has only r-x against those dirs.

Fix path: in the image, drop privileges to a non-root user (e.g. deeptutor, UID 1000) before init_user_directories and before exec supervisord, and have the entrypoint chown any pre-baked /app/data directories to that user on first start. That way, keep-id works without requiring the operator to know about userns-mapped UIDs.

A smaller, complementary fix in the docs: explicitly note that under userns_mode: keep-id, a bind mount on a host-owned directory is required — named volumes will not work because of the UID-mismatch described above. (This is also the case for the existing docker-compose.yml if anyone tries to run it rootless.)

Blocker 3 — PocketBase data-dir path in the docs/examples is wrong

The image's entrypoint is pocketbase serve --dir=/pb_data --publicDir=/pb_public --hooksDir=/pb_hooks (absolute paths, no /pb/ prefix). The existing docker-compose.yml mounts ./data/pocketbase:/pb/pb_data — wrong target. With a fresh volume + RO rootfs, the first start crashes immediately:

2026/06/17 09:14:13 mkdir /pb_data: read-only file system

The original compose's bug is masked if you mkdir ./data/pocketbase/pb_data on the host first (the existing docker-compose.yml user is expected to do this), but the image itself never asked for that. The volume mount target in the example should be /pb_data, with the same named/bind volume also mounted on /pb_public and /pb_hooks.

Fix path: update the example docker-compose.yml to mount ./data/pocketbase:/pb_data (no /pb/ prefix), and have the image accept the data dir via the PB_DATA env var so deployments can override the path entirely.


Proposed implementation (high level)

In rough order of dependency:

  1. Add a non-root user (deeptutor, UID 1000) to the production image and run the entrypoint as that user. This single change fixes Blocker 2 and unblocks Blocker 1 (the writable-tmpfs approach can then chown correctly under keep-id). Bake the next_public_api_base default so Blocker 1's runtime sed is for the override-only case.
  2. Mount /app/web/.next as a tmpfs (or copy-on-write overlay) inside the entrypoint before the runtime sed. This is the minimal change to make read_only: true work without forking the image.
  3. Read PocketBase's data dir from the PB_DATA env var (default /pb_data) in the entrypoint, and update the bundled docker-compose.yml to use the correct mount targets.
  4. Update docker-compose.yml and add a compose.yaml with a tested podman-friendly configuration. Document the keep-id + bind-mount requirement, and the read_only: true + tmpfs requirements, in README.md.

Items 1, 2, and 3 are the minimum to ship a hardened compose. Item 4 is the user-facing payoff.


Acceptance criteria

  • podman compose -f compose.yaml up -d brings the stack up cleanly on rootless podman with userns_mode: keep-id, read_only: true, and tmpfs for the documented scratch paths.
  • No PermissionError, no silent sed -i failures, no mkdir …: read-only file system on first start.
  • data/ on the host is owned by the host user, fully readable / editable from the host without podman unshare.
  • The frontend loads and an authenticated API call round-trips end-to-end.
  • The same compose.yaml works on docker compose (best effort; if docker is the primary target, that can stay a separate concern).

Reproduction (current state)

  1. Pull the images:
    podman pull ghcr.io/hkuds/deeptutor:latest
    podman pull ghcr.io/muchobien/pocketbase:latest
  2. Drop a compose.yaml with userns_mode: keep-id, read_only: true, named volumes (or bind mounts — both fail differently).
  3. podman compose -f compose.yaml up -d.
  4. Observe pocketbase crash loop, or deeptutor's PermissionError on first JSON write, or the frontend's "NEXT_PUBLIC_API_BASE is not configured" error.

Additional context

A working-but-pragmatic local workaround lives in a compose.yaml next to the existing docker-compose.yml. It drops read_only: true on the deeptutor service, uses bind mounts under userns_mode: keep-id, and gets the stack running end-to-end with the three upstream bugs unfixed. The goal of this feature request is to remove those compromises from the local config and push them into the image.

Happy to split this into three separate issues (one feature, three bugs) if that's easier to triage — say the word.

— filed by a self-hoster who wants to keep using DeepTutor without giving up podman + a read-only rootfs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions