Skip to content

Commit 8d95400

Browse files
fix: run the runtime container as a non-root user (#80)
Follow-up to #75 addressing the review feedback: - Fixed uid/gid (100:101) created explicitly so the documented volume ownership instructions can never drift between rebuilds. - /app/updates pre-created and chowned in the runtime stage, so the default LOCAL_BUCKET_BASE_PATH works without root. - USER is numeric (100:101), letting Kubernetes verify runAsNonRoot: true without an explicit runAsUser per pod spec. - Storage docs: volume-ownership note for bind mounts (chown 100:101) and Kubernetes (fsGroup: 101). - scripts/test-docker-nonroot.sh asserts the contract: numeric ids, default local mode boots and writes as uid 100, and (on Linux hosts) a root-owned bind mount is rejected.
1 parent 168a4e5 commit 8d95400

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

Dockerfile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@ COPY updates ./updates
1818
RUN GOOS=linux GOARCH=${TARGETARCH} go build -o main ./cmd/api
1919

2020
FROM alpine:latest
21-
RUN apk add --no-cache bash
21+
# Fixed uid/gid so USER can be numeric below and volume ownership instructions
22+
# (chown 100:101 / fsGroup: 101) stay stable across rebuilds. /app/updates is
23+
# pre-created and chowned so the default LOCAL_BUCKET_BASE_PATH (./updates)
24+
# stays writable without root.
25+
RUN apk add --no-cache bash && \
26+
addgroup -S -g 101 ota && adduser -S -u 100 -G ota ota && \
27+
mkdir -p /app/updates && chown ota:ota /app/updates
2228
WORKDIR /app
2329
COPY --from=builder /app/main /app/main
2430
COPY --from=dashboard-builder /app/apps/dashboard/dist /app/apps/dashboard/dist
31+
# Numeric UID:GID (not "ota") so Kubernetes can verify runAsNonRoot: true
32+
# without requiring an explicit runAsUser in every pod spec.
33+
USER 100:101
2534
EXPOSE 3000
2635
CMD ["/app/main"]

apps/docs/docs/server-configuration/storage.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ import TabItem from '@theme/TabItem';
2929
STORAGE_MODE=local
3030
LOCAL_BUCKET_BASE_PATH=/path/to/your/assets
3131
```
32+
33+
:::info Volume ownership
34+
35+
The Docker image runs as a non-root user (uid `100`, gid `101`). The default
36+
`LOCAL_BUCKET_BASE_PATH` (`/app/updates` inside the container) is already
37+
writable, but if you mount a volume over it — or point
38+
`LOCAL_BUCKET_BASE_PATH` at a bind mount — the mounted directory must be
39+
writable by that user:
40+
41+
- **Docker bind mounts (Linux):** `chown 100:101 ./updates` on the host
42+
before mounting.
43+
- **Kubernetes:** set `fsGroup: 101` in the pod's `securityContext`.
44+
45+
:::
3246
</TabItem>
3347
<TabItem value="s3" label="Amazon S3">
3448
To enable Amazon S3 as your storage solution, you need to set the following environment variables:

scripts/test-docker-nonroot.sh

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env sh
2+
# Verifies the runtime image's non-root contract:
3+
# 1. The container runs as numeric uid 100 / gid 101 (so Kubernetes can
4+
# verify `runAsNonRoot: true` without an explicit runAsUser).
5+
# 2. Default local storage mode works without root: /app/updates is
6+
# writable, so migrations (.migrationhistory) and boot succeed with no
7+
# volume mounted.
8+
# 3. (Linux only) A root-owned bind mount is correctly rejected — this is
9+
# the scenario the docs tell users to fix with chown 100:101 / fsGroup.
10+
#
11+
# Usage: ./scripts/test-docker-nonroot.sh [image-tag]
12+
set -eu
13+
14+
IMAGE="${1:-expo-open-ota:nonroot-test}"
15+
CONTAINER="expo-open-ota-nonroot-test"
16+
17+
fail() { echo "FAIL: $1" >&2; docker logs "$CONTAINER" 2>&1 | tail -5 >&2 || true; docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; exit 1; }
18+
cleanup() { docker rm -f "$CONTAINER" >/dev/null 2>&1 || true; }
19+
trap cleanup EXIT
20+
21+
echo "==> Building image"
22+
docker build -q -t "$IMAGE" .
23+
24+
echo "==> 1/3 container runs as numeric uid 100 / gid 101"
25+
uid="$(docker run --rm "$IMAGE" id -u)"
26+
gid="$(docker run --rm "$IMAGE" id -g)"
27+
[ "$uid" = "100" ] || fail "expected uid 100, got $uid"
28+
[ "$gid" = "101" ] || fail "expected gid 101, got $gid"
29+
echo " ok (uid=$uid gid=$gid)"
30+
31+
echo "==> 2/3 default local mode boots without root (writes to /app/updates)"
32+
docker run -d --name "$CONTAINER" -p 13000:3000 \
33+
-e STORAGE_MODE=local \
34+
-e BASE_URL=http://localhost:13000 \
35+
-e JWT_SECRET=test-secret \
36+
-e EXPO_ACCESS_TOKEN=test -e EXPO_APP_ID=test \
37+
"$IMAGE" >/dev/null
38+
i=0
39+
until curl -sf http://localhost:13000/hc >/dev/null 2>&1; do
40+
i=$((i+1)); [ "$i" -lt 20 ] || fail "server did not become healthy on default LOCAL_BUCKET_BASE_PATH"
41+
sleep 0.5
42+
done
43+
# migrations write .migrationhistory into the bucket path — prove the write happened as uid 100
44+
owner="$(docker exec "$CONTAINER" stat -c '%u' /app/updates/.migrationhistory 2>/dev/null || echo missing)"
45+
[ "$owner" = "100" ] || fail "expected .migrationhistory owned by uid 100, got: $owner"
46+
docker rm -f "$CONTAINER" >/dev/null
47+
echo " ok"
48+
49+
echo "==> 3/3 root-owned bind mount is rejected (Linux only)"
50+
if [ "$(uname -s)" = "Linux" ]; then
51+
tmpdir="$(mktemp -d)"
52+
# root-owned, no group/other write — the situation the docs warn about
53+
chmod 755 "$tmpdir"
54+
docker run -d --name "$CONTAINER" \
55+
-e STORAGE_MODE=local -e LOCAL_BUCKET_BASE_PATH=/updates \
56+
-e BASE_URL=http://localhost:3000 -e JWT_SECRET=test-secret \
57+
-e EXPO_ACCESS_TOKEN=test -e EXPO_APP_ID=test \
58+
-v "$tmpdir":/updates "$IMAGE" >/dev/null
59+
sleep 3
60+
if docker exec "$CONTAINER" true 2>/dev/null && [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" = "true" ]; then
61+
fail "expected boot failure on root-owned bind mount, but server is running"
62+
fi
63+
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
64+
rm -rf "$tmpdir"
65+
echo " ok (boot failed as expected; fix per docs: chown 100:101)"
66+
else
67+
echo " skipped (bind-mount ownership semantics only apply on Linux hosts)"
68+
fi
69+
70+
echo "PASS: non-root contract holds"

0 commit comments

Comments
 (0)