From cf773b74e3481c389b95b5b47810783c03590161 Mon Sep 17 00:00:00 2001 From: albertlast Date: Tue, 28 Jul 2026 23:02:30 +0200 Subject: [PATCH 1/4] Adds a Docker development environment using PostgreSQL Provides a reproducible local stack so contributors can work on SMF without installing PHP, Composer or PostgreSQL on the host: - PHP 8.4 on Apache, with every extension other/requirements.md lists as required (mbstring, fileinfo, pgsql, mysqli) or recommended (gd, intl, curl, exif, ftp, xsl, zip). - PostgreSQL 17, with standard_conforming_strings forced on at database level as SMF requires. - Mailpit, so mail() is captured locally and nothing can be sent out. - Adminer, for browsing the database. The entrypoint runs composer install, waits for the database, generates a Settings.php pointed at the db service and drops install.php into place, so a fresh checkout is ready to install on first boot. Everything lives under .docker/ because check-smf-index.php and check-smf-license.php skip dot directories, so the environment cannot break the file integrity checks. Co-Authored-By: Claude Opus 5 Signed-off-by: albertlast --- .docker/README.md | 105 ++++++++++++++++++++++++++++++++ .docker/env.example | 17 ++++++ .docker/php/Dockerfile | 47 ++++++++++++++ .docker/php/entrypoint.sh | 92 ++++++++++++++++++++++++++++ .docker/php/msmtprc | 11 ++++ .docker/php/php.ini | 34 +++++++++++ .docker/php/vhost.conf | 19 ++++++ .docker/postgres/init/10-smf.sh | 12 ++++ .gitignore | 7 +++ compose.yaml | 76 +++++++++++++++++++++++ 10 files changed, 420 insertions(+) create mode 100644 .docker/README.md create mode 100644 .docker/env.example create mode 100644 .docker/php/Dockerfile create mode 100644 .docker/php/entrypoint.sh create mode 100644 .docker/php/msmtprc create mode 100644 .docker/php/php.ini create mode 100644 .docker/php/vhost.conf create mode 100644 .docker/postgres/init/10-smf.sh create mode 100644 compose.yaml diff --git a/.docker/README.md b/.docker/README.md new file mode 100644 index 00000000000..d230980f984 --- /dev/null +++ b/.docker/README.md @@ -0,0 +1,105 @@ +# SMF development environment (PostgreSQL) + +A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is +part of the shipped forum — it lives in `.docker/` precisely so the CI checks +(`check-smf-index.php`, `check-smf-license.php`) skip it. + +## Requirements + +Docker Desktop (Linux containers). Nothing else — no local PHP, Composer or +PostgreSQL install is needed. + +## Start + +```sh +docker compose up -d --build +``` + +First boot takes a few minutes: it builds the PHP image and runs +`composer install` into `vendor/`. Watch it with `docker compose logs -f web` +and wait for the `[smf-dev] ready` line. + +| Service | URL | Notes | +| -------- | ----------------------- | ---------------------------------------- | +| Forum | http://localhost:8080 | The forum itself | +| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | +| Adminer | http://localhost:8081 | Database browser, pre-pointed at `db` | +| Postgres | `localhost:5433` | For DBeaver/psql/etc. on the host | + +Database credentials are `smf` / `smf` / database `smf` throughout. + +## Installing the forum + +On first boot the entrypoint writes a `Settings.php` pre-filled for the +`db` service and copies `other/install.php` to the web root, so +http://localhost:8080 redirects into the installer. + +The installer does **not** read its form defaults from `Settings.php` — it uses +the hardcoded defaults in `SMF\Db\APIs\PostgreSQL`. On the *Database Server +Settings* step you must enter: + +| Field | Value | +| ------------- | ------------ | +| Database type | `PostgreSQL` | +| Server | `db` | +| Port | `5432` | +| Username | `smf` | +| Password | `smf` | +| Database name | `smf` | + +Port `5432` is correct here: `5433` is only how the host reaches postgres from +outside Docker. Containers talk to each other on the internal network. + +When the installer finishes, delete `install.php` from the repo root — while it +exists, `Settings.php` redirects every request back into the installer. + +## Everyday use + +```sh +docker compose logs -f web # apache + php errors, live +docker compose exec web bash # shell in the web container +docker compose exec db psql -U smf # psql on the forum database + +docker compose exec web composer install +docker compose exec web composer lint + +docker compose restart web # after changing php.ini or the vhost +docker compose down # stop, keep the database +docker compose down -v # stop and destroy the database +``` + +The repository is bind-mounted at `/var/www/html`, so edits on the host are +live on the next request. Opcache is on but revalidates every request, so you +never need to restart for a PHP change. + +To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and +`Settings_bak.php`, then `docker compose up -d`. + +## Configuration + +`compose.yaml` works with no `.env` file. To change ports, versions or +credentials, copy `.docker/env.example` to `.env` in the repository root. + +## What is in the image + +PHP 8.4 on Apache, with everything `other/requirements.md` lists: + +- Required: `mbstring`, `fileinfo`, `pgsql` (SMF checks for `pg_connect`), plus + `mysqli` so the installer still offers MySQL. +- Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`. +- `standard_conforming_strings` is set `on` at database level, as SMF requires. +- `mail()` is routed through msmtp into Mailpit, so no mail can escape the + machine. + +## Files + +``` +compose.yaml the stack +.docker/php/Dockerfile PHP + Apache image +.docker/php/php.ini dev php settings, per requirements.md +.docker/php/vhost.conf apache vhost +.docker/php/msmtprc mail() -> mailpit +.docker/php/entrypoint.sh composer install, Settings.php, permissions +.docker/postgres/init/10-smf.sh runs once on first database creation +.docker/env.example optional overrides +``` diff --git a/.docker/env.example b/.docker/env.example new file mode 100644 index 00000000000..52db52142d6 --- /dev/null +++ b/.docker/env.example @@ -0,0 +1,17 @@ +# Copy to the repository root as `.env` to override any of the defaults. +# compose.yaml works without this file. + +# Host ports +WEB_PORT=8080 +ADMINER_PORT=8081 +MAILPIT_PORT=8025 +POSTGRES_PORT=5433 + +# Versions +PHP_VERSION=8.4 +POSTGRES_VERSION=17-alpine + +# Database credentials (dev only) +POSTGRES_DB=smf +POSTGRES_USER=smf +POSTGRES_PASSWORD=smf diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile new file mode 100644 index 00000000000..a9588f1df5d --- /dev/null +++ b/.docker/php/Dockerfile @@ -0,0 +1,47 @@ +# SMF development image: PHP + Apache with everything other/requirements.md asks for. +ARG PHP_VERSION=8.4 +FROM php:${PHP_VERSION}-apache + +COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/ +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +# Required by SMF: mbstring, fileinfo, pgsql (pg_connect), mysqli. +# Recommended by SMF: gd, intl, curl, exif, ftp, xsl. +RUN install-php-extensions \ + pgsql \ + pdo_pgsql \ + mysqli \ + mbstring \ + fileinfo \ + gd \ + intl \ + curl \ + exif \ + ftp \ + xsl \ + zip \ + opcache \ + && a2enmod rewrite headers expires \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + msmtp \ + msmtp-mta \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Route mail() at Mailpit so outgoing forum mail is captured, never sent. +COPY .docker/php/msmtprc /etc/msmtprc +RUN chmod 0644 /etc/msmtprc + +COPY .docker/php/php.ini /usr/local/etc/php/conf.d/zz-smf.ini +COPY .docker/php/vhost.conf /etc/apache2/sites-available/000-default.conf +COPY .docker/php/entrypoint.sh /usr/local/bin/smf-entrypoint + +RUN chmod +x /usr/local/bin/smf-entrypoint + +WORKDIR /var/www/html + +ENTRYPOINT ["/usr/local/bin/smf-entrypoint"] +CMD ["apache2-foreground"] diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh new file mode 100644 index 00000000000..676ed275c00 --- /dev/null +++ b/.docker/php/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/sh +# Prepares the bind-mounted SMF checkout so the forum is ready to install/serve. +# Everything here is idempotent: it is safe to restart the container at any time. +set -eu + +BOARD_DIR=/var/www/html + +log() { + echo "[smf-dev] $*" +} + +# ---------------------------------------------------------------- dependencies +if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then + log 'vendor/ is missing, running composer install (this takes a minute the first time)' + composer install \ + --working-dir="$BOARD_DIR" \ + --no-interaction \ + --no-progress \ + --prefer-dist \ + --ansi +fi + +# ------------------------------------------------------------------- database +log "waiting for postgres at ${SMF_DB_SERVER}:${SMF_DB_PORT}" +until pg_isready -h "$SMF_DB_SERVER" -p "$SMF_DB_PORT" -U "$SMF_DB_USER" -q; do + sleep 1 +done +log 'postgres is accepting connections' + +# --------------------------------------------------------------- installer bits +# Settings.php redirects to install.php whenever install.php is present, so both +# files only get placed while the forum has not been installed yet. +if [ ! -f "$BOARD_DIR/Settings.php" ]; then + log 'generating Settings.php pre-filled for the postgres service' + + sed \ + -e "s|^\$db_type = 'mysql';|\$db_type = 'postgresql';|" \ + -e "s|^\$db_port = 0;|\$db_port = ${SMF_DB_PORT};|" \ + -e "s|^\$db_server = 'localhost';|\$db_server = '${SMF_DB_SERVER}';|" \ + -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \ + -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ + -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ + -e "s|^\$boardurl = 'http://127.0.0.1/smf';|\$boardurl = '${SMF_BOARDURL}';|" \ + -e "s|^\$mbname = 'My Community';|\$mbname = 'SMF Dev';|" \ + "$BOARD_DIR/other/Settings.php" > "$BOARD_DIR/Settings.php" + + cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php" + + log "installer ready -- open ${SMF_BOARDURL}/install.php" +fi + +[ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php" + +# ------------------------------------------------------------------ writability +# The installer refuses to continue unless all of these are writable, and the +# forum needs them at runtime too. +for path in \ + attachments \ + avatars \ + custom_avatar \ + cache \ + Packages \ + Smileys \ + Themes \ + Languages \ + Sources \ + Settings.php \ + Settings_bak.php \ + Languages/en_US/agreement.txt +do + [ -e "$BOARD_DIR/$path" ] || mkdir -p "$BOARD_DIR/$path" +done + +[ -f "$BOARD_DIR/cache/db_last_error.php" ] || cp "$BOARD_DIR/db_last_error.php" "$BOARD_DIR/cache/db_last_error.php" 2>/dev/null || true + +# Bind mounts from the Windows host ignore chown/chmod, which is harmless. On +# Linux/macOS hosts these calls are what makes the checkout writable by Apache. +chown -R www-data:www-data \ + "$BOARD_DIR/attachments" \ + "$BOARD_DIR/avatars" \ + "$BOARD_DIR/custom_avatar" \ + "$BOARD_DIR/cache" \ + "$BOARD_DIR/Packages" \ + "$BOARD_DIR/Smileys" \ + "$BOARD_DIR/Themes" \ + "$BOARD_DIR/Languages" \ + "$BOARD_DIR/Settings.php" \ + "$BOARD_DIR/Settings_bak.php" 2>/dev/null || true + +log 'ready' + +exec "$@" diff --git a/.docker/php/msmtprc b/.docker/php/msmtprc new file mode 100644 index 00000000000..ce832dcf3b4 --- /dev/null +++ b/.docker/php/msmtprc @@ -0,0 +1,11 @@ +defaults +auth off +tls off +logfile /dev/stderr + +account mailpit +host mailpit +port 1025 +from smf@localhost + +account default : mailpit diff --git a/.docker/php/php.ini b/.docker/php/php.ini new file mode 100644 index 00000000000..6b32f746e7a --- /dev/null +++ b/.docker/php/php.ini @@ -0,0 +1,34 @@ +; SMF development settings. +; Values follow other/requirements.md (Requirements + Recommendations). + +[PHP] +engine = On +file_uploads = On +memory_limit = 512M +max_execution_time = 30 +max_input_time = 60 +post_max_size = 128M +upload_max_filesize = 128M +max_file_uploads = 50 +date.timezone = UTC + +; Dev-only: surface every problem instead of hiding it. +display_errors = On +display_startup_errors = On +error_reporting = E_ALL +log_errors = On +error_log = /dev/stderr + +[Session] +session.use_trans_sid = Off +session.save_path = "/tmp" + +[mail function] +sendmail_path = "/usr/bin/msmtp -t -i" + +[opcache] +; Keep opcache on for realistic behaviour, but always re-read changed files. +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 diff --git a/.docker/php/vhost.conf b/.docker/php/vhost.conf new file mode 100644 index 00000000000..8be73bae7ce --- /dev/null +++ b/.docker/php/vhost.conf @@ -0,0 +1,19 @@ +ServerName localhost + + + DocumentRoot /var/www/html + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + # Nothing under these paths should ever be served. + + Require all denied + + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + diff --git a/.docker/postgres/init/10-smf.sh b/.docker/postgres/init/10-smf.sh new file mode 100644 index 00000000000..0498c9270ff --- /dev/null +++ b/.docker/postgres/init/10-smf.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Runs once, on first initialisation of the postgres data volume. +set -eu + +# SMF requires standard_conforming_strings to be on (other/requirements.md). +# It is already the default on modern postgres; setting it at database level +# makes it explicit so it cannot drift out from under the forum. +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + ALTER DATABASE "$POSTGRES_DB" SET standard_conforming_strings = on; +EOSQL + +echo "[smf-dev] database $POSTGRES_DB initialised" diff --git a/.gitignore b/.gitignore index 8a574e9b8d5..6b46b9c3a5c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ attachments/ !/attachments/.htaccess !/attachments/index.php /upgrade.php +/install.php Themes/default/css/minified*.css Themes/default/scripts/minified*.js Themes/default/scripts/minified_deferred*.js @@ -72,6 +73,12 @@ Thumbs.db *.lnk ._* +# Local dev environment # +########################## +/.env +/compose.override.yaml +/compose.override.yml + # Test / Private files # ######################## /nbproject/private/ diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000000..be97189db49 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,76 @@ +# SMF development environment (PostgreSQL). +# +# docker compose up -d --build +# -> http://localhost:8080/install.php +# +# Everything below has a working default, so no .env file is required. Copy +# .docker/env.example to .env if you want to change ports or credentials. + +name: smf-dev + +services: + web: + build: + context: . + dockerfile: .docker/php/Dockerfile + args: + PHP_VERSION: ${PHP_VERSION:-8.4} + ports: + - "${WEB_PORT:-8080}:80" + volumes: + # The checkout is bind-mounted, so edits on the host are live immediately. + - .:/var/www/html + environment: + SMF_DB_SERVER: db + SMF_DB_PORT: "5432" + SMF_DB_NAME: ${POSTGRES_DB:-smf} + SMF_DB_USER: ${POSTGRES_USER:-smf} + SMF_DB_PASSWD: ${POSTGRES_PASSWORD:-smf} + SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + db: + image: postgres:${POSTGRES_VERSION:-17-alpine} + ports: + # Exposed on 5433 by default so it cannot collide with a local postgres. + - "${POSTGRES_PORT:-5433}:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-smf} + POSTGRES_USER: ${POSTGRES_USER:-smf} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-smf} + # Dev-only: skip the password prompt cost for local connections. + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + volumes: + - db-data:/var/lib/postgresql/data + - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-smf} -d ${POSTGRES_DB:-smf}"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + # Catches every mail the forum sends. Nothing leaves the machine. + mailpit: + image: axllent/mailpit:latest + ports: + - "${MAILPIT_PORT:-8025}:8025" + restart: unless-stopped + + # Browse and query the database at http://localhost:8081 + adminer: + image: adminer:latest + ports: + - "${ADMINER_PORT:-8081}:8080" + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: dracula + depends_on: + - db + restart: unless-stopped + +volumes: + db-data: From 2c42cccc1e1084ab52f74057ccde83c318541e1f Mon Sep 17 00:00:00 2001 From: albertlast Date: Wed, 29 Jul 2026 23:58:35 +0200 Subject: [PATCH 2/4] Adds MySQL to the dev environment and makes it the default SMF supports MySQL and PostgreSQL, and until now this environment only offered one of them. Both database services now start, and SMF_DB_TYPE decides which one the generated Settings.php points at. It defaults to mysql, since that is what the great majority of installs run on. The two engines keep separate volumes, so a forum can be installed on each and switched between by deleting Settings.php and restarting. Settings.php wins over SMF_DB_TYPE once it exists, and the entrypoint says so rather than silently ignoring the variable. The postgres service is renamed from `db` to say what it is, and keeps `db` as a network alias so Settings.php files written by the previous version still resolve. Engine settings are pinned the same way the postgres side already pinned standard_conforming_strings: utf8mb4 and InnoDB, matching SMF's own table DDL. The collation is deliberately left at the charset default, because SMF sets CHARSET without COLLATE, and forcing one here would diverge from the tables it creates. Also corrects the everyday-use notes: php.ini, the vhost and the entrypoint are copied into the image, so editing them needs a rebuild rather than a restart. Co-Authored-By: Claude Opus 5 --- .docker/README.md | 119 ++++++++++++++++++++++++----------- .docker/env.example | 22 +++++-- .docker/mysql/init/10-smf.sh | 16 +++++ .docker/php/Dockerfile | 1 + .docker/php/entrypoint.sh | 60 +++++++++++++++--- compose.yaml | 81 +++++++++++++++++++----- 6 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 .docker/mysql/init/10-smf.sh diff --git a/.docker/README.md b/.docker/README.md index d230980f984..43ed7bef21c 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -1,13 +1,15 @@ -# SMF development environment (PostgreSQL) +# SMF development environment A throwaway, reproducible local stack for working on SMF 3.0. Nothing here is part of the shipped forum — it lives in `.docker/` precisely so the CI checks (`check-smf-index.php`, `check-smf-license.php`) skip it. +Both database engines SMF supports are in the stack. **MySQL is the default.** + ## Requirements -Docker Desktop (Linux containers). Nothing else — no local PHP, Composer or -PostgreSQL install is needed. +Docker Desktop (Linux containers). Nothing else — no local PHP, Composer, MySQL +or PostgreSQL install is needed. ## Start @@ -19,36 +21,58 @@ First boot takes a few minutes: it builds the PHP image and runs `composer install` into `vendor/`. Watch it with `docker compose logs -f web` and wait for the `[smf-dev] ready` line. -| Service | URL | Notes | -| -------- | ----------------------- | ---------------------------------------- | -| Forum | http://localhost:8080 | The forum itself | -| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | -| Adminer | http://localhost:8081 | Database browser, pre-pointed at `db` | -| Postgres | `localhost:5433` | For DBeaver/psql/etc. on the host | +| Service | URL / address | Notes | +| ---------- | ----------------------- | --------------------------------------- | +| Forum | http://localhost:8080 | The forum itself | +| Mailpit | http://localhost:8025 | Every mail the forum sends lands here | +| Adminer | http://localhost:8081 | Database browser, pre-pointed at MySQL | +| MySQL | `localhost:3307` | For a client on the host | +| PostgreSQL | `localhost:5433` | For a client on the host | + +Credentials are `smf` / `smf` / database `smf` on both engines. + +## Choosing the engine + +Both database services always start. `SMF_DB_TYPE` decides which one the forum +is pointed at, and it defaults to `mysql`: + +```sh +# In .env, or inline: +SMF_DB_TYPE=postgresql docker compose up -d +``` + +This only affects the `Settings.php` the entrypoint *generates*. Once the forum +is installed, `Settings.php` is what counts and changing the variable does +nothing — the entrypoint says so in the log rather than leaving you guessing. To +move to the other engine, delete `Settings.php` and `Settings_bak.php`, then +restart `web` and reinstall. -Database credentials are `smf` / `smf` / database `smf` throughout. +Because both engines run side by side with separate volumes, you can install on +one, switch, install on the other, and switch back: each database keeps its own +forum. ## Installing the forum -On first boot the entrypoint writes a `Settings.php` pre-filled for the -`db` service and copies `other/install.php` to the web root, so +On first boot the entrypoint writes a `Settings.php` pre-filled for the chosen +engine and copies `other/install.php` to the web root, so http://localhost:8080 redirects into the installer. The installer does **not** read its form defaults from `Settings.php` — it uses -the hardcoded defaults in `SMF\Db\APIs\PostgreSQL`. On the *Database Server -Settings* step you must enter: - -| Field | Value | -| ------------- | ------------ | -| Database type | `PostgreSQL` | -| Server | `db` | -| Port | `5432` | -| Username | `smf` | -| Password | `smf` | -| Database name | `smf` | - -Port `5432` is correct here: `5433` is only how the host reaches postgres from -outside Docker. Containers talk to each other on the internal network. +the hardcoded defaults in the database API class. On the *Database Server +Settings* step, enter: + +| Field | MySQL | PostgreSQL | +| ------------- | ------- | ------------ | +| Database type | `MySQL` | `PostgreSQL` | +| Server | `mysql` | `postgres` | +| Port | `3306` | `5432` | +| Username | `smf` | `smf` | +| Password | `smf` | `smf` | +| Database name | `smf` | `smf` | + +The internal ports are correct here: `3307` and `5433` are only how the *host* +reaches the databases from outside Docker. Containers talk to each other on the +compose network. When the installer finishes, delete `install.php` from the repo root — while it exists, `Settings.php` redirects every request back into the installer. @@ -56,18 +80,23 @@ exists, `Settings.php` redirects every request back into the installer. ## Everyday use ```sh -docker compose logs -f web # apache + php errors, live -docker compose exec web bash # shell in the web container -docker compose exec db psql -U smf # psql on the forum database +docker compose logs -f web # apache + php errors, live +docker compose exec web bash # shell in the web container + +docker compose exec mysql mysql -usmf -psmf smf # mysql client +docker compose exec postgres psql -U smf # psql docker compose exec web composer install docker compose exec web composer lint -docker compose restart web # after changing php.ini or the vhost -docker compose down # stop, keep the database -docker compose down -v # stop and destroy the database +docker compose up -d --build web # after changing anything in .docker/php/ +docker compose down # stop, keep both databases +docker compose down -v # stop and destroy both databases ``` +`php.ini`, the vhost and the entrypoint are copied into the image at build time, +not bind-mounted, so a plain `restart` will not pick up edits to them. Rebuild. + The repository is bind-mounted at `/var/www/html`, so edits on the host are live on the next request. Opcache is on but revalidates every request, so you never need to restart for a PHP change. @@ -77,20 +106,33 @@ To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and ## Configuration -`compose.yaml` works with no `.env` file. To change ports, versions or -credentials, copy `.docker/env.example` to `.env` in the repository root. +`compose.yaml` works with no `.env` file. To change ports, versions, the engine +or credentials, copy `.docker/env.example` to `.env` in the repository root. + +The `postgres` service also answers to the hostname `db`, which is what +`Settings.php` files generated before MySQL was added point at. ## What is in the image PHP 8.4 on Apache, with everything `other/requirements.md` lists: -- Required: `mbstring`, `fileinfo`, `pgsql` (SMF checks for `pg_connect`), plus - `mysqli` so the installer still offers MySQL. +- Required: `mbstring`, `fileinfo`, and both `mysqli` and `pgsql` (SMF checks + for `pg_connect`), so either engine can be chosen at install time. - Recommended: `gd`, `intl`, `curl`, `exif`, `ftp`, `xsl`, and `zip`. -- `standard_conforming_strings` is set `on` at database level, as SMF requires. +- Both database command line clients, for the `docker compose exec` recipes + above and for the entrypoint's readiness check. - `mail()` is routed through msmtp into Mailpit, so no mail can escape the machine. +Engine settings SMF asks for are pinned at server level rather than left to the +image defaults: + +- PostgreSQL: `standard_conforming_strings = on`, as `requirements.md` requires. +- MySQL: `utf8mb4` and InnoDB, matching SMF's own table DDL. The collation is + deliberately left at the charset default, because SMF sets `CHARSET` without + `COLLATE`; forcing a different one here would diverge from the tables it + creates. + ## Files ``` @@ -100,6 +142,7 @@ compose.yaml the stack .docker/php/vhost.conf apache vhost .docker/php/msmtprc mail() -> mailpit .docker/php/entrypoint.sh composer install, Settings.php, permissions -.docker/postgres/init/10-smf.sh runs once on first database creation +.docker/mysql/init/10-smf.sh runs once on first mysql database creation +.docker/postgres/init/10-smf.sh runs once on first postgres database creation .docker/env.example optional overrides ``` diff --git a/.docker/env.example b/.docker/env.example index 52db52142d6..31736f2a186 100644 --- a/.docker/env.example +++ b/.docker/env.example @@ -1,17 +1,31 @@ # Copy to the repository root as `.env` to override any of the defaults. # compose.yaml works without this file. +# Which engine the forum runs on: mysql (default) or postgresql. +# Both database services start either way. This only decides what the generated +# Settings.php points at, so it has no effect once the forum is installed -- +# Settings.php wins from then on. +SMF_DB_TYPE=mysql + # Host ports WEB_PORT=8080 ADMINER_PORT=8081 MAILPIT_PORT=8025 +MYSQL_PORT=3307 POSTGRES_PORT=5433 # Versions PHP_VERSION=8.4 +MYSQL_VERSION=8.4 POSTGRES_VERSION=17-alpine -# Database credentials (dev only) -POSTGRES_DB=smf -POSTGRES_USER=smf -POSTGRES_PASSWORD=smf +# Database credentials (dev only). Shared by both engines so that switching +# SMF_DB_TYPE needs no other change. +DB_NAME=smf +DB_USER=smf +DB_PASSWORD=smf +DB_ROOT_PASSWORD=smf + +# Which service Adminer pre-fills in its server field. Service name, not engine +# name: mysql or postgres. +ADMINER_SERVER=mysql diff --git a/.docker/mysql/init/10-smf.sh b/.docker/mysql/init/10-smf.sh new file mode 100644 index 00000000000..6f1589bdaa5 --- /dev/null +++ b/.docker/mysql/init/10-smf.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Runs once, on first initialisation of the mysql data volume. +set -eu + +# SMF creates its own tables as InnoDB/utf8mb4, but the database's own default is +# what anything created outside that path inherits. Pinning it means it cannot +# drift out from under the forum, the same reason the postgres side pins +# standard_conforming_strings. +# +# The collation is left to whatever utf8mb4 defaults to on this server, because +# that is what SMF's tables get: its DDL sets CHARSET but never COLLATE. +mysql --protocol=socket -uroot -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL + ALTER DATABASE \`${MYSQL_DATABASE}\` CHARACTER SET utf8mb4; +EOSQL + +echo "[smf-dev] database ${MYSQL_DATABASE} initialised" diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile index a9588f1df5d..fcfb0ed41dd 100644 --- a/.docker/php/Dockerfile +++ b/.docker/php/Dockerfile @@ -29,6 +29,7 @@ RUN install-php-extensions \ msmtp \ msmtp-mta \ postgresql-client \ + default-mysql-client \ && rm -rf /var/lib/apt/lists/* # Route mail() at Mailpit so outgoing forum mail is captured, never sent. diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh index 676ed275c00..1609dcc31ee 100644 --- a/.docker/php/entrypoint.sh +++ b/.docker/php/entrypoint.sh @@ -21,22 +21,53 @@ if [ ! -f "$BOARD_DIR/vendor/autoload.php" ]; then fi # ------------------------------------------------------------------- database -log "waiting for postgres at ${SMF_DB_SERVER}:${SMF_DB_PORT}" -until pg_isready -h "$SMF_DB_SERVER" -p "$SMF_DB_PORT" -U "$SMF_DB_USER" -q; do - sleep 1 -done -log 'postgres is accepting connections' +# SMF_DB_TYPE picks the engine. Both are running; only the one the forum is +# pointed at gets waited for and written into Settings.php. +case "${SMF_DB_TYPE:-mysql}" in + mysql|mysqli|mariadb) + DB_TYPE=mysql + DB_SERVER="${SMF_MYSQL_SERVER:-mysql}" + DB_PORT="${SMF_MYSQL_PORT:-3306}" + ;; + + postgresql|postgres|pgsql) + DB_TYPE=postgresql + DB_SERVER="${SMF_POSTGRES_SERVER:-postgres}" + DB_PORT="${SMF_POSTGRES_PORT:-5432}" + ;; + + *) + log "SMF_DB_TYPE='${SMF_DB_TYPE}' is not a type SMF supports (mysql, postgresql)" + exit 1 + ;; +esac + +log "waiting for ${DB_TYPE} at ${DB_SERVER}:${DB_PORT}" + +if [ "$DB_TYPE" = 'postgresql' ]; then + until pg_isready -h "$DB_SERVER" -p "$DB_PORT" -U "$SMF_DB_USER" -q; do + sleep 1 + done +else + # Any answer at all means the server is listening; this deliberately does + # not authenticate, so it works before the init scripts have finished. + until mysqladmin ping -h "$DB_SERVER" -P "$DB_PORT" --silent >/dev/null 2>&1; do + sleep 1 + done +fi + +log "${DB_TYPE} is accepting connections" # --------------------------------------------------------------- installer bits # Settings.php redirects to install.php whenever install.php is present, so both # files only get placed while the forum has not been installed yet. if [ ! -f "$BOARD_DIR/Settings.php" ]; then - log 'generating Settings.php pre-filled for the postgres service' + log "generating Settings.php pre-filled for the ${DB_TYPE} service" sed \ - -e "s|^\$db_type = 'mysql';|\$db_type = 'postgresql';|" \ - -e "s|^\$db_port = 0;|\$db_port = ${SMF_DB_PORT};|" \ - -e "s|^\$db_server = 'localhost';|\$db_server = '${SMF_DB_SERVER}';|" \ + -e "s|^\$db_type = 'mysql';|\$db_type = '${DB_TYPE}';|" \ + -e "s|^\$db_port = 0;|\$db_port = ${DB_PORT};|" \ + -e "s|^\$db_server = 'localhost';|\$db_server = '${DB_SERVER}';|" \ -e "s|^\$db_name = 'smf';|\$db_name = '${SMF_DB_NAME}';|" \ -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ @@ -47,6 +78,17 @@ if [ ! -f "$BOARD_DIR/Settings.php" ]; then cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php" log "installer ready -- open ${SMF_BOARDURL}/install.php" +else + # Already installed, and Settings.php wins over SMF_DB_TYPE. Say so rather + # than leaving someone wondering why switching the variable did nothing. + # The installer writes this back with its own capitalisation ('PostgreSQL'), + # so compare case-insensitively or the note fires on every restart. + installed_type=$(sed -n "s|^\\\$db_type = '\\([^']*\\)';.*|\\1|p" "$BOARD_DIR/Settings.php" | head -n 1 | tr '[:upper:]' '[:lower:]') + + if [ -n "$installed_type" ] && [ "$installed_type" != "$DB_TYPE" ]; then + log "note: Settings.php is installed against ${installed_type}, not ${DB_TYPE}" + log ' to move, delete Settings.php and Settings_bak.php, then restart' + fi fi [ -f "$BOARD_DIR/Settings_bak.php" ] || cp "$BOARD_DIR/Settings.php" "$BOARD_DIR/Settings_bak.php" diff --git a/compose.yaml b/compose.yaml index be97189db49..cac7f51584f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,8 +1,11 @@ -# SMF development environment (PostgreSQL). +# SMF development environment. # # docker compose up -d --build # -> http://localhost:8080/install.php # +# Both database engines SMF supports are running. MySQL is what the forum is +# pointed at by default; set SMF_DB_TYPE=postgresql in .env to use the other. +# # Everything below has a working default, so no .env file is required. Copy # .docker/env.example to .env if you want to change ports or credentials. @@ -21,33 +24,76 @@ services: # The checkout is bind-mounted, so edits on the host are live immediately. - .:/var/www/html environment: - SMF_DB_SERVER: db - SMF_DB_PORT: "5432" - SMF_DB_NAME: ${POSTGRES_DB:-smf} - SMF_DB_USER: ${POSTGRES_USER:-smf} - SMF_DB_PASSWD: ${POSTGRES_PASSWORD:-smf} + # Which engine the generated Settings.php points at: mysql or postgresql. + SMF_DB_TYPE: ${SMF_DB_TYPE:-mysql} + SMF_DB_NAME: ${DB_NAME:-smf} + SMF_DB_USER: ${DB_USER:-smf} + SMF_DB_PASSWD: ${DB_PASSWORD:-smf} + # Per-engine host and port, so switching SMF_DB_TYPE is the only change + # needed. These are container-internal, not the host ports below. + SMF_MYSQL_SERVER: mysql + SMF_MYSQL_PORT: "3306" + SMF_POSTGRES_SERVER: postgres + SMF_POSTGRES_PORT: "5432" SMF_BOARDURL: http://localhost:${WEB_PORT:-8080} depends_on: - db: + mysql: + condition: service_healthy + postgres: condition: service_healthy restart: unless-stopped - db: + mysql: + image: mysql:${MYSQL_VERSION:-8.4} + ports: + # Exposed on 3307 by default so it cannot collide with a local mysql. + - "${MYSQL_PORT:-3307}:3306" + environment: + MYSQL_DATABASE: ${DB_NAME:-smf} + MYSQL_USER: ${DB_USER:-smf} + MYSQL_PASSWORD: ${DB_PASSWORD:-smf} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-smf} + # SMF creates its tables as InnoDB/utf8mb4, so match that at server level + # rather than relying on whichever defaults the image ships with. The + # collation is deliberately left to the charset default: SMF's own DDL says + # `DEFAULT CHARSET=utf8mb4` with no COLLATE, so forcing a different one here + # would only apply to objects SMF did not create, and the two would diverge. + command: + - --character-set-server=utf8mb4 + - --default-storage-engine=InnoDB + volumes: + - mysql-data:/var/lib/mysql + - ./.docker/mysql/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 --silent"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 20s + restart: unless-stopped + + postgres: image: postgres:${POSTGRES_VERSION:-17-alpine} + # Also reachable as `db`, which is what installs made before MySQL was + # added have in their Settings.php. + networks: + default: + aliases: + - db ports: # Exposed on 5433 by default so it cannot collide with a local postgres. - "${POSTGRES_PORT:-5433}:5432" environment: - POSTGRES_DB: ${POSTGRES_DB:-smf} - POSTGRES_USER: ${POSTGRES_USER:-smf} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-smf} + POSTGRES_DB: ${DB_NAME:-smf} + POSTGRES_USER: ${DB_USER:-smf} + POSTGRES_PASSWORD: ${DB_PASSWORD:-smf} # Dev-only: skip the password prompt cost for local connections. POSTGRES_HOST_AUTH_METHOD: scram-sha-256 volumes: - db-data:/var/lib/postgresql/data - ./.docker/postgres/init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-smf} -d ${POSTGRES_DB:-smf}"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-smf} -d ${DB_NAME:-smf}"] interval: 3s timeout: 3s retries: 20 @@ -60,17 +106,22 @@ services: - "${MAILPIT_PORT:-8025}:8025" restart: unless-stopped - # Browse and query the database at http://localhost:8081 + # Browse and query either database at http://localhost:8081. The server field + # is pre-filled with whichever engine the forum is using; type the other + # service name in to look at it instead. adminer: image: adminer:latest ports: - "${ADMINER_PORT:-8081}:8080" environment: - ADMINER_DEFAULT_SERVER: db + # Service name, not engine name: mysql or postgres. + ADMINER_DEFAULT_SERVER: ${ADMINER_SERVER:-mysql} ADMINER_DESIGN: dracula depends_on: - - db + - mysql + - postgres restart: unless-stopped volumes: db-data: + mysql-data: From d4f484cc343c23d6c49362b2338d3ff2a1b501f8 Mon Sep 17 00:00:00 2001 From: albertlast Date: Thu, 30 Jul 2026 22:55:32 +0200 Subject: [PATCH 3/4] Documents the postgres log as the way to debug SQL errors PostgreSQL logs every statement that errors together with the SQL that caused it, with no configuration needed, and the log is only on the container stderr. That makes `docker compose logs postgres` the most useful debugging tool in the stack, and nothing said so. MySQL logs server errors only, never the client statement that failed, so the note points out the asymmetry: now that mysql is the default engine, a suspected SQL problem is worth reproducing on postgres. Co-Authored-By: Claude Opus 5 --- .docker/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.docker/README.md b/.docker/README.md index 43ed7bef21c..2ba2f277fb3 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -81,6 +81,7 @@ exists, `Settings.php` redirects every request back into the installer. ```sh docker compose logs -f web # apache + php errors, live +docker compose logs -f postgres # every failing query, with its SQL docker compose exec web bash # shell in the web container docker compose exec mysql mysql -usmf -psmf smf # mysql client @@ -104,6 +105,30 @@ never need to restart for a PHP change. To reinstall from scratch: `docker compose down -v`, delete `Settings.php` and `Settings_bak.php`, then `docker compose up -d`. +## Debugging SQL with the PostgreSQL log + +The `postgres` log is the best tool in the stack for tracking down a broken +query. PostgreSQL logs every statement that errors together with the SQL that +caused it, always and without any configuration: + +``` +2026-01-01 12:00:00.000 UTC [98] ERROR: relation "nope" does not exist at character 15 +2026-01-01 12:00:00.000 UTC [98] STATEMENT: select * from nope; +``` + +Nothing is written to a file inside the container, so the compose log above is +where to look. Add `--since 5m` to it to skip past the startup noise. + +MySQL has no equivalent: it logs server errors only, never the client statement +that failed, so a query SMF gets wrong leaves no trace in its log. Since MySQL +is the default engine, a suspected SQL problem is worth reproducing against +PostgreSQL — install on it once and you can switch back and forth, because each +database keeps its own forum. + +Only failing statements are logged. Successful ones, timings and connections +are not, so this shows you the queries that break, not the ones that merely +return the wrong thing. + ## Configuration `compose.yaml` works with no `.env` file. To change ports, versions, the engine From ed38bc231d5f51b0151b21308292707aecfd1abb Mon Sep 17 00:00:00 2001 From: albertlast Date: Mon, 24 Aug 2026 09:06:26 +0200 Subject: [PATCH 4/4] Stops the entrypoint from naming the forum The generated Settings.php had $mbname rewritten to 'SMF Dev', which never reached the finished forum. The installer's forum-name field does not read Settings.php -- it defaults to the 'install_settings_name_default' string and writes $_POST['mbname'] back unconditionally -- so a browser install always ends up with 'My Community' whatever was written here, and before the install finishes Settings.php only redirects to install.php, so the name is never on screen either. The line was write-only. Signed-off-by: albertlast --- .docker/php/entrypoint.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/.docker/php/entrypoint.sh b/.docker/php/entrypoint.sh index 1609dcc31ee..05d37fe70bb 100644 --- a/.docker/php/entrypoint.sh +++ b/.docker/php/entrypoint.sh @@ -72,7 +72,6 @@ if [ ! -f "$BOARD_DIR/Settings.php" ]; then -e "s|^\$db_user = 'root';|\$db_user = '${SMF_DB_USER}';|" \ -e "s|^\$db_passwd = '';|\$db_passwd = '${SMF_DB_PASSWD}';|" \ -e "s|^\$boardurl = 'http://127.0.0.1/smf';|\$boardurl = '${SMF_BOARDURL}';|" \ - -e "s|^\$mbname = 'My Community';|\$mbname = 'SMF Dev';|" \ "$BOARD_DIR/other/Settings.php" > "$BOARD_DIR/Settings.php" cp "$BOARD_DIR/other/install.php" "$BOARD_DIR/install.php"