services: app: image: ghcr.io/erp-mafia/gnubok:${IMAGE_TAG:-latest} env_file: .env # Bound to loopback by default: put a TLS-terminating reverse proxy in # front (see docker/compose.caddy.yml). Override PORT in .env to change # the host port, or use the caddy overlay to remove the host binding. ports: - "127.0.0.1:${PORT:-3000}:3000" security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true init: true # The root filesystem is read-only, so the served bundle lives on writable # mounts: at every start the entrypoint empties them, copies the image-baked # templates in, sed-substitutes the NEXT_PUBLIC_* placeholders, then drops # the write bits. # # /app/.next is a named volume, not a tmpfs (#3164). A tmpfs needs a fixed # size cap, and the bundle grows past any cap as releases add features; its # pages also count against mem_limit and cannot be reclaimed, so raising # the cap only trades "No space left on device" for an OOM kill. The volume # has no ceiling of its own and its page cache is reclaimable. It holds # nothing worth keeping: the entrypoint rebuilds it from the image on every # start, so it needs no backup and can be removed whenever the app is down. # # Docker seeds a new named volume from the image's /app/.next, which the # Dockerfile creates owned by nextjs (uid 1001) with mode 750, matching the # uid=1001,mode=750 of the /app/public tmpfs; the entrypoint re-applies 750 # at every start, in case an older image created the volume. It runs # UNPRIVILEGED as nextjs and owns both, so it can populate them with no # CAP_CHOWN/CAP_SETUID, which is what lets the container run under # `cap_drop: ALL`. nodejs-group members can read the served bundle; no # other user can. volumes: - next_runtime:/app/.next tmpfs: - /tmp - /app/public:uid=1001,gid=1001,mode=750,size=200m # CPU limits are intentionally left to host tooling or a local override. # Some Compose builds bundled with NAS platforms reject the `cpus` key. mem_limit: 1g pids_limit: 200 restart: unless-stopped healthcheck: # 127.0.0.1, not localhost: the app binds 0.0.0.0 (IPv4) but localhost can # resolve to ::1 (IPv6), where nothing listens → false-unhealthy → the # cron service (depends_on healthy) never starts. test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 5s start_period: 30s # `start_interval` requires Docker Compose 2.20.2+ and Docker Engine 25.0+. # Omitting it keeps the base file usable with older NAS container stacks. retries: 3 logging: driver: json-file options: max-size: "10m" max-file: "5" cron: build: context: docker dockerfile: cron.Dockerfile depends_on: app: condition: service_healthy environment: - CRON_SECRET=${CRON_SECRET} - APP_URL=http://app:3000 volumes: - ./docker/crontab.self-hosted:/etc/supercronic/crontab:ro init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp mem_limit: 64m pids_limit: 30 restart: unless-stopped logging: driver: json-file options: max-size: "10m" max-file: "5" volumes: # The app's runtime copy of the Next.js bundle (see the app service above). next_runtime: