entrypoint.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# ==========================================================================
# Flarum 2.0 self-contained entrypoint.
#
# Idempotent: safe to re-run on every container restart. On first boot it
# installs Flarum (composer create-project + flarum install), requires + enables
# the full feature set (uploads, redis, Horizon, realtime, audit, extension
# manager), wires redis cache+queue and (optional) SMTP mail, runs migrations,
# then exec's supervisord. On subsequent boots it skips the install and just
# re-syncs config + migrates.
#
# Config comes entirely from the container env (.env). No network fetch, no
# external secrets, no callbacks.
# ==========================================================================
set -u
log() { echo "[+] $*"; }
warn() { echo "[!] WARNING: $*"; }
die() { echo "[!] FATAL: $*"; exit 1; }
run_as_www() {
if command -v sudo >/dev/null 2>&1; then sudo -u www-data "$@"; else su -s /bin/bash www-data -c "$*"; fi
}
composer_as_www() {
run_as_www env COMPOSER_HOME="$COMPOSER_CACHE_DIR" COMPOSER_MEMORY_LIMIT=-1 composer "$@"
}
# Import a (optionally gzipped) SQL dump into the database.
import_db() {
local f="$1"
log "Importing database dump: $f"
if [ "$DB_DRIVER" = pgsql ]; then
case "$f" in
*.dump) PGPASSWORD="$DB_PASS" pg_restore -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" --no-owner "$f" ;;
*.gz) zcat "$f" | PGPASSWORD="$DB_PASS" psql -q -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" ;;
*) PGPASSWORD="$DB_PASS" psql -q -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" < "$f" ;;
esac
else
case "$f" in
*.gz) zcat "$f" | mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" ;;
*) mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$f" ;;
esac
fi
}
# Write a fresh Flarum config.php from env (used by the restore path, which
# imports the DB dump instead of running `flarum install`).
write_config_php() {
cat > "$CONFIG_FILE" <<PHP
<?php return [
'debug' => false,
'database' => [
'driver' => '${DB_DRIVER}',
'host' => '${DB_HOST}',
'port' => ${DB_PORT},
'database' => '${DB_NAME}',
'username' => '${DB_USER}',
'password' => '${DB_PASS}',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
],
'url' => '${APP_URL}',
'paths' => ['api' => 'api', 'admin' => 'admin'],
];
PHP
chown www-data:www-data "$CONFIG_FILE"
}
# ── Variables ───────────────────────────────────────────────────────────────
WORKDIR="/var/www/html"
CONFIG_FILE="$WORKDIR/config.php"
SUPERVISOR_CONF="/etc/supervisor/conf.d/supervisord.conf"
LOG_DIR="$WORKDIR/storage/logs"
COMPOSER_CACHE_DIR="$WORKDIR/storage/composer-cache"
clean() { echo "$1" | tr -d '\r' | xargs; }
APP_URL=$(clean "${APP_URL:-}")
FORUM_TITLE=$(clean "${FORUM_TITLE:-My Forum}")
ADMIN_USER=$(clean "${ADMIN_USER:-}")
ADMIN_PASS=$(clean "${ADMIN_PASS:-}")
ADMIN_EMAIL=$(clean "${ADMIN_EMAIL:-}")
DB_HOST=$(clean "${DB_HOST:-mariadb}")
DB_NAME=$(clean "${DB_NAME:-flarum}")
DB_USER=$(clean "${DB_USER:-flarum}")
DB_PASS=$(clean "${DB_PASS:-}")
# Which database engine: mariadb (the default) or pgsql. The port defaults per
# driver. DB_ROOT_USER/DB_ROOT_PASS are optional: when given, the database and
# the role above are created with them (needed on Postgres, where an ordinary
# role cannot create its own database; on MariaDB the role usually can).
DB_DRIVER=$(clean "${DB_DRIVER:-mariadb}")
DB_ROOT_PASS=$(clean "${DB_ROOT_PASS:-}")
case "$DB_DRIVER" in
pgsql) DB_PORT=$(clean "${DB_PORT:-5432}"); DB_ROOT_USER=$(clean "${DB_ROOT_USER:-postgres}"); PDO_SCHEME=pgsql ;;
mariadb|mysql) DB_PORT=$(clean "${DB_PORT:-3306}"); DB_ROOT_USER=$(clean "${DB_ROOT_USER:-root}"); PDO_SCHEME=mysql ;;
*) die "DB_DRIVER must be mariadb, mysql or pgsql (got '$DB_DRIVER')." ;;
esac
DB_DSN="$PDO_SCHEME:host=$DB_HOST;port=$DB_PORT"
DB_DSN_DB="$DB_DSN;dbname=$DB_NAME"
if [ "$DB_DRIVER" = pgsql ]; then DB_DSN_ROOT="$DB_DSN;dbname=postgres"; else DB_DSN_ROOT="$DB_DSN"; fi
export DB_HOST DB_NAME DB_USER DB_PASS DB_DRIVER DB_PORT DB_ROOT_USER DB_DSN DB_DSN_DB DB_DSN_ROOT
REDIS_HOST=$(clean "${REDIS_HOST:-valkey}")
REDIS_PORT=$(clean "${REDIS_PORT:-6379}")
REDIS_PASSWORD=$(clean "${REDIS_PASSWORD:-}")
# Whether the operator pinned a version themselves. Checked BEFORE defaulting,
# because the default is exactly what "they said nothing" looks like afterwards.
# Setting TARGET_FLARUM_VERSION is the deliberate opt-out from the baked
# skeleton: it means "go and resolve this from Packagist instead", which is the
# old behaviour and still supported.
TARGET_FLARUM_VERSION_SET=$([ -n "${TARGET_FLARUM_VERSION:-}" ] && echo true || echo false)
TARGET_FLARUM_VERSION=$(clean "${TARGET_FLARUM_VERSION:-^2.0}")
# Flarum 1.x is refused, deliberately and early. The 1.x path this variable
# appeared to offer was broken three independent ways (issue #8):
# • install.yml pins `driver: mariadb`, which 1.8's installer rejects outright,
# so a fresh 1.8 install can never complete
# • the hard-require list carries flarum/realtime and flarum/audit, neither of
# which has a 1.x release, so the boot dies even after a manual install
# • the every-boot config sync rewrites the driver back to mariadb, so even a
# correct 1.8 config.php is clobbered into "Unsupported driver" on restart
# Each needs a different workaround to get past, and 1.8 is near end of life
# regardless. One clear refusal beats three confusing failures in a row.
flarum_major() {
# First run of digits in the constraint: ^1.8, 1.8.*, ~1.8.0 and ">=1.8 <2.0"
# all yield 1. A constraint carrying no digits (dev-main, a branch alias)
# yields nothing and is left alone rather than guessed at.
echo "$1" | grep -oE '[0-9]+' | head -1
}
if [ "$TARGET_FLARUM_VERSION_SET" = "true" ]; then
REQUESTED_MAJOR=$(flarum_major "$TARGET_FLARUM_VERSION")
if [ -n "$REQUESTED_MAJOR" ] && [ "$REQUESTED_MAJOR" -lt 2 ]; then
die "TARGET_FLARUM_VERSION='${TARGET_FLARUM_VERSION}' asks for Flarum ${REQUESTED_MAJOR}.x, which this image does not support.
Flarum 1.x cannot install or boot here (linkrobins/flarum-docker#8), and 1.8 is near end of life.
Use Flarum 2.x (e.g. TARGET_FLARUM_VERSION=^2.0), or unset it to install the version baked into this image."
fi
fi
# Seed from the image unless asked otherwise. The guard on composer.json keeps
# this correct for an image built before the skeleton existed, where the
# directory is simply absent.
FLARUM_SKELETON="${FLARUM_SKELETON:-/opt/flarum-skeleton}"
FLARUM_SKELETON_VERSION="${FLARUM_SKELETON_VERSION:-unknown}"
if [ "$TARGET_FLARUM_VERSION_SET" = "false" ] && [ -f "$FLARUM_SKELETON/composer.json" ]; then
FLARUM_FROM_SKELETON=true
else
FLARUM_FROM_SKELETON=false
fi
REALTIME_ENABLED=$(echo "${REALTIME_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')
# supervisord is exec'd at the end of this script and inherits our environment,
# where its realtime program re-reads REALTIME_ENABLED to decide whether to
# serve or idle. Without the export that check sees an unset variable whenever
# the compose file omits it, so the extension gets enabled and the websocket
# config written while nothing ever listens on :6001.
export REALTIME_ENABLED
# Restore-on-deploy: drop a backup in the mounted /restore dir (database.sql[.gz]
# + optional storage.tar.gz, as produced by backup.sh) and it's imported instead
# of installing fresh. Paths can be overridden via env; otherwise the
# conventional /restore layout is auto-detected.
RESTORE_DB=$(clean "${RESTORE_DB:-}")
RESTORE_FILES=$(clean "${RESTORE_FILES:-}")
RESTORE_FORCE=$(echo "${RESTORE_FORCE:-false}" | tr '[:upper:]' '[:lower:]')
if [ -z "$RESTORE_DB" ]; then
for f in /restore/database.sql.gz /restore/database.sql; do
[ -f "$f" ] && RESTORE_DB="$f" && break
done
fi
[ -z "$RESTORE_FILES" ] && [ -f /restore/storage.tar.gz ] && RESTORE_FILES="/restore/storage.tar.gz"
# Was a forum already installed before this boot?
HAD_CONFIG=false; [ -f "$CONFIG_FILE" ] && HAD_CONFIG=true
# Decide whether to restore this boot:
# • fresh volume (no install) + a backup present -> restore
# • existing forum + RESTORE_FORCE=true + a backup we haven't
# already imported (different sha256) -> restore OVER it
# The consumed-hash marker makes a forced restore one-shot, so a plain restart
# can never silently re-clobber a live forum (only a NEW backup restores again).
DO_RESTORE=false
RESTORE_HASH=""
CONSUMED_FILE="$WORKDIR/storage/.restore_consumed"
if [ -n "$RESTORE_DB" ]; then
RESTORE_HASH=$(sha256sum "$RESTORE_DB" 2>/dev/null | cut -d' ' -f1)
if [ "$HAD_CONFIG" = "false" ]; then
DO_RESTORE=true
elif [ "$RESTORE_FORCE" = "true" ]; then
PREV=$( [ -f "$CONSUMED_FILE" ] && cat "$CONSUMED_FILE" 2>/dev/null || echo "" )
[ "$RESTORE_HASH" != "$PREV" ] && DO_RESTORE=true
fi
fi
[ -n "$APP_URL" ] || die "APP_URL is required."
export COMPOSER_MEMORY_LIMIT=-1
# ── PHP tuning ────────────────────────────────────────────────────────────────
cat > /usr/local/etc/php/conf.d/flarum.ini <<'INI'
memory_limit = 512M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 900
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 24000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
realpath_cache_size = 4096k
realpath_cache_ttl = 600
INI
# php-fpm pool: ondemand workers.
FPM_POOL="/usr/local/etc/php-fpm.d/www.conf"
if [ -f "$FPM_POOL" ]; then
sed -i -e 's/^pm = .*/pm = ondemand/' -e 's/^pm.max_children = .*/pm.max_children = 8/' "$FPM_POOL"
fi
ln -sf /dev/stdout /var/log/nginx/access.log 2>/dev/null || true
ln -sf /dev/stderr /var/log/nginx/error.log 2>/dev/null || true
mkdir -p "$LOG_DIR" "$COMPOSER_CACHE_DIR" /var/log/supervisor
chown -R www-data:www-data "$WORKDIR/storage" 2>/dev/null || true
chmod -R 775 "$WORKDIR/storage" 2>/dev/null || true
# ── Wait for MariaDB + ensure the database exists ─────────────────────────────
log "Waiting for the database ($DB_DRIVER at $DB_HOST:$DB_PORT)..."
if [ -n "$DB_ROOT_PASS" ]; then
# An administrator was given: create the role and the database with it.
for i in $(seq 1 60); do
if DB_ROOT_PASS="$DB_ROOT_PASS" php -r 'new PDO(getenv("DB_DSN_ROOT"), getenv("DB_ROOT_USER"), getenv("DB_ROOT_PASS"));' >/dev/null 2>&1; then break; fi
sleep 2
done
DB_ROOT_PASS="$DB_ROOT_PASS" php -r '
try {
$n = getenv("DB_NAME"); $u = getenv("DB_USER"); $p = getenv("DB_PASS");
$pdo = new PDO(getenv("DB_DSN_ROOT"), getenv("DB_ROOT_USER"), getenv("DB_ROOT_PASS"), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$qp = $pdo->quote($p);
if (getenv("DB_DRIVER") === "pgsql") {
// Role first, then a database it OWNS: on Postgres 15+ only the
// owner may create tables in the public schema.
$qu = "\"" . str_replace("\"", "\"\"", $u) . "\""; $qn = "\"" . str_replace("\"", "\"\"", $n) . "\"";
$has = $pdo->query("SELECT 1 FROM pg_roles WHERE rolname = " . $pdo->quote($u))->fetchColumn();
$pdo->exec($has ? "ALTER ROLE $qu WITH LOGIN PASSWORD $qp" : "CREATE ROLE $qu WITH LOGIN PASSWORD $qp");
$hasDb = $pdo->query("SELECT 1 FROM pg_database WHERE datname = " . $pdo->quote($n))->fetchColumn();
$pdo->exec($hasDb ? "ALTER DATABASE $qn OWNER TO $qu" : "CREATE DATABASE $qn OWNER $qu ENCODING \"UTF8\"");
} else {
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$n` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("CREATE USER IF NOT EXISTS `$u`@`%` IDENTIFIED BY $qp");
$pdo->exec("ALTER USER `$u`@`%` IDENTIFIED BY $qp");
$pdo->exec("GRANT ALL ON `$n`.* TO `$u`@`%`");
$pdo->exec("FLUSH PRIVILEGES");
}
echo "DB + user ready." . PHP_EOL;
} catch (Exception $e) { echo $e->getMessage() . PHP_EOL; exit(1); }
' || die "Could not bootstrap the database and user as $DB_ROOT_USER"
elif [ "$DB_DRIVER" = pgsql ]; then
# No administrator: the database must already exist for this role.
for i in $(seq 1 60); do
if php -r 'new PDO(getenv("DB_DSN_DB"), getenv("DB_USER"), getenv("DB_PASS"));' >/dev/null 2>&1; then break; fi
sleep 2
done
php -r 'new PDO(getenv("DB_DSN_DB"), getenv("DB_USER"), getenv("DB_PASS")); echo "DB ready." . PHP_EOL;' \
|| die "Could not reach database $DB_NAME as $DB_USER (on Postgres, create it first or pass DB_ROOT_USER/DB_ROOT_PASS)"
else
for i in $(seq 1 60); do
if php -r 'new PDO(getenv("DB_DSN"), getenv("DB_USER"), getenv("DB_PASS"));' >/dev/null 2>&1; then break; fi
sleep 2
done
php -r '
try {
$pdo = new PDO(getenv("DB_DSN"), getenv("DB_USER"), getenv("DB_PASS"));
$pdo->exec("CREATE DATABASE IF NOT EXISTS `" . getenv("DB_NAME") . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
echo "DB ready." . PHP_EOL;
} catch (Exception $e) { echo $e->getMessage() . PHP_EOL; exit(1); }
' || die "Could not reach/create the MariaDB database"
fi
# ── Fresh install (only when config.php is absent) ────────────────────────────
if [ ! -f "$CONFIG_FILE" ]; then
if [ "$DO_RESTORE" = "true" ]; then
log "No config.php — RESTORING from backup ($RESTORE_DB)."
else
log "No config.php — fresh Flarum install."
[ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASS" ] && [ -n "$ADMIN_EMAIL" ] \
|| die "ADMIN_USER / ADMIN_PASS / ADMIN_EMAIL are required for a fresh install."
fi
TMP_INSTALL="$WORKDIR/storage/flarum_stage"
rm -rf "$TMP_INSTALL" && mkdir -p "$TMP_INSTALL" && chown www-data:www-data "$TMP_INSTALL"
# Seed from the skeleton baked into the image. No Packagist, no resolution,
# and the same image tag always produces the same forum — which was the
# whole point of issue #2. Falls back to resolving over the network only
# when the operator deliberately asked for a different version, or when the
# image predates the skeleton.
if [ "$FLARUM_FROM_SKELETON" = "true" ]; then
log "Seeding Flarum ${FLARUM_SKELETON_VERSION} from the baked skeleton (no network)."
cp -a "$FLARUM_SKELETON/." "$TMP_INSTALL/" \
|| die "could not copy the baked skeleton from $FLARUM_SKELETON"
chown -R www-data:www-data "$TMP_INSTALL"
else
log "composer create-project flarum/flarum:${TARGET_FLARUM_VERSION}..."
composer_as_www create-project "flarum/flarum:${TARGET_FLARUM_VERSION}" --stability=beta "$TMP_INSTALL" --no-interaction \
>> "$LOG_DIR/composer_install.log" 2>&1 || die "composer create-project failed — see $LOG_DIR/composer_install.log"
fi
cp -an "$TMP_INSTALL/." "$WORKDIR/" && rm -rf "$TMP_INSTALL"
chown -R www-data:www-data "$WORKDIR"
find "$WORKDIR" -type d -exec chmod 775 {} + ; find "$WORKDIR" -type f -exec chmod 664 {} +
chmod -R 775 "$WORKDIR/storage" "$WORKDIR/public/assets"
if [ "$DO_RESTORE" = "true" ]; then
# Restore: import the backup DB instead of running a fresh install, and
# write config.php from env (the dump carries the forum's data/settings).
import_db "$RESTORE_DB" >> "$LOG_DIR/db_restore.log" 2>&1 \
|| die "Database import failed — see $LOG_DIR/db_restore.log"
write_config_php
log "Database restored from backup."
else
cat > "$WORKDIR/install.yml" <<YAML
debug: false
baseUrl: '${APP_URL}'
databaseConfiguration:
driver: ${DB_DRIVER}
host: '${DB_HOST}'
port: ${DB_PORT}
database: '${DB_NAME}'
username: '${DB_USER}'
password: '${DB_PASS}'
prefix: ''
adminUser:
username: '${ADMIN_USER}'
password: '${ADMIN_PASS}'
email: '${ADMIN_EMAIL}'
settings:
forum_title: '${FORUM_TITLE}'
YAML
chown www-data:www-data "$WORKDIR/install.yml"
log "Running Flarum CLI installer..."
cd "$WORKDIR"
run_as_www php flarum install --file=install.yml >> "$LOG_DIR/flarum_install.log" 2>&1 \
|| die "Flarum install failed — see $LOG_DIR/flarum_install.log"
rm -f "$WORKDIR/install.yml"
log "Flarum installed."
fi
fi
# Keep config.php in sync with the DB + base URL on every boot.
if [ -f "$CONFIG_FILE" ]; then
sed -i \
-e "s/'driver' => '[^']*'/'driver' => '${DB_DRIVER}'/g" \
-e "s/'host' => '[^']*'/'host' => '${DB_HOST}'/g" \
-e "s/'port' => [0-9]*/'port' => ${DB_PORT}/g" \
-e "s/'database' => '[^']*'/'database' => '${DB_NAME}'/g" \
-e "s/'username' => '[^']*'/'username' => '${DB_USER}'/g" \
-e "s/'password' => '[^']*'/'password' => '${DB_PASS}'/g" \
-e "s~'url' => '[^']*'~'url' => '${APP_URL}'~g" \
"$CONFIG_FILE"
# The connection options the installer wrote are per engine: a config.php
# born on MariaDB says utf8mb4/InnoDB, which Postgres refuses at connect
# time before Flarum prints a word. Rewrite them for the driver in use so
# moving between engines is an environment change, nothing more.
if [ "$DB_DRIVER" = pgsql ]; then
sed -i -e "s/'charset' => '[^']*'/'charset' => 'utf8'/" -e "/'collation' => /d" -e "/'engine' => /d" "$CONFIG_FILE"
else
sed -i -e "s/'charset' => '[^']*'/'charset' => 'utf8mb4'/" "$CONFIG_FILE"
grep -q "'collation' =>" "$CONFIG_FILE" || sed -i "s/'charset' => 'utf8mb4',/'charset' => 'utf8mb4',\n 'collation' => 'utf8mb4_unicode_ci',\n 'engine' => 'InnoDB',/" "$CONFIG_FILE"
fi
fi
# Forced restore OVER an existing forum (RESTORE_FORCE=true). The fresh-volume
# case is handled in the install branch above; this covers "I already set up the
# site and now want to restore a backup into it." mysqldump's DROP TABLE IF
# EXISTS replaces the tables; the migrate step below reconciles the schema.
if [ "$DO_RESTORE" = "true" ] && [ "$HAD_CONFIG" = "true" ]; then
warn "RESTORE_FORCE: importing the backup OVER the existing forum ($RESTORE_DB)."
import_db "$RESTORE_DB" >> "$LOG_DIR/db_restore.log" 2>&1 \
|| die "Forced database import failed — see $LOG_DIR/db_restore.log"
log "Existing database replaced from backup."
fi
# Restore uploaded files (storage/ + public/assets — avatars, fof/upload files)
# from the backup archive (both the fresh and forced restore paths).
if [ "$DO_RESTORE" = "true" ] && [ -n "$RESTORE_FILES" ] && [ -f "$RESTORE_FILES" ]; then
log "Restoring files from $RESTORE_FILES..."
tar -xzf "$RESTORE_FILES" -C "$WORKDIR" >> "$LOG_DIR/files_restore.log" 2>&1 \
|| warn "File restore failed (non-fatal) — see $LOG_DIR/files_restore.log"
chown -R www-data:www-data "$WORKDIR/storage" "$WORKDIR/public/assets" 2>/dev/null || true
fi
# ── Version drift: what this image ships vs what this volume actually runs ────
# The baked skeleton only ever seeds an EMPTY volume (see the install branch
# above), so an existing forum keeps whatever flarum/core it was installed with,
# including a version an admin later upgraded to through the Extension Manager,
# which this image never hears about. Pulling a new image tag therefore does not
# move a live forum — deliberately, see issue #7 — and until now nothing said so,
# which left the tag describing a Flarum the operator was not running.
#
# This reports, and never acts. Upgrading stays a deliberate act.
#
# Read before the hard-require block below, so the number reflects what the
# volume booted with rather than anything this same boot goes on to install.
installed_core_version() {
php -r '
$f = $argv[1];
if (! is_readable($f)) exit(1);
$lock = json_decode(file_get_contents($f), true);
foreach (($lock["packages"] ?? []) as $p) {
if (($p["name"] ?? "") === "flarum/core") { echo ltrim($p["version"], "v"); exit; }
}
exit(1);
' "$1" 2>/dev/null
}
VERSION_STATUS_FILE="$WORKDIR/storage/.flarum_version"
INSTALLED_CORE=$(installed_core_version "$WORKDIR/composer.lock") || INSTALLED_CORE=""
[ -n "$INSTALLED_CORE" ] || INSTALLED_CORE="unknown"
if [ "$INSTALLED_CORE" = "unknown" ] || [ "$FLARUM_SKELETON_VERSION" = "unknown" ]; then
VERSION_DRIFT="unknown"
log "Flarum core: running '${INSTALLED_CORE}', image ships '${FLARUM_SKELETON_VERSION}' (not comparable)."
elif [ "$INSTALLED_CORE" = "$FLARUM_SKELETON_VERSION" ]; then
VERSION_DRIFT="false"
log "Flarum core ${INSTALLED_CORE} matches the version baked into this image."
else
VERSION_DRIFT="true"
warn "VERSION DRIFT: this forum runs Flarum ${INSTALLED_CORE}, this image ships ${FLARUM_SKELETON_VERSION}."
warn " Pulling an image never upgrades an existing forum. The tag describes what a FRESH install"
warn " would get, not what is running here. Nothing is broken; they are simply not the same."
warn " To upgrade deliberately, back up first, then update through composer:"
warn " docker compose exec flarum backup.sh"
warn " docker compose exec -u www-data flarum composer update 'flarum/*' --with-all-dependencies"
warn " docker compose exec -u www-data flarum php flarum migrate"
warn " docker compose exec -u www-data flarum php flarum cache:clear"
warn " Update 'flarum/*', not flarum/core alone: the skeleton requires every bundled extension"
warn " at '*', so a core-only update leaves all of them on the version they were installed at."
warn " (or use the Extension Manager in admin). Background: linkrobins/flarum-docker#7"
fi
printf 'installed=%s\nimage=%s\ndrift=%s\n' \
"$INSTALLED_CORE" "$FLARUM_SKELETON_VERSION" "$VERSION_DRIFT" > "$VERSION_STATUS_FILE" 2>/dev/null || true
chown www-data:www-data "$VERSION_STATUS_FILE" 2>/dev/null || true
# ── Required extensions (hard-require — fail the boot on a composer error) ─────
composer_require() {
local pkg="$1"
if ! grep -q "\"$pkg\"" "$WORKDIR/composer.json" 2>/dev/null; then
log "Requiring $pkg..."
composer_as_www require "${pkg}:*" --no-interaction \
>> "$LOG_DIR/composer_require_${pkg//\//_}.log" 2>&1 \
|| die "$pkg require failed — see $LOG_DIR/composer_require_${pkg//\//_}.log"
else
log "$pkg already present."
fi
}
composer_require "fof/upload"
composer_require "fof/redis"
composer_require "fof/horizon"
composer_require "flarum/realtime"
# audit + extension-manager may ship with flarum/flarum already; require is a
# no-op if present, and guarantees they exist otherwise.
composer_require "flarum/audit"
composer_require "flarum/extension-manager"
# ── extend.php: redis cache/queue (fof/redis) + Horizon worker config ─────────
# fof/redis points Flarum's cache + queue at redis; Horizon manages the workers.
REDIS_PASS_PHP=$( [ -n "$REDIS_PASSWORD" ] && echo "'${REDIS_PASSWORD}'" || echo "null" )
cat > "$WORKDIR/extend.php" <<EPHP
<?php
error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED);
use Flarum\Extend;
use FoF\Horizon\Extend\Horizon;
return [
new FoF\Redis\Extend\Redis([
'client' => 'phpredis',
'host' => '${REDIS_HOST}',
'password' => ${REDIS_PASS_PHP},
'port' => ${REDIS_PORT},
'database' => 1,
]),
(new Horizon())->environment([
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['digest', 'default'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 2,
'processes' => 2,
'tries' => 3,
'memory' => 128,
],
]),
];
EPHP
chown www-data:www-data "$WORKDIR/extend.php"
log "extend.php written (redis + horizon)."
# ── Realtime websocket config (port 6001) ─────────────────────────────────────
# Honour APP_URL's scheme: an https base URL implies a TLS-terminating proxy in
# front, so the JS client must connect securely on 443; otherwise plain on 80.
if [ "$REALTIME_ENABLED" = "true" ]; then
log "Applying realtime websocket config..."
DOMAIN_ONLY=$(echo "$APP_URL" | sed 's~https\?://~~' | sed 's~/.*~~')
case "$APP_URL" in
https://*) JS_PORT=443; JS_SECURE=true ;;
*) JS_PORT=80; JS_SECURE=false ;;
esac
php -r "
\$cfg = include '$CONFIG_FILE';
\$cfg['websocket'] = [
'server-host' => '0.0.0.0', 'server-port' => 6001,
'js-client-host' => '$DOMAIN_ONLY', 'js-client-port' => $JS_PORT,
'js-client-secure' => $JS_SECURE,
'php-client-host' => '127.0.0.1', 'php-client-port' => 6001,
'php-client-scheme' => 'http', 'php-client-secure' => false,
];
file_put_contents('$CONFIG_FILE', '<?php return ' . var_export(\$cfg, true) . ';');
echo 'WebSocket config written.' . PHP_EOL;
" || warn "WebSocket config failed — continuing"
fi
# ── Mail: generic SMTP from env (optional) ────────────────────────────────────
if [ -n "${MAIL_HOST:-}" ]; then
log "Writing SMTP mail settings..."
MAIL_PORT=$(clean "${MAIL_PORT:-587}")
MAIL_USERNAME=$(clean "${MAIL_USERNAME:-}")
MAIL_PASSWORD_VAL="${MAIL_PASSWORD:-}"
MAIL_ENCRYPTION=$(clean "${MAIL_ENCRYPTION:-tls}")
MAIL_FROM=$(clean "${MAIL_FROM:-$ADMIN_EMAIL}")
MAIL_HOST_VAL=$(clean "${MAIL_HOST}")
MAIL_HOST_VAL="$MAIL_HOST_VAL" MAIL_PORT="$MAIL_PORT" MAIL_USERNAME="$MAIL_USERNAME" \
MAIL_PASSWORD_VAL="$MAIL_PASSWORD_VAL" MAIL_ENCRYPTION="$MAIL_ENCRYPTION" MAIL_FROM="$MAIL_FROM" \
DB_HOST="$DB_HOST" DB_NAME="$DB_NAME" DB_USER="$DB_USER" DB_PASS="$DB_PASS" php -r '
require "/usr/local/bin/lr-db.php";
$s = $pdo->prepare(SETTINGS_UPSERT);
foreach ([
"mail_driver" => "smtp",
"mail_host" => getenv("MAIL_HOST_VAL"),
"mail_port" => getenv("MAIL_PORT"),
"mail_encryption" => getenv("MAIL_ENCRYPTION"),
"mail_username" => getenv("MAIL_USERNAME"),
"mail_password" => getenv("MAIL_PASSWORD_VAL"),
"mail_from" => getenv("MAIL_FROM"),
] as $k => $v) $s->execute([$k, $v]);
echo "Mail settings saved." . PHP_EOL;
' || warn "Failed to write mail settings"
fi
# ── Migrate + enable extensions ───────────────────────────────────────────────
log "Running Flarum migrations..."
run_as_www php "$WORKDIR/flarum" migrate >> "$LOG_DIR/flarum_migrate.log" 2>&1 \
|| die "Flarum migration failed — see $LOG_DIR/flarum_migrate.log"
enable_ext() {
log "Enabling extension: $1"
run_as_www php "$WORKDIR/flarum" extension:enable "$1" 2>/dev/null || warn "Could not enable $1"
}
enable_ext "fof-upload"
enable_ext "fof-redis"
enable_ext "fof-horizon"
enable_ext "flarum-audit"
enable_ext "flarum-extension-manager"
[ "$REALTIME_ENABLED" = "true" ] && enable_ext "flarum-realtime"
# Route the queue through redis (Horizon picks it up).
php -r '
require "/usr/local/bin/lr-db.php";
$pdo->prepare(SETTINGS_UPSERT)->execute(["queue_driver", "redis"]);
' || warn "Failed to set queue driver"
log "Clearing Flarum cache..."
run_as_www php "$WORKDIR/flarum" cache:clear >> "$LOG_DIR/cache_clear.log" 2>&1 || warn "cache:clear failed (non-fatal)"
# Mark this backup consumed so a forced restore is one-shot: a plain restart
# won't re-import it (only a different backup will). Cleared with the volume.
if [ "$DO_RESTORE" = "true" ] && [ -n "$RESTORE_HASH" ]; then
echo "$RESTORE_HASH" > "$CONSUMED_FILE" 2>/dev/null || true
chown www-data:www-data "$CONSUMED_FILE" 2>/dev/null || true
fi
# ── Flarum scheduler cron (runs as www-data) ──────────────────────────────────
( crontab -u www-data -l 2>/dev/null | grep -v "schedule:run"
echo "* * * * * /usr/local/bin/php /var/www/html/flarum schedule:run >> /dev/null 2>&1"
) | crontab -u www-data -
log "All done. Starting supervisord."
exec /usr/bin/supervisord -c "$SUPERVISOR_CONF"