Ctrl K
ComunidadGuías…

compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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