diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ef0389629..cf48d1b6d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1161,7 +1161,7 @@ jobs:
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
--security-opt no-new-privileges:true \
- --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
+ --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
-v "$RUNNER_TEMP/trek/data:/app/data" \
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
@@ -1194,6 +1194,17 @@ jobs:
curl -sf http://127.0.0.1:3000/ | grep -q '
' \
|| { echo "::error::/ does not serve the client"; exit 1; }
+ # Same capabilities as the shipped compose file: dumb-init has to be able
+ # to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
+ - name: Stop gracefully
+ run: |
+ set -euo pipefail
+ docker stop -t 30 trek-ci >/dev/null
+ code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
+ [ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
+ docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
+ || { echo "::error::the server never saw SIGTERM"; exit 1; }
+
- name: Container log
if: always()
run: docker logs trek-ci 2>&1 || true
diff --git a/docker-compose.yml b/docker-compose.yml
index ab879c62a..5c0c97f76 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,6 +11,10 @@ services:
- CHOWN
- SETUID
- SETGID
+ # dumb-init runs as root and forwards SIGTERM to node, which runs as the
+ # node user; signalling another user's process needs KILL. Without it
+ # docker stop ends in SIGKILL and the graceful shutdown never runs.
+ - KILL
tmpfs:
- /tmp:noexec,nosuid,size=128m
ports:
diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md
index 1027650cc..1379a9a9b 100644
--- a/wiki/Install-Docker-Compose.md
+++ b/wiki/Install-Docker-Compose.md
@@ -15,7 +15,7 @@ The compose file ships with several hardening options enabled by default:
| `read_only: true` | Mounts the container filesystem read-only; only the two mounted volumes (`/app/data`, `/app/uploads`) and `/tmp` are writable |
| `security_opt: no-new-privileges:true` | Prevents the process from gaining additional Linux privileges via setuid/setgid executables |
| `cap_drop: [ALL]` | Drops all Linux capabilities from the container |
-| `cap_add: [CHOWN, SETUID, SETGID]` | Adds back only the capabilities needed for the entrypoint to drop privileges to the `node` user |
+| `cap_add: [CHOWN, SETUID, SETGID, KILL]` | Adds back only what the container needs: the first three let the entrypoint drop privileges to the `node` user, `KILL` lets `dumb-init` pass the stop signal on to it, so `docker stop` shuts TREK down cleanly instead of killing it |
| `tmpfs: /tmp:noexec,nosuid,size=128m` | Mounts a 128 MB in-memory `/tmp`; required because the container root is read-only |
> **Note (Docker from snap):** If you installed Docker via `snap` (config under `/var/snap/docker/...`), `no-new-privileges:true` will prevent the container from starting with `exec /usr/bin/dumb-init: operation not permitted`. This is a [snap/AppArmor limitation](https://bugs.launchpad.net/snapd/+bug/1908448), not a TREK issue — install Docker from the [official apt repository](https://docs.docker.com/engine/install/ubuntu/) instead, or remove `no-new-privileges`. See [Troubleshooting](Troubleshooting#container-wont-start-exec-usrbindumb-init-operation-not-permitted).