# SPDX-License-Identifier: MIT # Copyright (c) 2026 Netresearch DTT GmbH # compose.yml — production GLPI stack # # Services: # db — mariadb:11, binlog enabled (PITR-ready) # valkey — valkey:9-alpine, GLPI cache backend (RESP-compatible) # app-assets — one-shot init: syncs GLPI's public/ assets into a shared # volume so nginx can serve them # app — our php-fpm image (GLPI code, no nginx) # web — nginx:alpine, serves public/ statics + fastcgi -> app socket # scheduler — ofelia (Netresearch's fork), label-driven cron for GLPI's # front/cron.php (every 2 min) and phpbu (nightly) # backup — phpbu-docker, nightly DB dump + files/config archives # (opt-in: `--profile backup`) # # Quickstart: # 1. make init # generates .env with random secrets # 2. docker compose up -d # 3. docker compose logs -f app # 4. open GLPI_URL (default http://localhost:8080) — login glpi / glpi, # then CHANGE the default passwords immediately. # # Dev overrides (mailpit, exposed db, ...): cp compose.override.yml.example compose.override.yml # TLS / reverse proxy: see examples/compose.traefik.yml etc. # # Resource limits (mem_limit / cpus) are sized for a small-org deployment # (single host, <=100 agents/users) and are intentionally explicit so # operators can tune them. Raise db.mem_limit as the dataset grows and # app.mem_limit together with pm.max_children (zz-glpi.conf, default 10 # workers ~ 1.5g) for more concurrency. name: glpi # Shared logging policy — json-file with size-based rotation (10 MiB * 5 = # 50 MiB cap per container) so container logs can't fill the host disk. x-logging: &logging driver: json-file options: max-size: "10m" max-file: "5" # Shared GLPI environment for every container that runs the app image (app + # scheduler-driven cron execs). GLPI_*_DIR are baked into the image; the DB # credentials, cache DSN and timezone come from .env (`make init`). x-glpi-env: &glpi-env GLPI_DB_HOST: db GLPI_DB_PORT: "3306" GLPI_DB_NAME: ${GLPI_DB_NAME:-glpi} GLPI_DB_USER: ${GLPI_DB_USER:-glpi} GLPI_DB_PASSWORD: ${GLPI_DB_PASSWORD:-} # production hides stack traces + the debug toolbar. Use "development" only # on a throwaway instance. GLPI_ENVIRONMENT_TYPE: ${GLPI_ENVIRONMENT_TYPE:-production} # Valkey speaks the RESP protocol, so GLPI's redis:// cache DSN works as-is. GLPI_CACHE_DSN: ${GLPI_CACHE_DSN:-redis://valkey:6379} REDIS_HOST: valkey REDIS_PORT: "6379" # Initial-install language (only consumed by database:install on first boot). GLPI_DEFAULT_LANGUAGE: ${GLPI_DEFAULT_LANGUAGE:-en_GB} # Set 1 when serving over HTTPS (behind a TLS-terminating proxy) so the # entrypoint marks the PHP session cookie Secure. Default 0 for plain-HTTP # local bring-up. SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-0} TZ: ${TZ:-UTC} services: # --------------------------------------------------------------------- # Database # --------------------------------------------------------------------- db: image: mariadb:12 restart: unless-stopped mem_limit: 2g cpus: 2.0 logging: *logging environment: # Populated by `make init`; mariadb refuses to start with an empty root # password — the failure is loud and the runbook clear. MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-} MARIADB_DATABASE: ${GLPI_DB_NAME:-glpi} MARIADB_USER: ${GLPI_DB_USER:-glpi} MARIADB_PASSWORD: ${GLPI_DB_PASSWORD:-} MARIADB_AUTO_UPGRADE: "1" TZ: ${TZ:-UTC} # sync_binlog=1 + innodb_flush_log_at_trx_commit=1 trade throughput for # durability: every committed transaction is fsync'd before the client # sees the ACK, so a power loss cannot lose writes the binlog already # advertised — required for honest point-in-time recovery. For higher # write throughput at the cost of ~1s of writes on crash, set both to 0/2 # and pair with a UPS / battery-backed cache. # # GLPI requires utf8mb4 and READ-COMMITTED is recommended upstream to # reduce lock contention on its high-write tables. command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci - --transaction-isolation=READ-COMMITTED - --log-bin=mariadb-bin - --binlog-format=ROW - --binlog_expire_logs_seconds=1209600 # 14 days - --sync_binlog=1 - --innodb_flush_log_at_trx_commit=1 - --server-id=1 volumes: - db-data:/var/lib/mysql cap_drop: - ALL cap_add: - CHOWN - DAC_OVERRIDE - FOWNER - SETUID - SETGID healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s # 60s — first-run mysql_install_db on slow disks can exceed 30s; the # longer grace prevents the initial loop marking the container unhealthy # during legitimate bootstrap. start_period: 60s retries: 6 security_opt: - no-new-privileges:true networks: - glpi # --------------------------------------------------------------------- # Valkey — GLPI cache backend (Linux-Foundation Redis fork, drop-in) # --------------------------------------------------------------------- valkey: image: valkey/valkey:9-alpine restart: unless-stopped mem_limit: 384m cpus: 0.5 logging: *logging # AOF-only persistence (appendfsync=everysec) gives at-most-1s loss on # crash with bounded fsync overhead. RDB snapshots are disabled (--save "") # — the cache state doesn't warrant doubling fsync load. command: - valkey-server - --appendonly - "yes" - --appendfsync - everysec - --save - "" - --maxmemory - 256mb - --maxmemory-policy - allkeys-lru environment: TZ: ${TZ:-UTC} volumes: - valkey-data:/data cap_drop: - ALL cap_add: - CHOWN # entrypoint chowns /data to the valkey user - DAC_OVERRIDE # ...and accesses the existing appendonlydir across uids - SETUID # su-exec drops to the valkey user - SETGID healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 10s timeout: 3s retries: 5 security_opt: - no-new-privileges:true networks: - glpi # --------------------------------------------------------------------- # Init: sync GLPI's public/ assets into the shared volume that nginx serves # read-only. Runs once on every `up`; idempotent. # --------------------------------------------------------------------- app-assets: image: ghcr.io/netresearch/glpi-php-fpm:${GLPI_IMAGE_TAG:-latest} restart: "no" mem_limit: 128m cpus: 0.25 logging: *logging entrypoint: ["/bin/sh", "-c"] # Two-phase, per-entry atomic asset swap: stage into .new/ on the same # volume (no cross-device copy), then rename(2) each top-level entry into # place. Bounds the old/new mix window to (entries * rename latency), not # tree size. For GLPI's hash-versioned assets this is acceptable; front # nginx with blue/green if you need strict atomicity. command: - | set -eu STAGE=/shared-public/.new rm -rf "$$STAGE" mkdir -p "$$STAGE" cp -a /var/www/glpi/public/. "$$STAGE"/ for entry in "$$STAGE"/* "$$STAGE"/.[!.]*; do [ -e "$$entry" ] || continue name=$$(basename "$$entry") rm -rf "/shared-public/$$name" mv "$$entry" "/shared-public/$$name" done rmdir "$$STAGE" 2>/dev/null || rm -rf "$$STAGE" echo "[app-assets] synced $$(ls /shared-public | wc -l) entries" volumes: - app-public:/shared-public security_opt: - no-new-privileges:true networks: - glpi # --------------------------------------------------------------------- # App — php-fpm with GLPI # # Hardened: no-new-privileges, cap_drop ALL with only the minimum re-adds # the entrypoint/php-fpm need, tmpfs /tmp, and the unix socket on a shared # tmpfs that `web` mounts. GLPI's mutable state lives on named volumes # mounted off the read-only code root. # --------------------------------------------------------------------- app: image: ghcr.io/netresearch/glpi-php-fpm:${GLPI_IMAGE_TAG:-latest} restart: unless-stopped mem_limit: 1.5g cpus: 2.0 logging: *logging depends_on: db: condition: service_healthy valkey: condition: service_healthy environment: <<: *glpi-env # First boot runs database:install; later boots run database:update. # Set GLPI_AUTOINSTALL=false to manage install/upgrade manually, or # SKIP_DB_UPDATE=true on multi-replica deployments (upgrade once, out # of band). GLPI_AUTOINSTALL: ${GLPI_AUTOINSTALL:-true} SKIP_DB_UPDATE: ${SKIP_DB_UPDATE:-false} volumes: - glpi-config:/etc/glpi # config_db.php + glpicrypt.key - glpi-var:/var/lib/glpi/files # files/ tree (uploads, cache, sessions) - glpi-plugins:/var/lib/glpi/plugins # marketplace plugins - glpi-logs:/var/log/glpi # application logs - fpm-sock:/run/php-fpm # php-fpm unix socket (shared with web) healthcheck: # Liveness only: php-fpm /ping returns "pong" (the worker pool is # accepting FastCGI). This is what `web` gates on, so it must NOT depend # on the scheduler — a dead scheduler should never stop the app from # serving. Scheduler health is observed separately (docker compose ps / # ofelia logs), not folded into app liveness. test: - CMD-SHELL - 'SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET cgi-fcgi -bind -connect /run/php-fpm/glpi.sock 2>/dev/null | grep -q pong' interval: 30s timeout: 10s # 180s covers first-boot database:install + the first heartbeat write. start_period: 180s retries: 3 security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - CHOWN # entrypoint chowns state volumes to www-data - DAC_OVERRIDE # entrypoint accesses files across uid boundaries - FOWNER # entrypoint chmods state dirs - SETUID # su-exec drops to www-data - SETGID # su-exec drops to www-data - KILL # php-fpm signals its workers tmpfs: - /tmp:mode=1777,size=128M,noexec,nosuid,nodev labels: # ofelia (scheduler service) execs these inside this container on a # schedule, via the docker socket. ofelia.enabled: "true" # GLPI's task runner — processes due automatic actions (notifications, # inventory, telemetry, …). front/cron.php is GLPI's external-cron entry # point; run it every 2 minutes. ofelia.job-exec.glpi-cron.schedule: "@every 2m" ofelia.job-exec.glpi-cron.user: "www-data" ofelia.job-exec.glpi-cron.command: "php /var/www/glpi/front/cron.php" networks: - glpi # --------------------------------------------------------------------- # Web — nginx, serves public/ statics + reverse-proxies PHP to the app # over the shared unix socket. # --------------------------------------------------------------------- web: image: nginx:alpine restart: unless-stopped mem_limit: 128m cpus: 0.5 logging: *logging depends_on: app-assets: condition: service_completed_successfully app: condition: service_healthy environment: TZ: ${TZ:-UTC} volumes: - app-public:/var/www/glpi/public:ro # NOT :ro — unix-socket connect() needs write access to the socket file, # which docker's :ro flag blocks even when the socket mode allows it. - fpm-sock:/run/php-fpm - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./config/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro # security-headers.conf is `include`d by every location block in # default.conf — must be mounted or nginx fails to start. - ./config/nginx/snippets:/etc/nginx/snippets:ro ports: # Loopback-only by default — front with a reverse proxy for public # exposure (examples/compose.traefik.yml). Override GLPI_HTTP_PORT in .env. - "127.0.0.1:${GLPI_HTTP_PORT:-8080}:80" healthcheck: test: ["CMD", "wget", "-qO-", "--spider", "http://127.0.0.1/health"] interval: 30s timeout: 5s start_period: 20s retries: 3 security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - CHOWN - DAC_OVERRIDE - SETUID - SETGID - NET_BIND_SERVICE tmpfs: - /tmp:mode=1777,size=64M,noexec,nosuid,nodev - /var/cache/nginx:mode=0755,size=64M,nosuid,nodev # PID file only (nginx.conf). A tmpfs on /var/run (= /run) would cover # the fpm-sock volume at /run/php-fpm; whether it does depends on the # order in which the Docker engine applies the mounts. - /run/nginx:mode=0755,size=1M,nosuid,nodev networks: - glpi # --------------------------------------------------------------------- # Scheduler — ofelia (Netresearch's fork) drives GLPI's front/cron.php AND # the nightly phpbu backup via labels on the target services. Mounts the # host docker socket read-only so it can `docker exec`. # --------------------------------------------------------------------- # docker-socket-proxy — a least-privilege chokepoint in front of the host # Docker socket. ofelia needs to list containers + create/start execs; it does # NOT need (and must not have) the full Docker API, which is equivalent to # host root (a caller can create a privileged container bind-mounting /). # Only CONTAINERS + EXEC + POST are allowed here; everything else is denied. docker-socket-proxy: image: ghcr.io/tecnativa/docker-socket-proxy:v0.5.0 restart: unless-stopped mem_limit: 64m cpus: 0.25 logging: *logging environment: CONTAINERS: "1" # list/inspect containers (read labels) EXEC: "1" # create exec instances POST: "1" # allow the POSTs that exec create/start need TZ: ${TZ:-UTC} volumes: - /var/run/docker.sock:/var/run/docker.sock:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:2375/_ping"] interval: 10s timeout: 3s start_period: 10s retries: 5 security_opt: - no-new-privileges:true cap_drop: - ALL networks: - glpi scheduler: image: ghcr.io/netresearch/ofelia:latest@sha256:68143170168b15ca53755bd59294b0cfa6d154551c210ccf851abd5bca58fb4b restart: unless-stopped mem_limit: 64m cpus: 0.25 logging: *logging depends_on: app: condition: service_started docker-socket-proxy: condition: service_healthy command: ["daemon"] environment: # Drive jobs through the restricted proxy instead of the raw host socket. DOCKER_HOST: tcp://docker-socket-proxy:2375 TZ: ${TZ:-UTC} security_opt: - no-new-privileges:true cap_drop: - ALL networks: - glpi # --------------------------------------------------------------------- # Backup — phpbu (Netresearch's hardened image) dumps the DB and archives # the GLPI files/ tree AND the config dir (which holds glpicrypt.key — back # it up or encrypted DB fields become unrecoverable). Idles between runs; # ofelia execs phpbu via the labels below. # # For off-host storage, bind-mount ./backups to a NAS/NFS share, or run # restic/rclone on the host reading the named volume. # # OPT-IN: behind the `backup` compose profile, so a plain `docker compose # up -d` (and the default `make up`) brings up the working core without it. # Enable with: docker compose --profile backup up -d (or `make backup-up`). # This also decouples the default stack from the phpbu-docker image's # availability — pin/replace the tag below for your environment. # --------------------------------------------------------------------- backup: profiles: ["backup"] image: ghcr.io/netresearch/phpbu-docker:6 restart: unless-stopped mem_limit: 256m cpus: 0.5 logging: *logging depends_on: db: condition: service_healthy environment: DB_HOST: db DB_DATABASE: ${GLPI_DB_NAME:-glpi} DB_USER: ${GLPI_DB_USER:-glpi} DB_PASSWORD: ${GLPI_DB_PASSWORD:-} # At-rest encryption passphrase for the openssl crypt step in # config/phpbu/backup.json. REQUIRED when the backup profile is enabled # (an empty value makes phpbu's crypt step fail). `make init` generates a # random one. Reaches the ofelia-exec'd phpbu via the container env. BACKUP_CRYPT_PASSWORD: ${BACKUP_CRYPT_PASSWORD:-} TZ: ${TZ:-UTC} volumes: - ./config/phpbu/backup.json:/config/backup.json:ro - glpi-var:/snapshot/files:ro - glpi-config:/snapshot/config:ro - backups:/backups # The phpbu image's default ENTRYPOINT runs phpbu once and exits; we need # it alive for ofelia to `docker exec` into. Replace with an idle loop # that also writes the TZ ini the image entrypoint would have created. entrypoint: ["/bin/sh", "-c"] command: - | set -eu mkdir -p /backups/db /backups/files /backups/config if [ -n "${TZ:-}" ]; then printf 'date.timezone = %s\n' "$TZ" > /usr/local/etc/php/conf.d/tz.ini fi exec tail -f /dev/null healthcheck: test: ["CMD", "/app/vendor/bin/phpbu", "--version"] interval: 60s timeout: 10s start_period: 15s retries: 3 # phpbu must read GLPI's 0600/0660 config (oauth keys, glpicrypt.key) owned # by www-data; run as root so cap_add DAC_OVERRIDE actually applies (caps are # inert for a non-root uid). Opt-in + socket-less + :ro sources bound the risk. user: "0:0" security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - CHOWN - DAC_OVERRIDE - FOWNER labels: ofelia.enabled: "true" ofelia.job-exec.glpi-backup.schedule: "0 0 3 * * *" ofelia.job-exec.glpi-backup.command: '/app/vendor/bin/phpbu --configuration=/config/backup.json' networks: - glpi volumes: db-data: name: glpi-db-data valkey-data: name: glpi-valkey-data glpi-config: name: glpi-config glpi-var: name: glpi-var glpi-plugins: name: glpi-plugins glpi-logs: name: glpi-logs app-public: name: glpi-public backups: name: glpi-backups # tmpfs-backed shared volume for the php-fpm unix socket; populated by `app` # at startup, consumed by `web`. fpm-sock: name: glpi-fpm-sock driver_opts: type: tmpfs device: tmpfs o: "size=8m,mode=0755" networks: glpi: name: glpi driver: bridge