Container sets its data directory's owner and mode itself (closes #340)
The image now starts as root through deploy/docker-entrypoint.sh, which creates DATA_DIR if it is missing, gives the directory and anything in it owned by another user to webhooker, sets the directory to 0750, and runs the command as webhooker with su-exec. An empty root-owned bind mount, or data left by another uid, now works with no step on the host. Started with --user, the script only runs the command. The README drops every instruction to create or chown the host directory; the upaas volume bullet names only the path. Model: opus-5-5
This commit is contained in:
+9
-2
@@ -88,7 +88,9 @@ RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static
|
|||||||
# alpine:3.21, 2026-03-17
|
# alpine:3.21, 2026-03-17
|
||||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates
|
# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app
|
||||||
|
# as webhooker with it.
|
||||||
|
RUN apk --no-cache add ca-certificates su-exec=0.2-r3
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -g 1000 -S webhooker && \
|
RUN addgroup -g 1000 -S webhooker && \
|
||||||
@@ -99,13 +101,17 @@ WORKDIR /app
|
|||||||
# Copy binary from builder
|
# Copy binary from builder
|
||||||
COPY --from=builder /build/bin/webhooker /app/webhooker
|
COPY --from=builder /build/bin/webhooker /app/webhooker
|
||||||
|
|
||||||
|
# Not under /app, which belongs to webhooker: this script runs as root.
|
||||||
|
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create data directory for all SQLite databases (main app DB +
|
# Create data directory for all SQLite databases (main app DB +
|
||||||
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
|
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
|
||||||
RUN mkdir -p /var/lib/webhooker
|
RUN mkdir -p /var/lib/webhooker
|
||||||
|
|
||||||
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
|
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
|
||||||
|
|
||||||
USER webhooker
|
# No USER: the entrypoint starts as root to make the data directory
|
||||||
|
# webhooker's, then runs the app as webhooker.
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
@@ -124,4 +130,5 @@ ENV BIND_ADDRESS=0.0.0.0
|
|||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
CMD ["/app/webhooker"]
|
CMD ["/app/webhooker"]
|
||||||
|
|||||||
@@ -552,8 +552,9 @@ printf '%s' "$NEW_PASSWORD" | \
|
|||||||
DATA_DIR=/var/lib/webhooker webhooker resetpw admin
|
DATA_DIR=/var/lib/webhooker webhooker resetpw admin
|
||||||
```
|
```
|
||||||
|
|
||||||
In a container it is the same binary, which the image sets as `CMD`
|
In a container it is the same binary. The image's `CMD` is
|
||||||
rather than `ENTRYPOINT`, so the whole command has to be given:
|
`/app/webhooker`, and a command given to `docker run` replaces all of
|
||||||
|
it, so the whole command has to be given:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -v webhooker-data:/var/lib/webhooker \
|
docker run --rm -v webhooker-data:/var/lib/webhooker \
|
||||||
@@ -690,38 +691,22 @@ those three values rather than trusting the figure. Measured at 65s on
|
|||||||
Docker 29.7.2.) A container `unhealthy` with `connection refused` in
|
Docker 29.7.2.) A container `unhealthy` with `connection refused` in
|
||||||
its health log, or a published port that resets connections, is this.
|
its health log, or a published port that resets connections, is this.
|
||||||
|
|
||||||
The container runs as a non-root user (`webhooker`, UID 1000), exposes
|
The app runs as a non-root user (`webhooker`, UID 1000), exposes port
|
||||||
port 8080, and includes a health check against
|
8080, and includes a health check against `/.well-known/healthcheck`.
|
||||||
`/.well-known/healthcheck`. The `/var/lib/webhooker` volume holds all
|
The `/var/lib/webhooker` volume holds all SQLite databases: the main
|
||||||
SQLite databases: the main application database (`webhooker.db`), the
|
application database (`webhooker.db`), the per-webhook event databases
|
||||||
per-webhook event databases (`events-{uuid}.db`), and any archive
|
(`events-{uuid}.db`), and any archive databases written by `database`
|
||||||
databases written by `database` targets (`archive-{uuid}.db`). Mount
|
targets (`archive-{uuid}.db`). Mount this as a persistent volume to
|
||||||
this as a persistent volume to preserve data across container
|
preserve data across container restarts.
|
||||||
restarts.
|
|
||||||
|
|
||||||
**The bind-mounted directory must be owned by UID 1000, or the
|
**The container sets its data directory's owner and mode itself
|
||||||
container does not start.** Docker creates a `-v` source path that
|
before the app starts**, so a host directory can be mounted as it is,
|
||||||
does not exist yet as `root:root`, and the process runs as UID 1000,
|
whoever owns it. The image's `ENTRYPOINT`,
|
||||||
so it cannot take its `DATA_DIR` lock:
|
`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if
|
||||||
|
it is missing, gives the directory and anything in it that belongs to
|
||||||
```
|
another user to `webhooker`, sets the directory to `0750`, and only
|
||||||
webhooker: locking data directory /var/lib/webhooker: open
|
then runs the app as `webhooker`. Started with `--user`, it changes
|
||||||
/var/lib/webhooker/webhooker.lock: permission denied
|
nothing and runs the app as that user.
|
||||||
```
|
|
||||||
|
|
||||||
It exits non-zero at that point, before opening any database. Create
|
|
||||||
the directory ahead of the first `docker run`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /path/to/data
|
|
||||||
chown 1000:1000 /path/to/data
|
|
||||||
chmod 750 /path/to/data
|
|
||||||
```
|
|
||||||
|
|
||||||
The same `chown` is what a restore needs — see step 4 of
|
|
||||||
[Restore](#restore). A **named volume** does not have this problem:
|
|
||||||
Docker copies the image's ownership onto a volume it initializes, and
|
|
||||||
the image creates `/var/lib/webhooker` owned by `webhooker`.
|
|
||||||
|
|
||||||
**The file modes are not yours to set, and do not depend on the
|
**The file modes are not yours to set, and do not depend on the
|
||||||
directory.** `webhooker.db` holds target configuration in plaintext —
|
directory.** `webhooker.db` holds target configuration in plaintext —
|
||||||
@@ -729,13 +714,10 @@ bearer tokens, API keys, Slack webhook URLs — along with the session
|
|||||||
encryption key, so webhooker creates every SQLite file it owns `0600`:
|
encryption key, so webhooker creates every SQLite file it owns `0600`:
|
||||||
each database and both of its `-wal` and `-shm` sidecars, across all
|
each database and both of its `-wal` and `-shm` sidecars, across all
|
||||||
three tiers. Files an earlier build left `0644` are tightened when
|
three tiers. Files an earlier build left `0644` are tightened when
|
||||||
they are opened. A `DATA_DIR` webhooker creates itself is `0750`, but
|
they are opened. The directory's `0750` is defence in depth — it stops
|
||||||
a bind mount supplies its own directory and Docker's default for one
|
other local users listing the directory and learning your webhook
|
||||||
it creates is `0755`; the `0600` files hold there regardless. The
|
UUIDs from the `events-{uuid}.db` filenames — not the barrier
|
||||||
`chmod 750` above is defence in depth — it stops other local users
|
protecting the credentials.
|
||||||
listing the directory and learning your webhook UUIDs from the
|
|
||||||
`events-{uuid}.db` filenames — not the barrier protecting the
|
|
||||||
credentials.
|
|
||||||
|
|
||||||
### Running under upaas
|
### Running under upaas
|
||||||
|
|
||||||
@@ -751,17 +733,6 @@ repository's `Dockerfile` and runs it. The app needs:
|
|||||||
app name, port `8080`. Leave `PORT` unset: the image's health check
|
app name, port `8080`. Leave `PORT` unset: the image's health check
|
||||||
probes `8080`.
|
probes `8080`.
|
||||||
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
- **Volume:** one host directory mounted at `/var/lib/webhooker`.
|
||||||
upaas bind-mounts the host path it is given and does not create it,
|
|
||||||
and the container does not start unless UID 1000 owns it (see
|
|
||||||
[Running with Docker](#running-with-docker)). Create it before the
|
|
||||||
first deploy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /path/to/data
|
|
||||||
chown 1000:1000 /path/to/data
|
|
||||||
chmod 750 /path/to/data
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Environment variables:**
|
- **Environment variables:**
|
||||||
- `WEBHOOKER_ENVIRONMENT=prod`
|
- `WEBHOOKER_ENVIRONMENT=prod`
|
||||||
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
- `TRUSTED_PROXIES`: your reverse proxy's address on that Docker
|
||||||
@@ -1020,12 +991,12 @@ done
|
|||||||
`.backup` reads through the WAL and writes a single consistent file with
|
`.backup` reads through the WAL and writes a single consistent file with
|
||||||
no sidecars of its own, so the destination is complete as it stands.
|
no sidecars of its own, so the destination is complete as it stands.
|
||||||
Two caveats. First, the runtime image is `alpine:3.21` with only
|
Two caveats. First, the runtime image is `alpine:3.21` with only
|
||||||
`ca-certificates` added — the `sqlite3` CLI is **not** in it, so run
|
`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in
|
||||||
this on the host against the volume path, or from a throwaway container
|
it, so run this on the host against the volume path, or from a
|
||||||
that mounts the volume. Second, each file is captured at its own
|
throwaway container that mounts the volume. Second, each file is
|
||||||
instant, so a webhook created or an event delivered between two files
|
captured at its own instant, so a webhook created or an event delivered
|
||||||
being copied lands in one and not the other. If you need the whole set
|
between two files being copied lands in one and not the other. If you
|
||||||
coherent as of a single moment, stop the service.
|
need the whole set coherent as of a single moment, stop the service.
|
||||||
|
|
||||||
Note that `sqlite3 <db> .dump` is **not** one of these procedures: it is
|
Note that `sqlite3 <db> .dump` is **not** one of these procedures: it is
|
||||||
an export, it holds a read transaction open for as long as it runs, and
|
an export, it holds a read transaction open for as long as it runs, and
|
||||||
@@ -1079,21 +1050,11 @@ with any `-wal`/`-shm` beside it, or wait until there are none.
|
|||||||
archive not opened since a crash. A copy salvaged from a crashed
|
archive not opened since a crash. A copy salvaged from a crashed
|
||||||
instance has them for everything, and needs all of them.
|
instance has them for everything, and needs all of them.
|
||||||
|
|
||||||
4. **Fix ownership.** The container runs as the non-root `webhooker`
|
4. Start the service. The container gives the directory and the
|
||||||
user, UID 1000 / GID 1000. Restored files must be owned by (or
|
restored files to the `webhooker` user before the app starts,
|
||||||
writable by) that UID, and so must the directory itself — SQLite
|
whoever restored them (see
|
||||||
creates the `-wal` and `-shm` sidecars beside the database, so a
|
[Running with Docker](#running-with-docker)). `AutoMigrate` runs
|
||||||
writable file inside a directory it cannot write is not enough:
|
against each restored database as it is opened.
|
||||||
|
|
||||||
```bash
|
|
||||||
chown -R 1000:1000 /path/to/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Restoring as `root` on the host and forgetting this step is the
|
|
||||||
usual way a restore fails.
|
|
||||||
|
|
||||||
5. Start the service. `AutoMigrate` runs against each restored database
|
|
||||||
as it is opened.
|
|
||||||
|
|
||||||
### Upgrades
|
### Upgrades
|
||||||
|
|
||||||
@@ -3071,7 +3032,9 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
- Prometheus metrics behind basic auth
|
- Prometheus metrics behind basic auth
|
||||||
- Static assets embedded in binary (no filesystem access needed at
|
- Static assets embedded in binary (no filesystem access needed at
|
||||||
runtime)
|
runtime)
|
||||||
- Container runs as non-root user (UID 1000)
|
- The app runs as a non-root user (UID 1000) in the container; only
|
||||||
|
the `ENTRYPOINT` script that sets the data directory's owner runs as
|
||||||
|
root, before the app starts
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||||
all of them but `Setting` (data preserved for audit)
|
all of them but `Setting` (data preserved for audit)
|
||||||
|
|
||||||
@@ -3198,10 +3161,13 @@ version is fixed independently of the compiler's:
|
|||||||
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
`GO_LDFLAGS`, so neither can drop the `-X` that stamps the version.
|
||||||
The version arrives as the `VERSION` build arg, since the context
|
The version arrives as the `VERSION` build arg, since the context
|
||||||
has no `.git` (see [Version stamping](#version-stamping)).
|
has no `.git` (see [Version stamping](#version-stamping)).
|
||||||
3. **Runtime stage** (`alpine:3.21`) — copies the static binary,
|
3. **Runtime stage** (`alpine:3.21`) — copies the static binary and
|
||||||
creates the `/var/lib/webhooker` directory for all SQLite databases,
|
`deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker`
|
||||||
runs as the non-root `webhooker` user (UID 1000), exposes port 8080,
|
directory for all SQLite databases, exposes port 8080, and includes
|
||||||
and includes a health check against `/.well-known/healthcheck`.
|
a health check against `/.well-known/healthcheck`. It sets no
|
||||||
|
`USER`: the `ENTRYPOINT` script starts as root, sets the data
|
||||||
|
directory's owner and mode, and runs the app as the non-root
|
||||||
|
`webhooker` user (UID 1000) through `su-exec`.
|
||||||
|
|
||||||
The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
The lint stage invokes `golangci-lint` directly rather than `make lint`:
|
||||||
it is already the pinned linter image, and `make lint` builds
|
it is already the pinned linter image, and `make lint` builds
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted
|
||||||
|
# data directory keeps its owner from the host, often root, and the app
|
||||||
|
# could not write to it. Started as root, this creates DATA_DIR if
|
||||||
|
# needed, gives it and everything in it to webhooker, sets its mode, and
|
||||||
|
# runs the command as webhooker, so the app never runs as root. Started
|
||||||
|
# as another user, it only runs the command.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if [ "$(id -u)" != 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
dir="${DATA_DIR:-/var/lib/webhooker}"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} +
|
||||||
|
chmod 750 "$dir"
|
||||||
|
exec su-exec webhooker "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user