compose.yml
Rama main · réplica de lectura del repositorio original.
# 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