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:
- 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.
- 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.
- 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:
- 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.
- 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.
- 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.
- 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)
- Pull the images:
podman pull ghcr.io/hkuds/deeptutor:latest
podman pull ghcr.io/muchobien/pocketbase:latest
- Drop a
compose.yaml with userns_mode: keep-id, read_only: true, named volumes (or bind mounts — both fail differently).
podman compose -f compose.yaml up -d.
- 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
Tested on: podman 5.8.3 (rootless) +
podman-compose1.6.0 on Linux, imageghcr.io/hkuds/deeptutor:latest, imageghcr.io/muchobien/pocketbase:latest. Date: 2026-06-17. Target branch for any future PR:dev(perCONTRIBUTING.md).Feature Request Description
Make the published
ghcr.io/hkuds/deeptutor:latestimage 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: trueon the root filesystemtmpfs:for the small set of paths the runtime actually needs to write toThe 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 composeon 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.yamlnext to the existingdocker-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-idso they can edit their knowledge bases, workspace, and settings directly from their home directory without going throughpodman unshareorpodman execindirectiontmpfs:for the few scratch paths (mirrors the project's existing sandbox-runner hardening, but applied to the main app)pull_policy: alwaysagainstghcr.io/hkuds/deeptutor:latest, so upgrades are anup -dawayToday 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.shdoessed -ion read-only image filesstart-frontend.sh(in the image) replaces the build-time placeholder__NEXT_PUBLIC_API_BASE_PLACEHOLDER__at container startup:Under
read_only: true, the image's/app/web/.next/*is not writable, sosed -ifails on every file. The trailing2>/dev/null || trueswallows the error silently. The placeholder never gets replaced. The frontend boots, then crashes the first time any API call is made:This message is misleading —
system.jsonis irrelevant; the sed never ran.Fix path: the cleanest change is in the image, not the user config. Options:
/app/web/.nextas a tmpfs (or copy from the image into a tmpfs at startup) and run the sed against that copy. The standalonenode server.jsreads from the same tmpfs.API_BASEat build time (matchesAUTH_ENABLEDalready being baked in), and only override at runtime ifNEXT_PUBLIC_API_BASE_EXTERNALis set explicitly. The runtime sed becomes a no-op for the default case.Option 1 is the smallest change and preserves the current "configurable at runtime" contract.
Blocker 2 —
init_user_directoriesand named volumes underuserns_mode: keep-iduserns_mode: keep-idmakes the container process run as the host user's UID (e.g. 1000) inside the namespace, not as root. The image'sentrypoint.shthen calls:This runs as UID 1000 inside. The first JSON write (e.g. the atomic write to
data/user/settings/system.jsonvia_atomic_write_json) fails:The reason: the named volume
deeptutor-datawas auto-created by podman with100000:100000ownership (userns-mapped root) and mode 0755. UID 1000 inside has onlyr-xagainst those dirs.Fix path: in the image, drop privileges to a non-root user (e.g.
deeptutor, UID 1000) beforeinit_user_directoriesand beforeexec supervisord, and have the entrypointchownany pre-baked/app/datadirectories to that user on first start. That way,keep-idworks 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 existingdocker-compose.ymlif 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 existingdocker-compose.ymlmounts./data/pocketbase:/pb/pb_data— wrong target. With a fresh volume + RO rootfs, the first start crashes immediately:The original compose's bug is masked if you
mkdir ./data/pocketbase/pb_dataon the host first (the existingdocker-compose.ymluser 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_publicand/pb_hooks.Fix path: update the example
docker-compose.ymlto mount./data/pocketbase:/pb_data(no/pb/prefix), and have the image accept the data dir via thePB_DATAenv var so deployments can override the path entirely.Proposed implementation (high level)
In rough order of dependency:
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 thenext_public_api_basedefault so Blocker 1's runtime sed is for the override-only case./app/web/.nextas a tmpfs (or copy-on-write overlay) inside the entrypoint before the runtime sed. This is the minimal change to makeread_only: truework without forking the image.PB_DATAenv var (default/pb_data) in the entrypoint, and update the bundleddocker-compose.ymlto use the correct mount targets.docker-compose.ymland add acompose.yamlwith a tested podman-friendly configuration. Document the keep-id + bind-mount requirement, and theread_only: true+ tmpfs requirements, inREADME.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 -dbrings the stack up cleanly on rootless podman withuserns_mode: keep-id,read_only: true, and tmpfs for the documented scratch paths.PermissionError, no silentsed -ifailures, nomkdir …: read-only file systemon first start.data/on the host is owned by the host user, fully readable / editable from the host withoutpodman unshare.compose.yamlworks on docker compose (best effort; if docker is the primary target, that can stay a separate concern).Reproduction (current state)
compose.yamlwithuserns_mode: keep-id,read_only: true, named volumes (or bind mounts — both fail differently).podman compose -f compose.yaml up -d.PermissionErroron 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.yamlnext to the existingdocker-compose.yml. It dropsread_only: trueon the deeptutor service, uses bind mounts underuserns_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