mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
92a4931f44
Tiny /bin/sh entrypoint shim that, if invoked as root, reads PUID/PGID
env vars (defaulting to 99/100 — Unraid's nobody:users), remaps the
in-image pad user, chowns /data, and execs the binary via su-exec.
If invoked as non-root (caller passed --user), it just execs directly
— caller knows what they want.
Solves the classic Unraid appdata-ownership-mismatch first-run failure
where the in-image pad user (uid 1000) couldn't write to a host volume
owned by nobody:users (uid 99, gid 100). Reusable on Synology / QNAP /
TrueNAS where the host's appdata user is similarly non-1000.
Behavior changes:
- Container starts as root (USER directive removed). Entrypoint drops
privileges via su-exec before exec'ing pad — standard PUID/PGID
pattern. Healthcheck adapts: root → su-exec to pad; non-root →
direct wget.
- chown -R is always-run (warn-and-continue on per-file failures). A
shallow stat-only check would silently break pad on a restored
backup with mixed-ownership inner files.
- Healthcheck start-period bumped 10s → 60s to absorb slow chown -R
on large attachment stores.
- Compose default 1000/1000 for backward compat with existing deploys
whose volumes were created under the previous USER pad image.
- Raw `docker run` defaults to 99/100 (Unraid convention).
Validation rejects PUID=0 / PGID=0 (would defeat the unprivileged-user
invariant), empty values, and non-numeric values with clear errors.
Goes through 11 rounds of codex pre-implementation design review,
catching:
- gid bug where groupmod alone leaves /etc/passwd's primary-gid stale
- compose $-interpolation gotcha (needs $$( ) not $())
- getent missing from default alpine BusyBox
- shell ${VAR:-} silently masking explicit empty values
- healthcheck running as root after USER drop
- su-exec failing for --user non-root pass-through
Part of PLAN-1166 (Pad on Unraid — Community Apps launch). Unblocks
TASK-1169 (XML template authoring).
95 lines
4.0 KiB
Docker
95 lines
4.0 KiB
Docker
# Stage 1: Build web UI
|
|
FROM node:22-alpine AS web-builder
|
|
WORKDIR /app/web
|
|
COPY web/package.json web/package-lock.json ./
|
|
RUN npm ci
|
|
COPY web/ ./
|
|
RUN npm run build
|
|
|
|
# Stage 2: Build Go binary
|
|
FROM golang:1.26-alpine AS go-builder
|
|
WORKDIR /app
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
COPY --from=web-builder /app/web/build ./web/build
|
|
|
|
# Build metadata. All three are caller-passed via --build-arg (see
|
|
# pad-cloud/scripts/build-pad.sh for the production wrapper that
|
|
# resolves them from the host's pad checkout).
|
|
#
|
|
# Why all three are passed in vs. computed inside the container:
|
|
#
|
|
# - .dockerignore intentionally excludes .git/, so an in-container
|
|
# `git rev-parse` substitution returns empty (with `2>/dev/null`
|
|
# swallowing the error) — the previous Dockerfile shipped "dev"
|
|
# forever because of this. We don't want to add .git/ to the
|
|
# context just for this; pre-computing on the host is the
|
|
# standard pattern.
|
|
# - `date` would work in-container but a fresh `date` value on
|
|
# every build invalidates layer caching for this RUN. Passing
|
|
# as ARG lets the caller decide cache semantics.
|
|
#
|
|
# Defaults are deliberately ugly-but-honest so a `docker build .`
|
|
# without args produces a binary whose pad_version makes the
|
|
# misconfiguration obvious ("dev (unknown)") rather than hiding it.
|
|
ARG VERSION=dev
|
|
ARG COMMIT=unknown
|
|
ARG BUILD_TIME=
|
|
RUN CGO_ENABLED=0 go build \
|
|
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildTime=${BUILD_TIME}" \
|
|
-o pad ./cmd/pad
|
|
|
|
# Stage 3: Runtime
|
|
FROM alpine:3.21
|
|
# ca-certificates: TLS roots for outbound HTTPS (e.g. Maileroo email).
|
|
# tzdata: timezone names for Go's time package.
|
|
# shadow: provides usermod / groupmod (BusyBox's adduser/addgroup don't
|
|
# ship modify equivalents — needed by docker-entrypoint.sh).
|
|
# su-exec: lightweight alpine equivalent of gosu — execs the target in
|
|
# the same process so SIGTERM propagates correctly. Used by the
|
|
# entrypoint shim AND by the conditional healthcheck below.
|
|
RUN apk add --no-cache ca-certificates tzdata shadow su-exec
|
|
COPY --from=go-builder /app/pad /usr/local/bin/pad
|
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
|
# In-image `pad` user/group at uid/gid 1000 — the entrypoint shim remaps
|
|
# these to match PUID/PGID at container start (defaults 99/100, the
|
|
# Unraid `nobody:users` convention). The actual numeric IDs can be
|
|
# anything; 1000 is just the historical default.
|
|
RUN addgroup -g 1000 pad \
|
|
&& adduser -D -u 1000 -G pad -h /home/pad pad \
|
|
&& mkdir -p /data \
|
|
&& chown -R pad:pad /data
|
|
ENV PAD_DATA_DIR=/data
|
|
ENV PAD_HOST=0.0.0.0
|
|
|
|
# IMPORTANT: NO `USER pad` directive — container starts as root so the
|
|
# entrypoint shim can chown /data + remap user/group ids before
|
|
# dropping privileges via `su-exec pad`. See TASK-1168 / PLAN-1166.
|
|
|
|
EXPOSE 7777
|
|
VOLUME /data
|
|
|
|
# Healthcheck adapts to the caller's chosen execution model:
|
|
#
|
|
# • Default invocation (container starts as root, entrypoint drops to pad):
|
|
# healthcheck runs as ROOT (Docker uses the image USER, which is root
|
|
# because we removed the USER directive). Wrap with su-exec so it
|
|
# matches the main process's unprivileged UID.
|
|
#
|
|
# • `docker run --user 1234` invocation: healthcheck ALSO runs as 1234.
|
|
# `su-exec pad ...` would fail because 1234 lacks the privilege to
|
|
# switch to user `pad`. Just run wget directly in that branch.
|
|
#
|
|
# start-period bumped 10s → 60s. The always-chown-R policy (D4) means a
|
|
# user restoring a backup with a large attachment store can legitimately
|
|
# spend tens of seconds in the entrypoint before pad starts listening;
|
|
# 60s covers ~600k files at 10k files/sec on local SSD.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|
CMD sh -c 'if [ "$(id -u)" = "0" ]; then exec su-exec pad wget -q --spider http://localhost:7777/api/v1/health; fi; exec wget -q --spider http://localhost:7777/api/v1/health'
|
|
|
|
ENTRYPOINT ["docker-entrypoint.sh", "pad"]
|
|
CMD ["server", "start"]
|