Add a lightweight schema migration runner, fix missing PHPMailer in prod image

docker-entrypoint-initdb.d only applies db/init.sql, and only on a brand new
volume - existing installs never got db/migrations/*.sql applied unless
someone ran them by hand. This bit twice this session (qr-oss's own init.sql
was missing a migration, and qr-vip's live DB was two migrations behind after
a code deploy). scripts/migrate.php now runs automatically on every container
start via a new entrypoint wrapper, applying any not-yet-recorded migration -
safe to run repeatedly since every migration file already checks
information_schema before altering.

Also caught in the process: Dockerfile.fpm (the production image) never
installed phpmailer/phpmailer, unlike the dev Dockerfile - meaning
self-registration's password email has been silently fatal-erroring in
production since the nginx+php-fpm switch.
This commit is contained in:
2026-07-15 01:19:47 +02:00
parent d169b85f54
commit 31290517ba
6 changed files with 102 additions and 6 deletions
+9
View File
@@ -97,11 +97,20 @@ WORKDIR /var/www/html
RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chmod 755 *;
# Qr code storage lives outside the document root so files can only be reached through
# the authenticated qrcode_image.php / qrcode_zip_download.php endpoints.
RUN mkdir -p /var/www/qrcode-storage/zip && chmod -R 777 /var/www/qrcode-storage
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
# on a brand new volume, so without this an existing install's schema silently falls
# behind the code on every `git pull` + restart.
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
EXPOSE 80
CMD ["php", "-S", "0.0.0.0:80"]
+10
View File
@@ -51,7 +51,9 @@ RUN cp -R ./php-qrcode/src /var/www/html/
WORKDIR /var/www/html
RUN composer update
RUN composer require phpmailer/phpmailer:^6.9
COPY ./src ./
COPY ./db/migrations ./db/migrations
RUN chown -R www-data:www-data /var/www/html \
&& find /var/www/html -type f -exec chmod 644 {} \; \
&& find /var/www/html -type d -exec chmod 755 {} \;
@@ -62,5 +64,13 @@ RUN mkdir -p /var/www/qrcode-storage/zip \
&& chown -R www-data:www-data /var/www/qrcode-storage \
&& chmod -R 775 /var/www/qrcode-storage
# Applies any not-yet-applied db/migrations/*.sql on every container start (see
# src/scripts/migrate.php) - docker-entrypoint-initdb.d only runs db/init.sql, and only
# on a brand new volume, so without this an existing install's schema silently falls
# behind the code on every `git pull` + restart.
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
EXPOSE 9000
CMD ["php-fpm"]
+2 -1
View File
@@ -43,7 +43,8 @@ project by Giandonato Inverso, built on [AdminLTE](https://adminlte.io/).
# What is included
- PHP 8.4 application source
- Database schema + migrations
- Database schema + migrations (applied automatically on every container start,
so `git pull` + restart is enough to bring an existing install up to date)
- Docker Compose files (dev and production)
- CSS/JS assets
+3 -5
View File
@@ -16,11 +16,9 @@ services:
DATABASE_PREFIX: "${DATABASE_PREFIX:-}"
DATABASE_CHARSET: "${DATABASE_CHARSET:-utf8}"
ALLOW_SELF_REGISTRATION: "${ALLOW_SELF_REGISTRATION:-false}"
# This file is also what vm420 runs in production (not docker-compose.prod.yml -
# see the "prod" naming is misleading, this is the actually-deployed one). Defaults
# here are dev-convenience only (mailhog, no auth) - a real deployment's .env must
# override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD explicitly, same as
# DATABASE_PASSWORD above already requires.
# Defaults here are dev-convenience only (mailhog, no auth) - a real deployment's
# .env must override MAIL_HOST/MAIL_SMTP_AUTH/MAIL_USERNAME/MAIL_PASSWORD
# explicitly, same as DATABASE_PASSWORD above already requires.
MAIL_HOST: "${MAIL_HOST:-mailhog}"
MAIL_PORT: "${MAIL_PORT:-1025}"
MAIL_ENCRYPTION: "${MAIL_ENCRYPTION:-}"
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
php /var/www/html/scripts/migrate.php
exec docker-php-entrypoint "$@"
+72
View File
@@ -0,0 +1,72 @@
<?php
// Lightweight migration runner: applies any db/migrations/*.sql file not yet recorded
// in schema_migrations, in filename order. Every migration file is itself idempotent
// (checks information_schema before altering), so re-running an already-applied file
// is a safe no-op - this script leans on that instead of needing transactional rollback.
// Run automatically by the container entrypoint on every start (see docker/entrypoint.sh),
// so a `git pull` + restart is enough to bring an existing install's schema up to date -
// docker-entrypoint-initdb.d only ever runs db/init.sql, and only on a brand new volume.
require_once __DIR__ . '/../config/environment.php';
mysqli_report(MYSQLI_REPORT_OFF);
$mysqli = new mysqli(DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD, DATABASE_NAME, DATABASE_PORT);
if ($mysqli->connect_errno) {
fwrite(STDERR, "migrate.php: could not connect to database: {$mysqli->connect_error}\n");
exit(1);
}
$mysqli->set_charset(DATABASE_CHARSET);
$mysqli->query(
'CREATE TABLE IF NOT EXISTS schema_migrations (
filename VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8'
);
$files = glob(__DIR__ . '/../db/migrations/*.sql');
sort($files, SORT_STRING);
$applied = [];
$result = $mysqli->query('SELECT filename FROM schema_migrations');
while ($row = $result->fetch_assoc()) {
$applied[$row['filename']] = true;
}
$ran = 0;
foreach ($files as $file) {
$filename = basename($file);
if (isset($applied[$filename])) {
continue;
}
echo "Applying migration: $filename\n";
if (!$mysqli->multi_query(file_get_contents($file))) {
fwrite(STDERR, "migrate.php: failed to apply $filename: {$mysqli->error}\n");
exit(1);
}
// multi_query queues result sets asynchronously - drain them all before the next
// query, and check for a mid-batch error on each one.
do {
if ($res = $mysqli->store_result()) {
$res->free();
}
if ($mysqli->errno) {
fwrite(STDERR, "migrate.php: error while applying $filename: {$mysqli->error}\n");
exit(1);
}
} while ($mysqli->more_results() && $mysqli->next_result());
$stmt = $mysqli->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, NOW())');
$stmt->bind_param('s', $filename);
$stmt->execute();
$stmt->close();
$ran++;
}
echo $ran === 0 ? "No pending migrations.\n" : "Applied $ran migration(s).\n";
$mysqli->close();