diff --git a/.dockerignore b/.dockerignore
index 36c5a45..f4cb79c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,2 +1,12 @@
-# Ignore items for docker build
+# Build-context hygiene for the docker-chamilo image.
+# This repo is docker-only: the LMS source is fetched at build time (curl),
+# not copied from context, so the context only needs Dockerfile + nginx.conf
+# + entrypoint.sh. Exclude everything else to keep the context lean.
+.git
+.github
+*.log
tmp/
+# Docs live in the repo for humans; the build never needs them in-context
+# (the only files the build uses are Dockerfile + nginx.conf + entrypoint.sh).
+# Blanket pattern so any future .md is auto-excluded, no per-file list to keep.
+*.md
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..46476c3
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+# Docker Compose environment template for docker-chamilo.
+#
+# Copy to `.env` in this directory and edit: cp .env.example .env
+# `.env` is gitignored — real values live there and are never committed.
+#
+# All four values below must be set: the compose file references them with
+# `${VAR:?required in .env}`, so an unset value makes `docker compose` /
+# `podman compose` fail at parse time instead of silently using a weak
+# default.
+#
+# MARIADB_ROOT_PASSWORD — MariaDB root password (used by the db healthcheck).
+# MARIADB_PASSWORD — password of the `chamilo` db user (MARIADB_USER).
+# DATABASE_PASSWORD — password the app connects with; MUST equal
+# MARIADB_PASSWORD (same `chamilo` db user).
+# APP_SECRET — Symfony app secret, 32+ chars.
+#
+# The values shown are dev defaults — change them for any real deployment.
+MARIADB_ROOT_PASSWORD=chamilo
+MARIADB_PASSWORD=chamilo
+DATABASE_PASSWORD=chamilo
+APP_SECRET=change-me-please-make-this-32-chars-min
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f609973
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,31 @@
+# Weekly Dependabot — GitHub Actions only.
+#
+# This repo is docker-only. Its only native-Detectable dependency manifest is
+# the GitHub Actions in .github/workflows/build.yml (actions/checkout,
+# docker/setup-buildx-action, docker/build-push-action). Native Dependabot
+# opens a weekly PR here when any of those actions publish a new version.
+#
+# It deliberately does NOT cover the rest of this image's inputs, because they
+# are not native Dependabot ecosystems:
+# * CHAMILO_LMS_REF — a raw git ref of chamilo/chamilo-lms, fetched by the
+# Dockerfile at build time. Tracked by .github/workflows/dep-drift.yml
+# (weekly), which opens the drift Issue and the LMS bump PR.
+# * docker-compose.yml images (mariadb:11, redis:7) — floating minors; they
+# already pull the newest 11.x / 7.x on each `docker compose pull`, so
+# there is no pin to manage.
+# * the PHP base (FROM php:${PHP_VER}-fpm) — deliberately pinned to 8.3 (the
+# point of the takeover branch), a human decision; it is also
+# arg-parameterised, which native Dependabot can't match.
+#
+# Division of labour (so the two don't double-bump the same dependency):
+# native Dependabot -> GitHub Actions (this file)
+# dep-drift workflow -> the LMS pin + the weekly drift Issue
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ labels:
+ - "dependencies"
+ - "github-actions"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..4d997f6
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,51 @@
+# CI: the image must build.
+#
+# This is a docker-only repo — the only build that matters is the image, built
+# with the pinned CHAMILO_LMS_REF (the Dockerfile's default ARG). This job
+# runs that exact build on push and on manual dispatch, catching the two
+# breakages this image is prone to instead of finding them at release time:
+# * the `assets:install` memory OOM (AGENTS.md gotcha #1), and
+# * a broken source fetch (pinned ref no longer present upstream).
+#
+# Cost: GitHub-hosted runners are free and unmetered for public repositories,
+# so this is not metered against the free plan (the 2,000-min/500 MB quota
+# applies only to private repos). The only real cost is build time per push;
+# the LMS fetch + Composer step is the slow part and is not cached between
+# runs, so each green build takes a few minutes.
+name: build
+
+on:
+ push:
+ workflow_dispatch:
+
+# Cancel in-progress runs when a new commit lands on the same branch.
+concurrency:
+ group: build-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ docker-build:
+ name: Build image
+ runs-on: ubuntu-latest
+ # A full build from scratch (LMS tarball + Composer) can exceed the
+ # default 1h job timeout on a cold cache; give it headroom.
+ timeout-minutes: 90
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ # Buildx builder for docker/build-push-action. Pinned to the latest
+ # published minor (a bare major would float across minors on its own).
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4.4.1
+
+ # Build (and load locally) with the repo's own pinned ref — no
+ # build-arg override, so CI exercises exactly what a real build ships.
+ # load: true materialises the final image (no registry push); the build
+ # itself is the assertion — a fetch or OOM failure fails the step.
+ - name: Build image
+ uses: docker/build-push-action@v7.4.0
+ with:
+ context: .
+ load: true
+ tags: chamilo-lms-ci:ci
diff --git a/.github/workflows/dep-drift.yml b/.github/workflows/dep-drift.yml
new file mode 100644
index 0000000..fadd2ea
--- /dev/null
+++ b/.github/workflows/dep-drift.yml
@@ -0,0 +1,185 @@
+# dep-drift — weekly dependency drift for what native Dependabot can't manage.
+#
+# Native Dependabot (.github/dependabot.yml) only understands real manifests,
+# and this docker-only repo has exactly one: the GitHub Actions in build.yml.
+# The other things that can go stale — and that a dependabot run can't touch
+# because they aren't a recognised ecosystem — are handled HERE, weekly:
+#
+# * CHAMILO_LMS_REF — a raw git ref of chamilo/chamilo-lms fetched by the
+# Dockerfile at build time. We track it against the latest stable release
+# tag of chamilo/chamilo-lms (AGENTS.md: "Prefer a release tag for
+# reproducible public builds" — NOT master, so the image stays reproducible).
+# * docker-compose.yml images — NOT managed here on purpose. mariadb:11 and
+# redis:7 are floating minors; `docker compose pull` already fetches the
+# newest 11.x / 7.x each time, so there is no pin to bump and nothing to
+# detect. (If you ever pin them to patch versions, add them below.)
+# * the PHP base (FROM php:${PHP_VER}-fpm) — deliberately pinned to 8.3 (the
+# point of the takeover branch). A major PHP jump is a human decision, so
+# it is left out of the automation.
+#
+# WHAT IT OPENS (automatically, weekly):
+# * a PR — the one-line Dockerfile bump to the newest stable LMS tag, and
+# * an Issue — the weekly drift report.
+#
+# The PR is FAIL-CLOSED: before it opens, this workflow builds the image at
+# the candidate ref (the exact build from build.yml, via docker/build-
+# push-action). A PR is opened only if that build succeeds. If it fails, NO
+# PR is opened and the drift Issue records the failure instead — so a large
+# LMS jump (the pin can be many commits behind the latest tag) never lands as
+# a red PR you have to close. It is OPENED, not merged: a maintainer reviews
+# the one-line diff and merges. The PR's own push re-runs build.yml as a
+# second gate.
+#
+# The Issue is opened only when there is drift (a PR was opened, or the build
+# gate failed). When the pin already equals the latest stable tag it is a
+# clean no-op — no weekly "all up to date" noise.
+#
+# HOW IT TALKS TO GITHUB: the `gh` CLI — preinstalled on the GitHub-hosted
+# ubuntu-latest runner and authenticated by GITHUB_TOKEN itself (no installer
+# action, no `gh auth login`). Each GitHub operation is a readable one-liner:
+# `gh api` for the release/compare lookups, `gh pr` to dedup/open the PR,
+# `gh issue` to dedup/file the report. Git is the repo's own (the one-line
+# Dockerfile edit + push). No hand-rolled curl/jq, no embedded JS.
+#
+# Cost: GitHub-hosted runners are free/unmetered for public repos, so this
+# is not metered against the 2,000-min/500 MB quota (private only). The real
+# cost is one cold image build per drift week; up-to-date weeks skip the build.
+name: dep-drift
+
+on:
+ # Mondays 06:00 UTC.
+ schedule:
+ - cron: "0 6 * * 1"
+ # Manual run (same as the schedule: detect latest stable tag, build-gate, open).
+ workflow_dispatch:
+
+# GITHUB_TOKEN is read-only by default; grant exactly what this needs. The
+# built-in token can push a fresh branch and open a PR on THIS repo (no PAT
+# required) because we target the repo the workflow runs in — which is why it
+# works in the fork today and would work in upstream if copied there later.
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+
+env:
+ LMS_REPO: chamilo/chamilo-lms
+ # PR branch prefix; keep stable for dedup.
+ PR_BRANCH_PREFIX: dep-drift/chamilo-lms-
+
+jobs:
+ drift:
+ name: Detect, build-gate, open
+ runs-on: ubuntu-latest
+ # A cold LMS build can exceed the default 1h; match build.yml's headroom.
+ timeout-minutes: 90
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ # Same buildx builder as build.yml so this gate is byte-identical to the
+ # real build. Pinned to the same published minor.
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4.4.1
+
+ # Detect drift: latest stable tag vs the current pin (read out of the
+ # Dockerfile). `gh` is preinstalled on the runner; GITHUB_TOKEN authenticates
+ # it (no `gh auth login`). `releases/latest` is the latest STABLE release
+ # (404s if there is none — let it fail the run, don't guess).
+ - name: Detect drift (latest stable tag vs current pin)
+ id: detect
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ LATEST="$(gh api "repos/${LMS_REPO}/releases/latest" --jq .tag_name)"
+ CUR="$(sed -n '/^ARG[[:space:]]*CHAMILO_LMS_REF=/{s/^ARG[[:space:]]*CHAMILO_LMS_REF=//p}' Dockerfile)"
+ DRIFT=false
+ [ "$CUR" != "$LATEST" ] && DRIFT=true
+ { echo "LATEST=$LATEST"; echo "CUR=$CUR"; echo "DRIFT=$DRIFT"; } >> "$GITHUB_ENV"
+ echo "current pin: $CUR"
+ echo "latest stable: $LATEST"
+
+ # Up-to-date: nothing to say. No build, no PR, no Issue.
+ - name: Up to date — no-op
+ if: env.DRIFT == 'false'
+ run: echo "CHAMILO_LMS_REF already equals the latest stable tag; nothing to open."
+
+ # Build at the candidate ref — the gate. Runs only when there is drift.
+ # Identical build to build.yml (same action, same pinning), but with the
+ # candidate ref as a build-arg. A failure here means NO PR is opened.
+ #
+ # NOTE: the gate conditions use `steps.gate.result` — the standard
+ # Actions step-status context (success/failure/cancelled/skipped). When
+ # DRIFT=false the gate is skipped, so result=='skipped' and neither
+ # downstream step runs.
+ - name: Build-gate at candidate ref
+ id: gate
+ if: env.DRIFT == 'true'
+ uses: docker/build-push-action@v7.4.0
+ with:
+ context: .
+ load: true
+ build-args: |
+ CHAMILO_LMS_REF=${{ env.LATEST }}
+ tags: dep-drift-gate:${{ env.LATEST }}
+
+ # Build failed: NO PR. File a drift Issue recording the failure (dedup by ref).
+ - name: File drift Issue (build FAILED — no PR opened)
+ if: env.DRIFT == 'true' && steps.gate.result == 'failure'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ LATEST: ${{ env.LATEST }}
+ run: |
+ set -euo pipefail
+ TITLE="chamilo-lms drift (build FAILED) ${LATEST}"
+ if gh issue list --state open --json title --jq '.[].title' | grep -Fxq "$TITLE"; then
+ echo "A failure Issue for ${LATEST} already exists; not duplicating."
+ exit 0
+ fi
+ gh issue create --title "$TITLE" --body "Build-gate FAILED building the image at CHAMILO_LMS_REF=${LATEST}; no bump PR was opened (fail-closed). The current pin is unchanged. See the dep-drift run log for the build failure."
+
+ # Build succeeded: open the one-line bump PR (dedup by branch), then file
+ # the weekly drift report Issue (dedup by from->to pair).
+ - name: Open bump PR + file drift Issue
+ if: env.DRIFT == 'true' && steps.gate.result == 'success'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CUR: ${{ env.CUR }}
+ LATEST: ${{ env.LATEST }}
+ PR_BRANCH_PREFIX: ${{ env.PR_BRANCH_PREFIX }}
+ run: |
+ set -euo pipefail
+ BRANCH="${PR_BRANCH_PREFIX}${LATEST}"
+ TITLE_PR="build: bump CHAMILO_LMS_REF to ${LATEST}"
+ # Commit identity = the workflow bot (deterministic; no local git config).
+ BOT_NAME="github-actions[bot]"
+ BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com"
+
+ # --- The bump PR (dedup by branch: skip if one for this ref is already open).
+ if [ -z "$(gh pr list --state open --head "$BRANCH" --json headRefName --jq '.[].headRefName' 2>/dev/null || true)" ]; then
+ git checkout -b "$BRANCH"
+ # One-line edit: rewrite the ARG line (wherever it is), value -> newest stable.
+ sed -i '/^ARG[[:space:]]*CHAMILO_LMS_REF=/{s/^ARG[[:space:]]*CHAMILO_LMS_REF=.*/ARG CHAMILO_LMS_REF='"${LATEST}"'/}' Dockerfile
+ git add Dockerfile
+ git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
+ commit -m "$TITLE_PR" \
+ -m "Bumps the pinned ${LMS_REPO} ref from ${CUR} to the latest stable release ${LATEST} (one-line, fail-closed: this run built the image at ${LATEST} before opening)."
+ # Push with GITHUB_TOKEN (contents:write -> fresh-branch push to THIS repo is allowed).
+ git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/$(gh repo view --json nameWithOwner -q .nameWithOwner).git"
+ git push -u origin "$BRANCH"
+ # `--head` skips any fork/push prompt (the branch is already pushed).
+ gh pr create --head "$BRANCH" --title "$TITLE_PR" --body "Bumps the pinned CHAMILO_LMS_REF from ${CUR} to the latest stable release ${LATEST}. Opened automatically by the weekly dep-drift workflow, which built the image at ${LATEST} before opening it (fail-closed). The push re-runs build.yml as a second gate. Review the one-line diff; no auto-merge."
+ else
+ echo "An open PR for ${BRANCH} already exists; not opening a duplicate."
+ fi
+
+ # --- The weekly drift report Issue (dedup by the from->to pair).
+ TITLE_ISSUE="chamilo-lms drift ${CUR} -> ${LATEST}"
+ if gh issue list --state open --json title --jq '.[].title' | grep -Fxq "$TITLE_ISSUE"; then
+ echo "A drift Issue for this pair already exists; not duplicating."
+ else
+ # How many commits the latest tag is ahead of the pin (best-effort).
+ AHEAD="$(gh api "repos/${LMS_REPO}/compare/${CUR}...${LATEST}" --jq .ahead_by 2>/dev/null || echo '?')"
+ gh issue create --title "$TITLE_ISSUE" --body "Weekly dep-drift report. Current pin ${CUR}, latest stable ${LATEST} (${AHEAD} commits ahead). Build-gate PASSED at ${LATEST}; a bump PR was opened (or is already open). The change is the linked PR (one-line Dockerfile diff). Not auto-merged."
+ fi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4bc2971
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# Real environment values — never committed (see .env.example for the template)
+.env
diff --git a/000-default.conf b/000-default.conf
deleted file mode 100644
index e4138f9..0000000
--- a/000-default.conf
+++ /dev/null
@@ -1,36 +0,0 @@
-
- # The ServerName directive sets the request scheme, hostname and port that
- # the server uses to identify itself. This is used when creating
- # redirection URLs. In the context of virtual hosts, the ServerName
- # specifies what hostname must appear in the request's Host: header to
- # match this virtual host. For the default virtual host (this file) this
- # value is not decisive as it is used as a last resort host regardless.
- # However, you must set it for any further virtual host explicitly.
- #ServerName www.example.com
-
- ServerAdmin webmaster@localhost
- DocumentRoot /var/www/html/chamilo2/public
- # Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
- # error, crit, alert, emerg.
- # It is also possible to configure the loglevel for particular
- # modules, e.g.
- #LogLevel info ssl:warn
-
- ErrorLog ${APACHE_LOG_DIR}/error.log
- CustomLog ${APACHE_LOG_DIR}/access.log combined
-
- # For most configuration files from conf-available/, which are
- # enabled or disabled at a global level, it is possible to
- # include a line for only one particular virtual host. For example the
- # following line enables the CGI configuration for this host only
- # after it has been globally disabled with "a2disconf".
- #Include conf-available/serve-cgi-bin.conf
-
-
- AllowOverride All
- Require all granted
-
-
-
-
-# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d1b2fc6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,102 @@
+# AGENTS.md
+
+Playbook for AI agents (and humans) working in this repo. Read this before
+touching the Dockerfile, compose, or LMS ref.
+
+## What this repo is
+
+A **docker-only** repo: it ships a `Dockerfile` that builds a single
+**PHP 8.3-FPM + nginx** container for the Chamilo LMS. The LMS source is
+**not** vendored — it is **fetched at build time** at a pinned ref of
+`chamilo/chamilo-lms` (the `CHAMILO_LMS_REF` build arg). Do **not** commit
+the LMS source tree here; that bloats the repo and defeats the slim design.
+
+## Key files
+
+| File | Purpose |
+|------|---------|
+| `Dockerfile` | Builds the image. Fetches LMS at `CHAMILO_LMS_REF`, installs deps + nginx, sets up FPM. |
+| `nginx.conf` | The vhost: serves `public/` statics, proxies `.php` to FPM `127.0.0.1:9000`. |
+| `entrypoint.sh` | Starts `php-fpm` (bg) then `exec nginx` (PID 1). |
+| `docker-compose.yml` | `chamilo` + `db` (MariaDB 11) + `redis` (Redis 7). |
+| `.dockerignore` | Keeps the build context lean (docs, VCS, logs). |
+
+## Build
+
+```bash
+# Default: pinned ref from the Dockerfile
+docker build -t chamilo-lms .
+
+# Override the LMS ref (tag or full 40-char SHA)
+docker build --build-arg CHAMILO_LMS_REF=v3.0.0 -t chamilo-lms .
+```
+
+Build is **slow the first time** (~88 MB source tarball + Composer fetch);
+later builds are cached. Use `podman` if that's the host runtime.
+
+## Run
+
+```bash
+docker compose up -d --build
+# → http://localhost/ (first-run installer)
+```
+
+Verify the wiring is live (no DB yet, so expect the installer / a Symfony
+error page — that **proves** nginx → FPM → PHP is connected):
+
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" http://localhost/ # 200/3xx/5xx = wired
+docker exec chamilo php -r 'exit((@fsockopen("127.0.0.1",9000)!==false)?0:1);' && echo "FPM up"
+docker exec chamilo nginx -t # vhost is valid
+```
+
+## Gotchas (do not re-learn these the hard way)
+
+1. **`memory_limit` OOM — split by context.** Symfony's `assets:install`
+ post-install script boots the kernel in a child `php` process and
+ exhausts PHP's 128 M default. The Dockerfile writes `memory_limit=-1` to
+ `/usr/local/etc/php/conf.d/zz-memory.ini` **for the build** — the child reads
+ the ini, and if you override memory the build OOMs in
+ `PhpConfigReferenceDumpPass`. It **also** bounds the runtime FPM `www` pool
+ to `256M` via `php_admin_value[memory_limit]` appended to
+ `/usr/local/etc/php-fpm.d/www.conf` — a per-pool directive that outranks the
+ ini — so a web request can't allocate unboundedly. **Do not remove either
+ part:** the build needs the `-1` ini, and the pool limit is what keeps the
+ runtime from running unlimited.
+2. **Nested `.git` bloat.** The old approach copied the LMS tree (with its
+ 1.2 GiB `.git`) into the image. This repo fetches a **tarball** (no `.git`),
+ so the image is ~1.2 GB. If you ever add a `COPY` of a source tree, you
+ **must** `.dockerignore` the nested `.git`.
+3. **Env var names.** The app reads **`DATABASE_*`** (see `.env.dist` of the
+ LMS), **not** `DB_*`. The compose sets `DATABASE_HOST=db` etc. Renaming
+ these breaks the DB connection. The 4 sensitive values
+ (`DATABASE_PASSWORD`, `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD`,
+ `APP_SECRET`) are read from a gitignored `.env` (template in `.env.example`);
+ the compose uses `${VAR:?required in .env}`, so a missing value fails at
+ parse time. **Do not commit `.env` or hard-code the values back into
+ `docker-compose.yml`.** The `.env` mechanism (not a native `secrets:`
+ block) is the portable choice because the `mariadb:11` image reads
+ `MARIADB_ROOT_PASSWORD` from env, not from a Docker secret file.
+4. **FPM is on `9000`, nginx on `80`.** nginx proxies `.php` to
+ `127.0.0.1:9000`. If you change the FPM port, update **both**
+ `nginx.conf` (`fastcgi_pass`) and the `entrypoint.sh` readiness check.
+5. **`entrypoint.sh` runs as root** (the image default). It must start FPM
+ before nginx or early requests 502. The readiness loop uses PHP's
+ `fsockopen` (no extra tools needed).
+6. **No TLS.** The image speaks plain HTTP on :80. Terminate TLS in front of
+ it (reverse proxy / load balancer) for production.
+
+## Releasing a new LMS version
+
+Change **one** thing — the `CHAMILO_LMS_REF` build arg in the `Dockerfile`
+(to a tag like `v3.0.1` or a full commit SHA), commit, and rebuild. Prefer a
+release **tag** for reproducible public builds; a moving SHA is fine for
+pinning "our exact current tree".
+
+## Repo hygiene
+
+- Keep it **slim**: no LMS source, no `vendor/`, no build artifacts.
+- Keep `.dockerignore` covering `.git`, `.github`, `*.log`, `tmp/`, and a
+ blanket `*.md` for the docs (they're for humans, not the build).
+- The old `000-default.conf` (Apache vhost) was removed — this image is
+ nginx + FPM, not Apache mod_php. Don't reintroduce Apache.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/Dockerfile b/Dockerfile
index f851c5e..acdc7e3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,61 +1,118 @@
-FROM ubuntu:14.04
-MAINTAINER Yannick Warnier
-
-# Keep upstart from complaining
-RUN dpkg-divert --local --rename --add /sbin/initctl
-RUN ln -sf /bin/true /sbin/initctl
-
-# Update Ubuntu and install basic PHP stuff
-RUN apt-get -y update && apt-get install -y \
- curl \
- git \
- libapache2-mod-php5 \
- php5-cli \
- php5-curl \
- php5-gd \
- php5-intl \
- php5-mysql \
- wget
-
-RUN apt-get install -y openssh-server
-RUN mkdir -p /var/run/sshd
-
-# Get Chamilo
-RUN mkdir -p /var/www/chamilo
-ADD https://github.com/chamilo/chamilo-lms/archive/v1.10.0-alpha.tar.gz /var/www/chamilo/chamilo.tar.gz
-WORKDIR /var/www/chamilo
-RUN tar zxf chamilo.tar.gz;rm chamilo.tar.gz;mv chamilo* www
-WORKDIR www
-RUN chown -R www-data:www-data \
- app \
- main/default_course_document/images \
- main/lang \
- vendor \
- web
-
-# Get Composer (putting the download in /root is discutible)
-WORKDIR /root
-RUN curl -sS https://getcomposer.org/installer | php
-RUN chmod +x composer.phar
-RUN mv composer.phar /usr/local/bin/composer
-
-# Get Chash
-RUN git clone https://github.com/chamilo/chash.git chash
-WORKDIR chash
-RUN composer update --no-dev
-RUN php -d phar.readonly=0 createPhar.php
-RUN chmod +x chash.phar && mv chash.phar /usr/local/bin/chash
-
-# Configure and start Apache
-ADD chamilo.conf /etc/apache2/sites-available/chamilo.conf
-RUN a2ensite chamilo
-RUN a2enmod rewrite
-RUN /etc/init.d/apache2 restart
-RUN echo "127.0.0.1 docker.chamilo.net" >> /etc/hosts
-
-# Go to Chamilo folder and install
-# Soon... (this involves having a SQL server in a linked container)
-
-WORKDIR /var/www/chamilo/www
-EXPOSE 22 80
-CMD ["/bin/bash"]
+# Chamilo LMS — single-container runtime (PHP FPM + nginx).
+#
+# Slim / docker-only image: the LMS source is NOT vendored into this repo.
+# It is fetched at build time from a pinned ref of chamilo/chamilo-lms, so
+# the image is fully reproducible and this repo stays small.
+ARG PHP_VER=8.3
+FROM php:${PHP_VER}-fpm
+
+# Pinned ref of chamilo/chamilo-lms. Bump to release a new LMS version.
+# Accepts a git tag (e.g. v3.0.0-beta.2) or a full commit SHA.
+ARG CHAMILO_LMS_REF=v3.0.1
+
+# System packages + PHP extensions the LMS needs.
+# curl/ca-certificates : fetch the pinned source; Composer zip dists (TLS)
+# nginx : serves the LMS over HTTP (front controller -> FPM)
+# git is intentionally omitted — every Composer dependency in composer.lock
+# ships a zip dist (no VCS-only packages), so Composer downloads archives via
+# the PHP zip extension instead of cloning.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ ca-certificates \
+ nginx \
+ libicu-dev \
+ libldap-dev \
+ libpng-dev \
+ libonig-dev \
+ libxml2-dev \
+ libxslt1-dev \
+ libzip-dev \
+ && docker-php-ext-install -j$(nproc) \
+ bcmath \
+ exif \
+ gd \
+ intl \
+ ldap \
+ opcache \
+ pdo \
+ pdo_mysql \
+ soap \
+ xsl \
+ zip \
+ && pecl install --onlyreqdeps --force redis \
+ && docker-php-ext-enable redis \
+ && rm -rf /var/lib/apt/lists/*
+
+# Web tier: drop the stock default vhost, install ours (listens on :80,
+# proxies .php to PHP-FPM at 127.0.0.1:9000, docroot /app/chamilo-lms/public).
+RUN rm -f /etc/nginx/sites-enabled/default \
+ && rm -rf /var/www/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+# PHP memory limit, split by context:
+# * build: the global CLI ini is -1 so `assets:install` (a child `php` boot
+# of the Symfony kernel, which reads the ini) can't OOM on the 128M
+# default. That line is required for the build (see AGENTS.md gotcha #1).
+# * runtime: the FPM `www` pool is bounded to 256M via php_admin_value — a
+# per-pool directive that outranks the ini — so a web request can't
+# allocate unboundedly. The -1 is NOT left in place for the web tier.
+RUN echo "memory_limit=-1" > /usr/local/etc/php/conf.d/zz-memory.ini \
+ && echo "php_admin_value[memory_limit] = 256M" >> /usr/local/etc/php-fpm.d/www.conf
+
+# Fetch the LMS source, install Composer deps, and chown — ONE layer.
+#
+# Fetch: build-time, not vendored. The tarball extracts to a single top-level
+# dir (chamilo-lms-[); rename it to /app/chamilo-lms so the path is stable
+# for a tag or a full SHA.
+#
+# Composer: two steps, one run —
+# 1. full install (dev + prod) — runs `assets:install` (a dev-env kernel
+# boot, which needs the dev-only DebugBundle/WebProfilerBundle), copying
+# bundle assets into public/.
+# 2. sync to prod-only (`--no-dev`), dropping dev packages (psalm, phpstan,
+# phpunit, debug/web-profiler bundles, maker-bundle, ...). `--no-scripts`
+# because re-running `assets:install` here would boot the kernel without
+# the dev bundles it needs (or, in prod, need a resolvable DB — there is
+# no DB at image-build time, so the asset step must run in step 1).
+#
+# Chown: the FPM `www` pool already runs as `www-data` (www.conf), but the app
+# tree is root-owned, so the pool workers couldn't write to the runtime dirs
+# Symfony writes constantly (var/cache, var/log, var/upload) — proven
+# Permission-denied. Hand the tree to the runtime user.
+#
+# DO NOT split this back into separate RUNs: the `chown -R` on files that
+# were written in *earlier* layers triggers an overlayfs copy-up — every file
+# is duplicated into the upper layer before its metadata changes — which adds
+# a ~600 MB phantom layer (image went 1.2 GB -> 1.83 GB when this was split).
+# In ONE layer the chown acts on in-layer files: no copy-up.
+# (No real caching is lost: a ref change rebuilds all three of these steps
+# today anyway, so the work is identical — only the layer count differs.)
+RUN curl -fsSL "https://github.com/chamilo/chamilo-lms/archive/${CHAMILO_LMS_REF}.tar.gz" -o /tmp/lms.tar.gz \
+ && mkdir -p /app/lms-fetch \
+ && tar -xzf /tmp/lms.tar.gz -C /app/lms-fetch \
+ && mv /app/lms-fetch/chamilo-lms-* /app/chamilo-lms \
+ && rm -f /tmp/lms.tar.gz \
+ && rm -rf /app/lms-fetch /root/.cache \
+ && cd /app/chamilo-lms \
+ && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
+ && composer install --no-interaction --optimize-autoloader \
+ && composer install --no-interaction --no-dev --no-scripts --optimize-autoloader \
+ && rm -rf /root/.composer /root/.cache/composer \
+ && chown -R www-data:www-data /app/chamilo-lms
+
+WORKDIR /app/chamilo-lms
+
+# Start PHP-FPM (daemon) + nginx (foreground, PID 1) on container start.
+COPY --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+CMD ["nginx", "-g", "daemon off;"]
+
+EXPOSE 80
+
+# Liveness probe: the web tier (nginx -> FPM) is up and answering HTTP.
+# Accepts ANY status code — a fresh LMS returns 5xx until the installer runs,
+# and that still means the container is alive and serving. Only a connection
+# failure (no response / FPM down) is "unhealthy". curl is already installed
+# (source fetch); --max-time caps the wait so a stuck FPM worker can't hang.
+HEALTHCHECK --start-period=15s --interval=30s --timeout=5s --retries=3 \
+ CMD ["sh", "-c", "curl -s --max-time 5 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null | grep -qE '^[0-9]{3}$'"]
diff --git a/README.md b/README.md
index 3516e73..7fb02e3 100644
--- a/README.md
+++ b/README.md
@@ -1,86 +1,84 @@
# docker-chamilo
-[](https://microbadger.com/images/chamilo/docker-chamilo "Get your own image badge on microbadger.com")
+Single-container Docker image for the [Chamilo LMS](https://www.chamilo.org).
-Official Docker image for Chamilo LMS
+This image bundles **PHP 8.3-FPM** and **nginx** in one container and serves the
+LMS over HTTP on port **80** (nginx → PHP-FPM on `127.0.0.1:9000`).
-This image is not ready yet. Please come back soon or watch the project for updates.
+## How it works
-## Launching
+The repo is **docker-only**: the LMS source is *not* vendored here. At build
+time the `Dockerfile` downloads the LMS at a **pinned ref** of
+[`chamilo/chamilo-lms`](https://github.com/chamilo/chamilo-lms) (a tag or a
+full commit SHA, set by the `CHAMILO_LMS_REF` build arg) and installs its
+Composer dependencies. The image is therefore fully reproducible, and this
+repo stays small.
-This image is currently based on Chamilo LMS 1.10 and requires a separate database container to run.
-We suggest using the "mariadb" container, like so:
+The container runs two processes:
-```
-docker run --name mariadb -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_USER=chamilo -e MYSQL_PASSWORD=chamilo -e MYSQL_DATABASE=chamilo -d mariadb
-```
-
-This will get you back on the command line of the Docker host. You can see the container running with ```docker ps```.
-
-Then start the chamilo/docker-chamilo container:
-
-```
-docker run --link=mariadb:db --name chamilo -p 8080:80 -it chamilo/docker-chamilo
-```
-
-At this point, the docker-chamilo image doesn't provide an installed version of Chamilo LMS, but this should be ready soon.
-
-The configuration files assume the host will be "docker.chamilo.net", so you will have to define it in your host's /etc/hosts file, depending on the IP of the container.
-
-```
-72.17.0.10 docker.chamilo.net
-```
+| Process | Listens on | Role |
+|---------|----------------|------|
+| PHP-FPM | `127.0.0.1:9000` | runs the Symfony front controller |
+| nginx | `0.0.0.0:80` | serves static files + proxies `.php` to FPM |
-Now start your browser and load http://docker.chamilo.net.
+## Quick start
-## Using with a load-balancer
+```bash
+# 1. Provide a local .env (gitignored; template in .env.example)
+cp .env.example .env
-If you want to use a more complex system with load balancing, you might want to try out the following suite of commands:
+# 2. Build the image and start the full stack (app + MariaDB + Redis)
+docker compose up -d --build
-```
-docker run --name varwww -d ywarnier/varw
+# Open the LMS
+# http://localhost/ → first-run installer (create the DB, then install)
```
-This will provide a shared /var/www2 partition
+See [SETUP.md](SETUP.md) for first-run and production notes.
-```
-docker run --name mariadb -e MYSQL_ROOT_PASSWORD=pass -e MYSQL_USER=chamilo -e MYSQL_PASSWORD=chamilo -e MYSQL_DATABASE=chamilo -d mariadb
-docker run --link=mariadb:db --volumes-from=varwww --name chamilo -p 8080:80 -it chamilo/docker-chamilo
-# Change all configuration to point to /var/www2/chamilo/www and change the Chamilo config file (root_web)
-# Also, inside app/config/configuration.php, change "session_stored_in_db" to true
-# configure Chamilo on this first container then take a snapshot
-docker commit -m "Live running Chamilo connected to host 'db' with existing database" {container-hash} docker-chamilo:live
-docker run --link=mariadb:db --volumes-from=varwww --name chamilo2 -p 8081:80 -it docker-chamilo:live
-docker run --name lb --link=chamilo:w1 --link=chamilo4:w2 -e CHAMILO_1_PORT_80_TCP_ADDR=172.17.0.10 -e CHAMILO_2_PORT_80_TCP_ADDR=172.17.0.11 -e CHAMILO_HOSTNAME=docker.chamilo.net -e CHAMILO_PATH=/ -p 8082:80 -it jasonwyatt/nginx-loadbalancer
-```
+## Configuration
-Sadly, there's something wrong at the moment in the nginx-loadbalancer image, and you have to connect to it to change the configuration of the reverse proxy (the last container you launched).
+Environment variables (read by the Symfony app — see `.env.dist` of the LMS):
-```
-docker ps
-```
+| Variable | Default | Notes |
+|--------------------|---------|-------|
+| `DATABASE_HOST` | `db` | FQDN of the database service |
+| `DATABASE_PORT` | `3306` | |
+| `DATABASE_NAME` | `chamilo` | |
+| `DATABASE_USER` | `chamilo` | |
+| `DATABASE_PASSWORD`| from `.env` | required in `.env` |
+| `APP_ENV` | `prod` | `dev` for verbose error pages |
+| `APP_SECRET` | from `.env` | required in `.env`, 32+ chars |
-(to identify the hash of the image of the load balancer (lb))
+> **Note:** the app reads `DATABASE_*`, not `DB_*`. Earlier compose examples
+> used `DB_*`, which the LMS ignores.
+>
+> **Secrets:** the 4 sensitive values (`DATABASE_PASSWORD`, `MARIADB_ROOT_PASSWORD`,
+> `MARIADB_PASSWORD`, `APP_SECRET`) come from a gitignored `.env`
+> (template in `.env.example`). The compose uses `${VAR:?required in .env}`,
+> so a missing value fails at parse time instead of silently using a weak
+> default.
-```
-docker exec -i -t {lb-container-hash} bash
-cd /etc/nginx/sites-available/
-vi proxy.conf
-```
+## Releasing a new LMS version
-(add the following *just before* proxy_pass, in the two occurrences)
+The version is pinned in one place — the `CHAMILO_LMS_REF` build arg in the
+`Dockerfile`. To ship a new LMS version, change it to a release tag
+(e.g. `v3.0.0`) or a full commit SHA, and rebuild:
-```
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
+```bash
+docker build --build-arg CHAMILO_LMS_REF= -t chamilo-lms .
```
-Now reload Nginx
+## Requirements
-```
-service nginx reload
-```
+- A MariaDB/MySQL database (provided by `docker-compose.yml` as the `db` service)
+- Port 80 (HTTP)
+- Optional: Redis for sessions/caching (provided as the `redis` service)
-Now you should be good to go.
+## Image size
-Note that this will only work as long as you don't upload any file or object that needs to be stored on disk, as the two web servers will not share any disk space in the context presented above.
+~1.2 GB after the source fetch (LMS source + prod-only vendor + PHP 8.3 +
+nginx). Dev-only Composer packages are stripped at build time (see the
+Dockerfile's two-step `composer install`), and the nested `.git` of the LMS
+source is excluded at build time — the old 1.2 GiB `.git` from the previous
+approach is gone.
diff --git a/SETUP.md b/SETUP.md
index 105e632..5eb0a3e 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -1,30 +1,95 @@
-# Creating a Chamilo 2 test containers stack
+# Setup
-To test Chamilo2 you can create a container based on the latest published code in GitHub as well as latest version of the base containers. **This is by no means a recommended approach for Production**.
+First-run and production notes for the `docker-chamilo` image.
-## Standalone Chamilo 2 container
+## What's in the image
-You can use the provided [Dockerfile](Dockerfile) to build your own.
-Not all possible PHP extensions have been enabled but only the required ones as well as APCu as an example.
+- **PHP 8.3-FPM** (the `www` pool on `127.0.0.1:9000`)
+- **nginx** (HTTP on `:80`) — the web tier
+- The **Chamilo LMS** source, fetched at build time at a pinned ref
+ (`CHAMILO_LMS_REF` in the `Dockerfile`)
+- Composer dependencies + the Symfony `assets:install` step, already run
-You can easily modify it to add more extensions. Layers are not squashed to make sure you can refresh Chamilo source for example by rebuilding without eventually needing refresh the previous layers.
+The container starts both PHP-FPM and nginx via `entrypoint.sh`; nginx is
+PID 1.
-As for the database it expects you can point to yours or use a default MariaDB container when using the `docker compose up` version.
+## Bring up the full stack
-## Test stack (`docker compose` approach)
+```bash
+# 1. Provide a local .env (gitignored; template in .env.example)
+cp .env.example .env
-Please note that you will need to create a `.env` file to define the variables of MariaDB in that case.
-
-```ini
-MYSQL_ROOT_PASSWORD=securePassword
-MYSQL_DATABASE=chamilo
-MYSQL_USER=root
-MYSQL_PASSWORD=
+# 2. Start the full stack (app + MariaDB + Redis)
+docker compose up -d --build
```
-While configuring Chamilo, use **`mariadb`** as the server hostname and whatever values you did set in the `.env` file to create the connection to the database.
+This starts three services (see `docker-compose.yml`):
+
+- `chamilo` — the app (HTTP :80)
+- `db` — MariaDB 11
+- `redis` — Redis 7 (sessions/cache)
+
+Then open **http://localhost/** — the LMS **first-run installer** walks you
+through creating the database, the admin account, and completing the install.
+
+> The app reads `DATABASE_*` environment variables (see `.env.dist` of the
+> LMS), **not** `DB_*`. The compose file sets `DATABASE_HOST=db`, etc.
+
+## Database
+
+The `db` service pre-creates a database and user (values read from `.env` —
+template in `.env.example`, gitignored):
+
+| Item | Value |
+|------|-------|
+| Root password | `chamilo` (dev default) |
+| Database | `chamilo` |
+| User | `chamilo` |
+| Password | `chamilo` (dev default) |
+
+The compose uses `${VAR:?required in .env}`, so a missing `.env` fails at parse
+time instead of silently using a weak default. For a real deployment, set
+strong values in `.env`.
+
+For an existing database, point `DATABASE_*` at it instead.
+
+## Production checklist
+
+- Set a strong `APP_SECRET` (32+ chars).
+- Terminate TLS **in front of** this container (a reverse proxy / load
+ balancer) — this image speaks plain HTTP on :80.
+- Use a real `DATABASE_PASSWORD` and a non-root DB user.
+- Back up the `db_data` volume (and `chamilo_data` for uploads).
+- Pin `CHAMILO_LMS_REF` to a release tag (not a moving SHA) for
+ reproducible builds.
+- Consider `APP_ENV=prod` (default) and disabling the debug error handler.
+
+## Releasing a new LMS version
+
+The version lives in one place — the `CHAMILO_LMS_REF` build arg in the
+`Dockerfile`:
+
+```bash
+# ship a stable release
+docker build --build-arg CHAMILO_LMS_REF=v3.0.0 -t chamilo-lms .
+
+# or pin an exact commit
+docker build --build-arg CHAMILO_LMS_REF= -t chamilo-lms .
+```
-Volumes are created as named volumes to be persisted on your docker host. You can find options inside the [docker-compose.yml](docker-compose.yml) to use binded volumes or seed from an existing database export.
+Rebuild and re-run `docker compose up -d --build`. The pinned ref changes
+what source is fetched; everything else (PHP, extensions, nginx config) is
+unchanged.
-If you do not intend to rebuild every time you set up the stack, please comment out the `build` instructions in the `docker-compose.yml` section for the Chamilo container.
+## Troubleshooting
+- **502 / 504 from nginx** — FPM isn't up. Check `docker logs chamilo` for
+ the `entrypoint.sh` startup; FPM must accept on `:9000` before nginx
+ proxies.
+- **DB connection errors** — confirm the `chamilo` service can reach `db`
+ (same compose network) and that `DATABASE_*` matches the `db` service.
+- **`memory_limit` OOM during build** — the Dockerfile sets
+ `memory_limit=-1`; if you override it, `assets:install` will OOM on the
+ 128 M default.
+- **Slow first build** — the source is downloaded at build time (~88 MB
+ tarball) and Composer deps are fetched; subsequent builds are cached.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..35ac109
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,67 @@
+# Chamilo LMS — compose.
+#
+# chamilo : the app (PHP 8.3-FPM + nginx, this repo's image) — HTTP on :80
+# db : MariaDB (LMS database)
+# redis : session/cache (optional; reachable on the compose network)
+#
+# The app reads DATABASE_* (see .env.dist), NOT DB_*. Passwords and APP_SECRET
+# come from `.env` (gitignored — template in .env.example), so no secrets are
+# committed in this file.
+services:
+ chamilo:
+ build: .
+ ports:
+ - "80:80"
+ environment:
+ - DATABASE_HOST=db
+ - DATABASE_PORT=3306
+ - DATABASE_NAME=chamilo
+ - DATABASE_USER=chamilo
+ - DATABASE_PASSWORD=${DATABASE_PASSWORD:?required in .env}
+ - APP_ENV=prod
+ - APP_SECRET=${APP_SECRET:?required in .env}
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ volumes:
+ - chamilo_data:/app/chamilo-lms/var
+ restart: unless-stopped
+
+ db:
+ image: docker.io/library/mariadb:11
+ environment:
+ - MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:?required in .env}
+ - MARIADB_DATABASE=chamilo
+ - MARIADB_USER=chamilo
+ - MARIADB_PASSWORD=${MARIADB_PASSWORD:?required in .env}
+ volumes:
+ - db_data:/var/lib/mysql
+ # "ready to accept connections" — used by chamilo's depends_on
+ # (condition: service_healthy). mariadb:11 ships the client as
+ # `mariadb-admin` (not `mysqladmin`); -u root authenticates with
+ # MARIADB_ROOT_PASSWORD (supplied via .env).
+ healthcheck:
+ test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u", "root", "-p${MARIADB_ROOT_PASSWORD:?required in .env}"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ restart: unless-stopped
+
+ redis:
+ image: docker.io/library/redis:7
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ chamilo_data:
+ db_data:
+ redis_data:
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 0000000..6f10313
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Single-container entrypoint: start PHP-FPM (background) then hand PID 1 to nginx.
+# nginx proxies PHP to FPM on 127.0.0.1:9000 (see nginx.conf).
+set -e
+
+# The www pool (php:8.3-fpm default) listens on 9000.
+php-fpm &
+
+# Wait until FPM accepts connections before nginx starts proxying to it.
+until php -r 'exit((@fsockopen("127.0.0.1",9000) !== false) ? 0 : 1);' 2>/dev/null; do
+ sleep 0.2
+done
+
+# Hand PID 1 to nginx so SIGTERM/SIGQUIT reach it cleanly for graceful stop.
+exec nginx -g "daemon off;"
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..2e812cf
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,38 @@
+# Chamilo LMS vhost — Symfony front controller -> PHP-FPM (127.0.0.1:9000).
+# Installed as /etc/nginx/conf.d/default.conf (included by the http{} block).
+# Serves the LMS over HTTP on :80; docroot is the Symfony public/ dir.
+server {
+ listen 80 default_server;
+ server_name _;
+
+ root /app/chamilo-lms/public;
+ index index.php;
+
+ client_max_body_size 64m;
+ client_body_buffer_size 128k;
+
+ # Front controller: anything that isn't a real file falls through to index.php
+ location / {
+ try_files $uri $uri/ /index.php?$args;
+ }
+
+ # PHP: route all .php through the front controller
+ location ~ \.php$ {
+ fastcgi_pass 127.0.0.1:9000;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_param SCRIPT_NAME /index.php;
+ }
+
+ # Static assets: served directly, no PHP round-trip
+ location ~* \.(?:css|js|gif|svg|jpe?g|png|ico|webp|woff2?|ttf|eot|otf|map)$ {
+ try_files $uri =404;
+ access_log off;
+ expires 30d;
+ }
+
+ # Never serve dotfiles (.env, .git, .htaccess, ...)
+ location ~ /\. {
+ deny all;
+ }
+}
]