Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Build: docker build -t tracefinity .
# Run: docker run -p 3000:3000 -v ./data:/app/storage tracefinity
# Run as host user: docker run -p 3000:3000 -v ./data:/app/storage --user "$(id -u):$(id -g)" tracefinity
# NAS (Unraid/TrueNAS): docker run -p 3000:3000 -e PUID=99 -e PGID=100 -v ./data:/app/storage tracefinity

FROM node:20-slim AS frontend-build

Expand All @@ -28,6 +29,7 @@ RUN apt-get update && \
nginx \
supervisor \
git \
gosu \
&& (apt-get install -y libglib2.0-0t64 2>/dev/null || apt-get install -y libglib2.0-0) \
&& rm -rf /var/lib/apt/lists/*

Expand All @@ -49,7 +51,7 @@ COPY --from=frontend-build /frontend/node_modules ./node_modules
# storage directory
RUN mkdir -p /app/storage/uploads /app/storage/processed /app/storage/outputs

# non-root user (UID 1000). --user flag can override with any UID.
# non-root user (UID 1000). PUID/PGID env vars or --user flag can override.
RUN groupadd -r -g 1000 tracefinity && \
useradd -r -u 1000 -g tracefinity -d /app -s /sbin/nologin tracefinity

Expand Down Expand Up @@ -138,10 +140,14 @@ RUN chmod -R 777 /app/storage /app/.u2net /app/.next && \
chmod -R 777 /var/lib/nginx /var/log/nginx && \
mkdir -p /tmp/nginx /tmp/supervisor && chmod 777 /tmp/nginx /tmp/supervisor

# entrypoint handles directory creation for arbitrary UIDs
# entrypoint handles directory creation and privilege drop
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

# entrypoint tests (run with: docker run --rm --entrypoint sh <image> /app/tests/test_entrypoint.sh)
COPY tests/test_entrypoint.sh /app/tests/
RUN chmod +x /app/tests/test_entrypoint.sh

EXPOSE 3000

ENV GEMINI_IMAGE_MODEL="gemini-3-pro-image-preview"
Expand All @@ -150,7 +156,5 @@ ENV U2NET_HOME=/app/.u2net
ENV NUMBA_CACHE_DIR=/tmp/numba_cache
ENV HOME=/app

USER tracefinity

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/tracefinity.conf"]
63 changes: 56 additions & 7 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,67 @@ for dir in $DIRS; do
mkdir -p "$dir" 2>/dev/null || echo "warning: cannot create $dir" >&2
done

# check storage is readable/writable by the running user.
# pre-rootless volumes may be owned by root:0 -- surface this clearly
# rather than letting stores silently fail at load time.
STORAGE_DIR="${STORAGE_PATH:-/app/storage}"

# when started with --user flag (non-root), skip remapping and run directly
if [ "$(id -u)" -ne 0 ]; then
if [ -d "$STORAGE_DIR" ]; then
if ! touch "$STORAGE_DIR/.write-check" 2>/dev/null; then
echo "ERROR: storage directory $STORAGE_DIR is not writable by UID $(id -u)." >&2
echo "If upgrading from a pre-rootless image, fix with:" >&2
echo " docker run --rm -v <your-volume>:/app/storage busybox chown -R $(id -u):$(id -g) /app/storage" >&2
exit 1
fi
rm -f "$STORAGE_DIR/.write-check"
fi
exec "$@"
fi

# running as root -- remap tracefinity user/group if PUID/PGID are set.
# default: 1000:1000 (unchanged from image build).
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"

CUR_UID=$(id -u tracefinity)
CUR_GID=$(id -g tracefinity)

if [ "$PGID" != "$CUR_GID" ]; then
groupmod -o -g "$PGID" tracefinity
fi

if [ "$PUID" != "$CUR_UID" ]; then
usermod -o -u "$PUID" tracefinity
fi

# chown storage only when ownership doesn't already match
if [ -d "$STORAGE_DIR" ]; then
OWNER_UID=$(stat -c '%u' "$STORAGE_DIR" 2>/dev/null || stat -f '%u' "$STORAGE_DIR" 2>/dev/null)
OWNER_GID=$(stat -c '%g' "$STORAGE_DIR" 2>/dev/null || stat -f '%g' "$STORAGE_DIR" 2>/dev/null)
if [ "$OWNER_UID" != "$PUID" ] || [ "$OWNER_GID" != "$PGID" ]; then
chown -R "$PUID:$PGID" "$STORAGE_DIR"
fi
fi

# check storage is writable by the target user
if [ -d "$STORAGE_DIR" ]; then
if ! touch "$STORAGE_DIR/.write-check" 2>/dev/null; then
echo "ERROR: storage directory $STORAGE_DIR is not writable by UID $(id -u)." >&2
echo "If upgrading from a pre-rootless image, fix with:" >&2
echo " docker run --rm -v <your-volume>:/app/storage busybox chown -R 1000:1000 /app/storage" >&2
if ! gosu tracefinity touch "$STORAGE_DIR/.write-check" 2>/dev/null; then
echo "ERROR: storage directory $STORAGE_DIR is not writable by UID $PUID." >&2
echo "Fix with one of:" >&2
echo " docker run -e PUID=\$(id -u) -e PGID=\$(id -g) ..." >&2
echo " docker run --rm -v <your-volume>:/app/storage busybox chown -R $PUID:$PGID /app/storage" >&2
exit 1
fi
rm -f "$STORAGE_DIR/.write-check"
fi

# supervisord must run as root to open /dev/stdout for child log capture
# (procfs fd permissions block non-root). inject user= directives so
# supervisor drops privileges per-child. these are NOT baked into the
# Dockerfile config because --user mode can't setuid at all.
SUPERVISOR_CONF="/etc/supervisor/conf.d/tracefinity.conf"
if [ -f "$SUPERVISOR_CONF" ] && ! grep -q "^user=" "$SUPERVISOR_CONF"; then
sed -i '/^\[supervisord\]$/a user=root' "$SUPERVISOR_CONF"
sed -i '/^\[program:.*\]$/a user=tracefinity' "$SUPERVISOR_CONF"
fi

exec "$@"
12 changes: 12 additions & 0 deletions docs/resource-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ resources:

For BiRefNet Lite, request 8Gi with a limit of 10Gi. The 128Mi placeholder in early Helm values is not viable for any configuration.

## Volume Permissions (Unraid / TrueNAS)

The container runs as UID 1000 by default. On NAS platforms where the host volume is owned by a different user (e.g. `nobody:users` / 99:100 on Unraid), set `PUID` and `PGID` to match:

```bash
docker run -p 3000:3000 -e PUID=99 -e PGID=100 -v /mnt/user/appdata/tracefinity:/app/storage ghcr.io/tracefinity/tracefinity
```

When these variables are set, the entrypoint remaps the internal `tracefinity` user to the given UID/GID and chowns `/app/storage` before starting the application. When unset, behaviour is identical to previous releases (UID 1000:1000).

The `--user` flag still works for platforms that support it directly.

## Platform Support

| Platform | Status |
Expand Down
182 changes: 182 additions & 0 deletions tests/test_entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/bin/sh
# tests for docker-entrypoint.sh PUID/PGID logic.
# run inside the docker image: docker run --rm --entrypoint sh <image> /app/tests/test_entrypoint.sh
set -e

PASS=0
FAIL=0
ENTRYPOINT="docker-entrypoint.sh"

# find entrypoint
if [ -f "/usr/local/bin/$ENTRYPOINT" ]; then
ENTRYPOINT="/usr/local/bin/$ENTRYPOINT"
elif [ -f "./$ENTRYPOINT" ]; then
ENTRYPOINT="./$ENTRYPOINT"
else
echo "SKIP: $ENTRYPOINT not found (run inside docker image)"
exit 0
fi

assert_eq() {
desc="$1"; expected="$2"; actual="$3"
if [ "$expected" = "$actual" ]; then
PASS=$((PASS + 1))
echo " PASS: $desc"
else
FAIL=$((FAIL + 1))
echo " FAIL: $desc (expected=$expected actual=$actual)"
fi
}

# need root for usermod/groupmod tests
if [ "$(id -u)" -ne 0 ]; then
echo "SKIP: must run as root to test PUID/PGID remapping"
exit 0
fi

# need gosu
if ! command -v gosu >/dev/null 2>&1; then
echo "SKIP: gosu not installed (run inside docker image)"
exit 0
fi

# need the tracefinity user
if ! id tracefinity >/dev/null 2>&1; then
echo "SKIP: tracefinity user not found (run inside docker image)"
exit 0
fi

reset_user() {
usermod -o -u 1000 tracefinity 2>/dev/null || true
groupmod -o -g 1000 tracefinity 2>/dev/null || true
}

# supervisor config tests run FIRST, before any entrypoint call mutates it
echo "=== test: supervisor config ships without user= directives ==="
SUPERVISOR_CONF="/etc/supervisor/conf.d/tracefinity.conf"
if [ -f "$SUPERVISOR_CONF" ]; then
BEFORE_USER_COUNT=$(grep -c "^user=" "$SUPERVISOR_CONF" || true)
assert_eq "no user= directives in shipped config" "0" "$BEFORE_USER_COUNT"
else
echo " SKIP: supervisor config not found"
fi

echo "=== test: entrypoint injects supervisor user directives when root ==="
if [ -f "$SUPERVISOR_CONF" ]; then
reset_user
unset PUID PGID
TESTDIR=$(mktemp -d)
export STORAGE_PATH="$TESTDIR"
chown -R tracefinity:tracefinity "$TESTDIR"

$ENTRYPOINT true 2>/dev/null

for prog in nginx backend frontend; do
HAS_USER=$(sed -n "/^\[program:$prog\]/,/^\[/p" "$SUPERVISOR_CONF" | grep -c "^user=tracefinity")
assert_eq "[program:$prog] has user=tracefinity" "1" "$HAS_USER"
done

HAS_ROOT=$(sed -n '/^\[supervisord\]/,/^\[/p' "$SUPERVISOR_CONF" | grep -c "^user=root")
assert_eq "[supervisord] has user=root" "1" "$HAS_ROOT"

ROOT_COUNT=$(grep -c "^user=root" "$SUPERVISOR_CONF")
assert_eq "user=root appears exactly once" "1" "$ROOT_COUNT"

rm -rf "$TESTDIR"
unset STORAGE_PATH
fi

echo "=== test: supervisor injection is idempotent ==="
if [ -f "$SUPERVISOR_CONF" ]; then
reset_user
unset PUID PGID
TESTDIR=$(mktemp -d)
export STORAGE_PATH="$TESTDIR"
chown -R tracefinity:tracefinity "$TESTDIR"

# run entrypoint again on already-injected config
$ENTRYPOINT true 2>/dev/null

ROOT_COUNT=$(grep -c "^user=root" "$SUPERVISOR_CONF")
assert_eq "user=root still exactly once after re-run" "1" "$ROOT_COUNT"

TRACEFINITY_COUNT=$(grep -c "^user=tracefinity" "$SUPERVISOR_CONF")
assert_eq "user=tracefinity exactly 3 after re-run" "3" "$TRACEFINITY_COUNT"

rm -rf "$TESTDIR"
unset STORAGE_PATH
fi

echo "=== test: default mode (no PUID/PGID) ==="
reset_user
unset PUID PGID

OUTPUT=$(id -u tracefinity)
assert_eq "default tracefinity UID is 1000" "1000" "$OUTPUT"

OUTPUT=$(id -g tracefinity)
assert_eq "default tracefinity GID is 1000" "1000" "$OUTPUT"

echo "=== test: PUID/PGID remapping ==="
reset_user
export PUID=99
export PGID=100

$ENTRYPOINT true 2>/dev/null

OUTPUT=$(id -u tracefinity)
assert_eq "remapped UID is 99" "99" "$OUTPUT"

OUTPUT=$(id -g tracefinity)
assert_eq "remapped GID is 100" "100" "$OUTPUT"

echo "=== test: storage ownership ==="
reset_user
TESTDIR=$(mktemp -d)
export STORAGE_PATH="$TESTDIR"
mkdir -p "$TESTDIR/uploads"
chown -R root:root "$TESTDIR"

export PUID=99
export PGID=100
$ENTRYPOINT true 2>/dev/null

OWNER=$(stat -c '%u:%g' "$TESTDIR" 2>/dev/null || stat -f '%u:%g' "$TESTDIR" 2>/dev/null)
assert_eq "storage chowned to 99:100" "99:100" "$OWNER"

echo "=== test: chown skipped when already correct ==="
# storage already 99:100 from previous test, run again
$ENTRYPOINT true 2>/dev/null
OWNER=$(stat -c '%u:%g' "$TESTDIR" 2>/dev/null || stat -f '%u:%g' "$TESTDIR" 2>/dev/null)
assert_eq "storage still 99:100 after no-op re-run" "99:100" "$OWNER"

rm -rf "$TESTDIR"
unset STORAGE_PATH PUID PGID

echo "=== test: idempotent remapping ==="
reset_user
export PUID=1000
export PGID=1000

$ENTRYPOINT true 2>/dev/null

OUTPUT=$(id -u tracefinity)
assert_eq "PUID=1000 stays 1000" "1000" "$OUTPUT"
unset PUID PGID

echo "=== test: gosu write-check uses target user ==="
reset_user
TESTDIR=$(mktemp -d)
export STORAGE_PATH="$TESTDIR"
chown -R tracefinity:tracefinity "$TESTDIR"
unset PUID PGID

$ENTRYPOINT true 2>/dev/null
assert_eq "write-check passed for tracefinity" "0" "$?"

rm -rf "$TESTDIR"
unset STORAGE_PATH

echo ""
echo "results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
Loading