From 97527f5e0c83062ffcfb93b4ae32ce2bc21e822d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 28 May 2026 11:27:57 +0200 Subject: [PATCH 1/4] chore: decouple dev compose stack from itkdev-docker tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh clones now run end-to-end without host-level itkdev tooling (itkdev-docker-compose wrapper, external `frontend` traefik network). - Bundled traefik behind `profiles: [traefik]` (on by default), terminating TLS on :80/:443 with a self-signed cert. Generated by `task dev:cert` via alpine/openssl, chained into `task compose-up`. Script is profile-aware: itkdev devs with `COMPOSE_PROFILES=` in `.env.local` get a no-op skip. - Consolidated to a single `docker-compose.yml`. The four ITK-hosting- specific files (`docker-compose.{override,server,dev,redirect}.yml`) are dropped; redis/node/playwright/markdownlint/prettier merged in. - mailpit removed — the app sends no mail. - `${COMPOSE_FRONTEND_NETWORK}` parameterizes both the network's engine name and the `traefik.docker.network` labels so the default (`display_frontend`) never collides with the shared `frontend` network itkdev hosts pre-create. - `docker-compose.shared-frontend.yml` overlay for itkdev opt-in: flips `frontend` to external + matching dotenv overrides documented in `.env` and README. - Taskfile: `DOCKER_COMPOSE` default → `docker compose`; the legacy wrapper remains opt-in via `TASK_DOCKER_COMPOSE=itkdev-docker-compose`. - New `Compose` workflow lints all four operational modes and smoke-tests both default and itkdev opt-in stacks. Path-scoped to compose files, traefik config, dev-cert script, and `.env`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env | 29 ++++++ .github/workflows/docker-compose.yaml | 132 ++++++++++++++++++++++++++ README.md | 37 +++++++- Taskfile.yml | 16 +++- docker-compose.dev.yml | 27 ------ docker-compose.override.yml | 73 -------------- docker-compose.redirect.yml | 15 --- docker-compose.server.yml | 63 ------------ docker-compose.shared-frontend.yml | 21 ++++ docker-compose.yml | 103 +++++++++++++++++--- scripts/dev-cert.sh | 64 +++++++++++++ traefik/dynamic.yml | 11 +++ traefik/ssl/.gitignore | 3 + traefik/traefik.yml | 33 +++++++ 14 files changed, 435 insertions(+), 192 deletions(-) create mode 100644 .github/workflows/docker-compose.yaml delete mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.override.yml delete mode 100644 docker-compose.redirect.yml delete mode 100644 docker-compose.server.yml create mode 100644 docker-compose.shared-frontend.yml create mode 100755 scripts/dev-cert.sh create mode 100644 traefik/dynamic.yml create mode 100644 traefik/ssl/.gitignore create mode 100644 traefik/traefik.yml diff --git a/.env b/.env index ea4dfd90c..106b135e0 100644 --- a/.env +++ b/.env @@ -5,6 +5,35 @@ COMPOSE_DOMAIN=display.local.itkdev.dk # itkdev-docker template identifier. ITKDEV_TEMPLATE=symfony-6 +# Built-in services to start (read natively by docker compose). +# traefik bundled dev traefik on :80/:443 with a self-signed cert +# generated by `task dev:cert`. Default for fresh clones. +# Leave empty (`COMPOSE_PROFILES=`) in .env.local on itkdev hosts running +# a host-level traefik on a shared `frontend` network — see opt-in below. +COMPOSE_PROFILES=traefik + +# Bundled-traefik port mapping. Override if 80/443 are taken on the host. +# HTTP_PORT=80 +# HTTPS_PORT=443 + +# The frontend network's engine name + the matching `traefik.docker.network` +# label both read from this var. Default is `_frontend` (scoped +# so switching modes doesn't collide with itkdev's external `frontend`). +# COMPOSE_FRONTEND_NETWORK=display_frontend + +# Itkdev devs running a host-level traefik on a shared `frontend` network: +# opt out of the bundled traefik AND opt in to the shared-frontend overlay +# in .env.local (Task forwards dotenv vars to subprocesses, including +# `docker compose`): +# +# COMPOSE_PROFILES= +# COMPOSE_FILE=docker-compose.yml:docker-compose.shared-frontend.yml +# COMPOSE_FRONTEND_NETWORK=frontend +# +# Also set `TASK_DOCKER_COMPOSE=itkdev-docker-compose` in .env.local if you +# want the legacy wrapper back — the Taskfile default is plain +# `docker compose`. + # In all environments, the following files are loaded if they exist, # the latter taking precedence over the former: # diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml new file mode 100644 index 000000000..6ecf05e0d --- /dev/null +++ b/.github/workflows/docker-compose.yaml @@ -0,0 +1,132 @@ +### ### Compose +### +### Validates the local dev compose stack — config-lint in every operational +### mode, plus a smoke-test that the default-mode bundled traefik actually +### serves nginx over HTTPS with the self-signed dev cert. +### +### Scoped to changes that can break the dev stack: compose files, +### bundled traefik config, the dev-cert script, and `.env` (whose +### COMPOSE_PROFILES default decides which services run). + +on: + pull_request: + paths: + - "docker-compose.yml" + - "docker-compose.shared-frontend.yml" + - "docker-compose.postgres.yml" + - "traefik/**" + - "scripts/dev-cert.sh" + - ".env" + - ".github/workflows/docker-compose.yaml" + +name: Compose + +jobs: + lint: + runs-on: ubuntu-latest + name: Lint compose (all modes) + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Default mode (traefik profile) + env: + COMPOSE_PROFILES: traefik + run: docker compose config --quiet + + - name: Default + dev tooling profile + env: + COMPOSE_PROFILES: traefik,dev + run: docker compose config --quiet + + - name: Postgres portability overlay + env: + COMPOSE_PROFILES: traefik + COMPOSE_FILE: docker-compose.yml:docker-compose.postgres.yml + run: docker compose config --quiet + + - name: Itkdev opt-in (shared-frontend overlay, bundled traefik off) + env: + COMPOSE_PROFILES: "" + COMPOSE_FILE: docker-compose.yml:docker-compose.shared-frontend.yml + COMPOSE_FRONTEND_NETWORK: frontend + run: | + docker network create frontend + docker compose config --quiet + + smoke-default: + runs-on: ubuntu-latest + name: Smoke test (bundled traefik) + env: + COMPOSE_PROFILES: traefik + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Generate dev cert + run: sh scripts/dev-cert.sh + + # Limited service set — mariadb + phpfpm + nginx + traefik + redis. + # node + playwright are intentionally excluded; they're heavy pulls + # and not needed to validate the routing path. + - name: Bring up core services + run: docker compose up --detach --wait mariadb redis nginx traefik + + - name: Probe nginx health via bundled traefik (HTTPS) + # `Host:` matches the router rule labelled on nginx + # (`Host(`${COMPOSE_DOMAIN}`)` = `display.local.itkdev.dk`). + # `-k` skips cert verification — the dev cert is self-signed. + run: | + status=$(curl -kfsS -o /dev/null -w '%{http_code}' \ + -H 'Host: display.local.itkdev.dk' \ + https://localhost/health) + echo "HTTP status: $status" + test "$status" = "200" + + - name: Dump logs on failure + if: failure() + run: docker compose logs --no-color + + - name: Tear down + if: always() + run: docker compose down --volumes --remove-orphans + + smoke-itkdev: + runs-on: ubuntu-latest + name: Smoke test (itkdev opt-in) + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Pre-create external frontend network + run: docker network create frontend + + # Mirror how an itkdev dev actually opts out of the bundled traefik: + # an `.env.local` that overrides the committed `.env` defaults. The + # dev-cert.sh script re-sources `.env` (with `.env.local` last so it + # wins), so this is the only way to make the override stick. + - name: Write itkdev .env.local override + run: | + cat > .env.local <<'EOF' + COMPOSE_PROFILES= + COMPOSE_FILE=docker-compose.yml:docker-compose.shared-frontend.yml + COMPOSE_FRONTEND_NETWORK=frontend + EOF + + - name: Dev cert script no-ops when bundled traefik is off + run: | + out=$(sh scripts/dev-cert.sh) + echo "$out" + echo "$out" | grep -q "skipping dev cert" + test ! -f traefik/ssl/dev.crt + + - name: Bring up core services (no bundled traefik) + run: docker compose up --detach --wait mariadb redis nginx + + - name: Dump logs on failure + if: failure() + run: docker compose logs --no-color + + - name: Tear down + if: always() + run: docker compose down --volumes --remove-orphans diff --git a/README.md b/README.md index 7281f5f12..2cc8f5571 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,39 @@ The fixtures have an editor user: with the password: "apass The fixtures have the image-text template, and two screen layouts: "full screen" and "two boxes". +### Reverse proxy & local HTTPS + +A fresh clone bundles its own traefik (compose profile `traefik`, enabled by default via +`COMPOSE_PROFILES` in `.env`) that terminates TLS on `:80`/`:443` with a self-signed dev cert. +`task site-install` runs `task dev:cert` for you on first install; re-run it manually with `FORCE=1` +to regenerate (e.g. after changing `COMPOSE_DOMAIN`). The cert covers `COMPOSE_DOMAIN` plus +the `node-` variant and `localhost`. Browsers warn the first time — accept the cert once. + +If 80/443 are taken on the host, override via `.env.local`: + +```text +HTTP_PORT=8080 +HTTPS_PORT=8443 +``` + +### Itkdev developers: host-level traefik opt-in + +Itkdev hosts already run a shared traefik on an external `frontend` network. Disable the bundled +traefik and switch to the shared-frontend overlay in `.env.local`: + +```text +COMPOSE_PROFILES= +COMPOSE_FILE=docker-compose.yml:docker-compose.shared-frontend.yml +COMPOSE_FRONTEND_NETWORK=frontend +# Optional — restores the legacy wrapper: +TASK_DOCKER_COMPOSE=itkdev-docker-compose +``` + +Switching modes is safe: the default network is `_frontend`, scoped so it never collides +with itkdev's shared `frontend` (override via `COMPOSE_FRONTEND_NETWORK`). + +See `docker-compose.shared-frontend.yml` for the pre-existing-network requirement. + ### Database (MariaDB) Local dev defaults to `mariadb:11.4` (LTS until May 2029). CI also exercises `mariadb:10.11` (LTS until @@ -367,7 +400,7 @@ To test the React apps we use playwright. It is important that the versions of the playwright container and the library imported in package.json align. -See the `docker-compose.override.yml` playwright entry and the version imported in package.json. +See the `docker-compose.yml` playwright entry and the version imported in package.json. #### Testing on the built files @@ -607,7 +640,7 @@ rather than the friendly Symfony validator message. Keep them ordered as: **PHP- | Layer | Knob | Where it lives | |---|---|---| | App (Symfony validator + Admin UI) | `MEDIA_MAX_UPLOAD_SIZE_MB` (megabytes, integer) | `.env` (committed default `200`) — override in `.env.local` for development or in the deployment environment for production | -| Nginx request body | `NGINX_MAX_BODY_SIZE` (nginx size string, e.g. `200m`) | `docker-compose.yml` and `docker-compose.server.yml`; image default is `200m` (set in `infrastructure/nginx/Dockerfile`) | +| Nginx request body | `NGINX_MAX_BODY_SIZE` (nginx size string, e.g. `200m`) | `docker-compose.yml`; image default is `200m` (set in `infrastructure/nginx/Dockerfile`) | | PHP-FPM upload + post body | `PHP_UPLOAD_MAX_FILESIZE`, `PHP_POST_MAX_SIZE` (PHP size strings, e.g. `200M`) | Operator-managed env vars on the php-fpm container (supported by the `itkdev/php8.4-fpm` base image). Not set in this repo by default — base image defaults apply unless overridden | The app reads `MEDIA_MAX_UPLOAD_SIZE_MB` per-request, so a deploy / php-fpm worker reload is enough to pick up diff --git a/Taskfile.yml b/Taskfile.yml index fc0808dfc..e02202743 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -6,7 +6,11 @@ dotenv: [".env.local", ".env"] vars: # https://taskfile.dev/reference/templating/ BASE_URL: "{{.TASK_BASE_URL | default .COMPOSE_SERVER_DOMAIN | default .COMPOSE_DOMAIN }}" - DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "itkdev-docker-compose" }}' + # Plain `docker compose` by default — works on any host with Docker + # Compose v2 installed, no itkdev wrapper required. Itkdev hosts that + # want the legacy `itkdev-docker-compose` wrapper can opt in via + # `TASK_DOCKER_COMPOSE=itkdev-docker-compose` in `.env.local`. + DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "docker compose" }}' tasks: default: @@ -49,6 +53,15 @@ tasks: - task site-open silent: true + dev:cert: + desc: | + Generate a self-signed cert for the bundled dev traefik + (traefik/ssl/dev.{crt,key}). Idempotent — no-op if dev.crt exists; + set FORCE=1 to overwrite. Has no effect when COMPOSE_PROFILES + doesn't include `traefik` (itkdev devs using a host-level traefik). + cmds: + - sh scripts/dev-cert.sh + site-open: desc: "Opens admin, docs and client." cmds: @@ -92,6 +105,7 @@ tasks: compose-up: desc: "Runs docker compose up." cmds: + - task dev:cert - task compose -- up --detach --remove-orphans silent: true diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index c75114eec..000000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,27 +0,0 @@ -# itk-version: 3.2.3 -services: - phpfpm: - environment: - - PHP_SENDMAIL_PATH=/usr/sbin/sendmail -S mail:1025 - - nginx: - labels: - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=ITKBasicAuth@file" - - mail: - image: axllent/mailpit - restart: unless-stopped - networks: - - app - - frontend - labels: - - "traefik.enable=true" - - "traefik.docker.network=frontend" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}-http.rule=Host(`mail.${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}-http.entrypoints=web" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}-http.middlewares=redirect-to-https" - - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}.rule=Host(`mail.${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}.entrypoints=websecure" - - "traefik.http.services.mail_${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=8025" - - "traefik.http.routers.mail_${COMPOSE_PROJECT_NAME}.middlewares=ITKMailhogAuth@file" diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index d94541784..000000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,73 +0,0 @@ -services: - nginx: - labels: - # HTTPS config - uncomment to enable redirect from :80 to :443 - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" - - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - - redis: - # Redis 8 GA'd 2025-05; back to OSI-approved licensing (AGPLv3) and - # ships RedisJSON / RediSearch / RedisTimeSeries / RedisBloom in-tree. - # Symfony 6.4 cache adapter only checks `redis_version >= 4.0`; the - # phpredis 6.3 shipped in itkdev/php8.4-fpm supports the new RESP3 - # niceties without any code change here. - image: "redis:8" - command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru - networks: - - app - ports: - - "6379" - - node: - image: node:24 - user: "${COMPOSE_NODE_USER:-root}" - command: npm run dev - networks: - - app - - frontend - ports: - - "5173" - working_dir: /app - environment: - - NODE_ENV=development - volumes: - - .:/app:delegated - labels: - - "traefik.enable=true" - - "traefik.docker.network=frontend" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}node.rule=Host(`node-${COMPOSE_DOMAIN}`)" - # HTTPS config - uncomment to enable redirect from :80 to :443 - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}node.middlewares=redirect-to-https" - - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - - playwright: - # https://playwright.dev/docs/docker - # This Playwright version should match the one in `package.json`. - image: mcr.microsoft.com/playwright:v1.57.0 - networks: - - app - depends_on: - nginx: - condition: service_healthy - volumes: - - .:/app - - /tmp/.X11-unix:/tmp/.X11-unix - working_dir: /app - - markdownlint: - image: peterdavehello/markdownlint - profiles: - - dev - volumes: - - ./:/md - - prettier: - # Prettier does not (yet, fcf. - # https://github.com/prettier/prettier/issues/15206) have an official - # docker image. - # https://hub.docker.com/r/jauderho/prettier is good candidate (cf. https://hub.docker.com/search?q=prettier&sort=updated_at&order=desc) - image: jauderho/prettier - profiles: - - dev - volumes: - - ./:/work diff --git a/docker-compose.redirect.yml b/docker-compose.redirect.yml deleted file mode 100644 index 844b3e6ac..000000000 --- a/docker-compose.redirect.yml +++ /dev/null @@ -1,15 +0,0 @@ -# itk-version: 3.2.3 -services: - nginx: - labels: - # Add www before domain and set redirect to non-www - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}-http.rule=Host(`www.${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}-http.entrypoints=web" - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}-http.middlewares=redirect-to-https,non_www" - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}.rule=Host(`www.${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}.entrypoints=websecure" - - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}.middlewares=non_www" - - - traefik.http.middlewares.non_www.redirectregex.regex=^(http|https)?://(?:www\.)?(.+) - - traefik.http.middlewares.non_www.redirectregex.replacement=https://$${2} - - traefik.http.middlewares.non_www.redirectregex.permanent=true diff --git a/docker-compose.server.yml b/docker-compose.server.yml deleted file mode 100644 index a744072e9..000000000 --- a/docker-compose.server.yml +++ /dev/null @@ -1,63 +0,0 @@ -# itk-version: 3.2.3 -networks: - frontend: - external: true - app: - driver: bridge - internal: false - -services: - phpfpm: - image: itkdev/php8.4-fpm:alpine - restart: unless-stopped - networks: - - app - extra_hosts: - - "host.docker.internal:host-gateway" - environment: - - PHP_MAX_EXECUTION_TIME=30 - - PHP_MEMORY_LIMIT=128M - volumes: - - .:/app - - nginx: - image: nginxinc/nginx-unprivileged:alpine - restart: unless-stopped - networks: - - app - - frontend - depends_on: - - phpfpm - volumes: - - ./infrastructure/nginx/etc/templates:/etc/nginx/templates:ro - - ./infrastructure/nginx/etc/nginx.conf:/etc/nginx/nginx.conf:ro - - .:/app - environment: - NGINX_FPM_SERVICE: ${COMPOSE_PROJECT_NAME}-phpfpm-1 - NGINX_FPM_PORT: 9000 - NGINX_WEB_ROOT: /app/public - NGINX_PORT: 8080 - NGINX_MAX_BODY_SIZE: 5M - NGINX_SET_REAL_IP_FROM: 172.16.0.0/12 - healthcheck: - # $$NGINX_PORT escapes compose-time interpolation; the variable is - # expanded by the shell inside the container against the service's - # environment. - test: - [ - "CMD-SHELL", - 'wget --quiet --tries=1 --spider "http://localhost:$$NGINX_PORT/health"', - ] - interval: 5s - timeout: 3s - retries: 6 - start_period: 5s - labels: - - "traefik.enable=true" - - "traefik.docker.network=frontend" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-http.rule=Host(`${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-http.entrypoints=web" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-http.middlewares=redirect-to-https" - - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_SERVER_DOMAIN}`)" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.entrypoints=websecure" diff --git a/docker-compose.shared-frontend.yml b/docker-compose.shared-frontend.yml new file mode 100644 index 000000000..094c134c9 --- /dev/null +++ b/docker-compose.shared-frontend.yml @@ -0,0 +1,21 @@ +# Shared-frontend overlay — opt-in for hosts running a shared traefik on an +# external `frontend` network (the itkdev local-dev pattern). Include via +# COMPOSE_FILE in `.env.local`, and disable the bundled traefik with +# `COMPOSE_PROFILES=`: +# +# COMPOSE_PROFILES= +# COMPOSE_FILE=docker-compose.yml:docker-compose.shared-frontend.yml +# +# Flips the `frontend` network to external. The host must pre-create the +# network (itkdev-docker-compose does this automatically when bringing up +# its traefik stack): +# +# docker network create frontend + +networks: + frontend: + external: true + # Same env var the base + labels use — defaults to the shared + # `frontend` engine name itkdev hosts pre-create. Override to a + # different engine name if the operator has named theirs differently. + name: ${COMPOSE_FRONTEND_NETWORK:-frontend} diff --git a/docker-compose.yml b/docker-compose.yml index ff81d1412..c9f2f16a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,19 @@ # itk-version: 3.2.3 networks: + # Compose-managed by default — the bundled traefik (profile `traefik`, + # enabled by default via COMPOSE_PROFILES in .env) attaches here and + # routes nginx + mail by their existing labels. Itkdev hosts running a + # shared host-level traefik on an external `frontend` network opt in + # via the `docker-compose.shared-frontend.yml` overlay (which flips this to + # external) and set `COMPOSE_PROFILES=` to disable the bundled traefik. frontend: - external: true + driver: bridge + # Project-scoped engine name by default (`_frontend`) so devs + # who switch between bundled traefik and the shared-frontend overlay + # don't collide with the itkdev-managed external `frontend` network. + # Itkdev hosts override this to the shared name in .env.local + # alongside the overlay (see `COMPOSE_FRONTEND_NETWORK` in .env). + name: ${COMPOSE_FRONTEND_NETWORK:-${COMPOSE_PROJECT_NAME:-display}_frontend} app: driver: bridge internal: false @@ -41,8 +53,6 @@ services: - PHP_XDEBUG_MODE=${PHP_XDEBUG_MODE:-off} - PHP_MAX_EXECUTION_TIME=30 - PHP_MEMORY_LIMIT=256M - # Depending on the setup, you may have to remove --read-envelope-from from msmtp (cf. https://marlam.de/msmtp/msmtp.html) or use SMTP to send mail - - PHP_SENDMAIL_PATH=/usr/bin/msmtp --host=mail --port=1025 --read-recipients --read-envelope-from - DOCKER_HOST_DOMAIN=${COMPOSE_DOMAIN} - PHP_IDE_CONFIG=serverName=localhost depends_on: @@ -86,22 +96,93 @@ services: start_period: 5s labels: - "traefik.enable=true" - - "traefik.docker.network=frontend" + - "traefik.docker.network=${COMPOSE_FRONTEND_NETWORK:-${COMPOSE_PROJECT_NAME:-display}_frontend}" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`)" # HTTPS config - uncomment to enable redirect from :80 to :443 # - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - mail: - image: axllent/mailpit + # Bundled traefik for fresh-clone dev — terminates TLS with a self-signed + # cert (generated by `task dev:cert` into traefik/ssl/) and routes by + # docker labels on nginx. Profile-gated so itkdev hosts running a + # shared host-level traefik can disable it via COMPOSE_PROFILES= in + # .env.local (paired with the docker-compose.shared-frontend.yml overlay). + traefik: + image: traefik:v3.6 + profiles: [traefik] + restart: unless-stopped + networks: + - app + - frontend + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + volumes: + - ./traefik/traefik.yml:/traefik.yml:ro + - ./traefik/dynamic.yml:/config/dynamic.yml:ro + - ./traefik/ssl:/certs:ro + # Read-only docker socket so traefik can discover service labels. + # Sufficient for local dev; a production setup would broker this + # via a socket-proxy (see os2display-docker-server for that pattern). + - /var/run/docker.sock:/var/run/docker.sock:ro + + # Redis 8 GA'd 2025-05; back to OSI-approved licensing (AGPLv3) and + # ships RedisJSON / RediSearch / RedisTimeSeries / RedisBloom in-tree. + # Symfony 6.4 cache adapter only checks `redis_version >= 4.0`; the + # phpredis 6.3 shipped in itkdev/php8.4-fpm supports the new RESP3 + # niceties without any code change here. + redis: + image: "redis:8" + command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru + networks: + - app + ports: + - "6379" + + node: + image: node:24 + user: "${COMPOSE_NODE_USER:-root}" + command: npm run dev networks: - app - frontend ports: - - "1025" - - "8025" + - "5173" + working_dir: /app + environment: + - NODE_ENV=development + volumes: + - .:/app:delegated labels: - "traefik.enable=true" - - "traefik.docker.network=frontend" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}mail.rule=Host(`mail-${COMPOSE_DOMAIN}`)" - - "traefik.http.services.${COMPOSE_PROJECT_NAME}mail.loadbalancer.server.port=8025" + - "traefik.docker.network=${COMPOSE_FRONTEND_NETWORK:-${COMPOSE_PROJECT_NAME:-display}_frontend}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}node.rule=Host(`node-${COMPOSE_DOMAIN}`)" + + playwright: + # https://playwright.dev/docs/docker + # Version should match the one in `package.json`. + image: mcr.microsoft.com/playwright:v1.57.0 + networks: + - app + depends_on: + nginx: + condition: service_healthy + volumes: + - .:/app + - /tmp/.X11-unix:/tmp/.X11-unix + working_dir: /app + + markdownlint: + image: peterdavehello/markdownlint + profiles: [dev] + volumes: + - ./:/md + + prettier: + # Prettier doesn't ship an official image + # (https://github.com/prettier/prettier/issues/15206); jauderho/prettier + # is a well-maintained community build. + image: jauderho/prettier + profiles: [dev] + volumes: + - ./:/work diff --git a/scripts/dev-cert.sh b/scripts/dev-cert.sh new file mode 100755 index 000000000..4c0054fd9 --- /dev/null +++ b/scripts/dev-cert.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Generate a self-signed certificate for the bundled dev traefik. +# Writes traefik/ssl/dev.{crt,key} covering COMPOSE_DOMAIN as the primary +# CN plus a handful of common local-dev fallbacks as SubjectAltNames. +# +# Usage: scripts/dev-cert.sh (no-op if dev.crt already exists) +# FORCE=1 scripts/dev-cert.sh (overwrite) +# +# NOT FOR PRODUCTION. RSA-2048, SHA-256, 365 days, untrusted CA. Browsers +# will show a warning the first time you accept it. +# +# Requires: docker. Uses alpine/openssl in a transient container so no +# openssl is needed on the host. + +set -euo pipefail + +CERT_DIR="traefik/ssl" +CERT_FILE="$CERT_DIR/dev.crt" +KEY_FILE="$CERT_DIR/dev.key" + +# Pull COMPOSE_DOMAIN from .env / .env.local so the cert matches the host +# the dev will actually browse to. Defensive sourcing — chained Task calls +# may have stale exported env if .env was just rewritten. +set -a +# shellcheck disable=SC1091 +[ -f .env ] && . ./.env +# shellcheck disable=SC1091 +[ -f .env.local ] && . ./.env.local +set +a + +# No bundled traefik in this dev's setup (itkdev devs use a host-level +# traefik and set COMPOSE_PROFILES= in .env.local). Don't generate a +# cert nothing will use. +case ",${COMPOSE_PROFILES:-}," in + *,traefik,*) ;; + *) + echo "Bundled traefik not in COMPOSE_PROFILES; skipping dev cert." + exit 0 + ;; +esac + +APP_DOMAIN="${COMPOSE_DOMAIN:-display.localhost}" + +if [ -f "$CERT_FILE" ] && [ "${FORCE:-0}" != "1" ]; then + echo "$CERT_FILE already exists. Set FORCE=1 to overwrite." + exit 0 +fi + +mkdir -p "$CERT_DIR" + +docker run --rm -v "$PWD/$CERT_DIR:/out" alpine/openssl \ + req -x509 -newkey rsa:2048 -nodes \ + -keyout /out/dev.key \ + -out /out/dev.crt \ + -days 365 \ + -subj "/CN=${APP_DOMAIN}" \ + -addext "subjectAltName=DNS:${APP_DOMAIN},DNS:node-${APP_DOMAIN},DNS:localhost,IP:127.0.0.1" + +echo "Generated:" +echo " $CERT_FILE (covers: ${APP_DOMAIN}, node-${APP_DOMAIN}, localhost, 127.0.0.1)" +echo " $KEY_FILE" +echo +echo "Restart traefik to pick up the new cert: docker compose restart traefik" diff --git a/traefik/dynamic.yml b/traefik/dynamic.yml new file mode 100644 index 000000000..c786b14ef --- /dev/null +++ b/traefik/dynamic.yml @@ -0,0 +1,11 @@ +# Dynamic traefik config — loaded by the file provider from /config/. +# References TLS material at /certs/dev.{crt,key}, generated by +# `task dev:cert` into ./traefik/ssl/ (gitignored). +tls: + options: + modern: + minVersion: VersionTLS12 + sniStrict: false + certificates: + - certFile: /certs/dev.crt + keyFile: /certs/dev.key diff --git a/traefik/ssl/.gitignore b/traefik/ssl/.gitignore new file mode 100644 index 000000000..401789a7d --- /dev/null +++ b/traefik/ssl/.gitignore @@ -0,0 +1,3 @@ +# Generated dev certs (task dev:cert). Never commit a cert + private key. +dev.crt +dev.key diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 000000000..9e1f6c074 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,33 @@ +# Static traefik config for the bundled dev traefik (profile `traefik`). +# Dev-only — no Let's Encrypt, no insecure dashboard. TLS certs come from +# the file provider in dynamic.yml, served from /certs/ (bound to +# ./traefik/ssl/ on the host; populate via `task dev:cert`). +api: + dashboard: false + insecure: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + address: ":443" + http: + tls: + options: modern@file + +providers: + file: + directory: /config + watch: true + docker: + # Default endpoint /var/run/docker.sock — mounted read-only in compose. + exposedByDefault: false + +log: + level: INFO From f11681cc88399e552da6af190314ab411bc928e8 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 28 May 2026 12:46:01 +0200 Subject: [PATCH 2/4] ci: invoke dev-cert.sh with bash and add CHANGELOG entry scripts/dev-cert.sh has a bash shebang and uses `set -o pipefail`, which dash (the Ubuntu runner's `sh`) rejects, so both Compose smoke-test jobs exited at line 16. Invoke the script with `bash` to match its declared interpreter. Also add the missing Unreleased CHANGELOG entry that the changelog gate requires. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docker-compose.yaml | 4 ++-- CHANGELOG.md | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml index 6ecf05e0d..9133268d4 100644 --- a/.github/workflows/docker-compose.yaml +++ b/.github/workflows/docker-compose.yaml @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@v6 - name: Generate dev cert - run: sh scripts/dev-cert.sh + run: bash scripts/dev-cert.sh # Limited service set — mariadb + phpfpm + nginx + traefik + redis. # node + playwright are intentionally excluded; they're heavy pulls @@ -115,7 +115,7 @@ jobs: - name: Dev cert script no-ops when bundled traefik is off run: | - out=$(sh scripts/dev-cert.sh) + out=$(bash scripts/dev-cert.sh) echo "$out" echo "$out" | grep -q "skipping dev cert" test ! -f traefik/ssl/dev.crt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d774c83a..3ebdb3851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Decoupled the dev compose stack from `itkdev-docker`: dropped the wrapper overlays in + favour of a self-contained stack with bundled traefik (opt-in via `COMPOSE_PROFILES=traefik`) + and a `docker-compose.shared-frontend.yml` overlay for devs keeping a host-level traefik. - Rewrote the consolidated end-of-2.8 migration to Doctrine's Schema tool API; added a `NoAddSqlInMigrationRule` PHPStan rule to enforce the convention on future migrations. - Added a Postgres `Validate Schema` job to the Doctrine workflow as a regression gate against From e06ab5a5e10a3ef70892c9af003070346986614e Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 28 May 2026 12:50:15 +0200 Subject: [PATCH 3/4] ci: align SNI and Host in bundled-traefik smoke probe Traefik v3 returns 421 (misdirected request) when the TLS SNI differs from the HTTP Host header, irrespective of `sniStrict`. Curl was sending SNI=localhost (from the URL) but Host=display.local.itkdev.dk (to match the router rule), tripping that check. Use `--resolve` so the URL host, SNI, and Host header all match the rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docker-compose.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml index 9133268d4..7c928b6c3 100644 --- a/.github/workflows/docker-compose.yaml +++ b/.github/workflows/docker-compose.yaml @@ -73,13 +73,15 @@ jobs: run: docker compose up --detach --wait mariadb redis nginx traefik - name: Probe nginx health via bundled traefik (HTTPS) - # `Host:` matches the router rule labelled on nginx - # (`Host(`${COMPOSE_DOMAIN}`)` = `display.local.itkdev.dk`). + # `--resolve` aligns URL host, TLS SNI, and HTTP Host header — all + # `display.local.itkdev.dk` (matches the router rule labelled on + # nginx). Mismatched SNI vs Host makes traefik v3 return 421 + # (misdirected request) even when `sniStrict` is off. # `-k` skips cert verification — the dev cert is self-signed. run: | status=$(curl -kfsS -o /dev/null -w '%{http_code}' \ - -H 'Host: display.local.itkdev.dk' \ - https://localhost/health) + --resolve display.local.itkdev.dk:443:127.0.0.1 \ + https://display.local.itkdev.dk/health) echo "HTTP status: $status" test "$status" = "200" From 88d41d2888db9cc65d03102209ad5a9e46564ee2 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Thu, 28 May 2026 12:53:50 +0200 Subject: [PATCH 4/4] ci: rename Compose workflow to "Docker Compose" Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-compose.yaml b/.github/workflows/docker-compose.yaml index 7c928b6c3..4d28230ef 100644 --- a/.github/workflows/docker-compose.yaml +++ b/.github/workflows/docker-compose.yaml @@ -19,7 +19,7 @@ on: - ".env" - ".github/workflows/docker-compose.yaml" -name: Compose +name: Docker Compose jobs: lint: