Some VPS configs can't get pdo_sqlite at all — Ubuntu 20.04 with Ondrej Sury's PHP 8.3 builds doesn't ship php8.3-sqlite3, and OS upgrade isn't always an option. Make MySQL the documented default while keeping SQLite working where it's available. - Schema (0001_initial.sql): TEXT → VARCHAR(N), INTEGER → BIGINT, keys declared with explicit PRIMARY KEY (...) syntax. Drops the previously unused request_log table (it had AUTO_INCREMENT, which spells differently on each engine and nothing wrote to it anyway). Both engines accept the new column types and indexes are IF NOT EXISTS for retry-safety. - Migrations.php: guard commit()/rollBack() with inTransaction(). MySQL implicitly commits any open transaction the moment it sees a DDL statement, so by the time we explicitly commit() the transaction is already gone and PDO throws "There is no active transaction". Same schema in PHP CREATE TABLE migrations also moved to VARCHAR/BIGINT. - Store::upsertStep: driver-detect via PDO::ATTR_DRIVER_NAME and emit ON DUPLICATE KEY UPDATE for MySQL, ON CONFLICT (...) DO UPDATE for SQLite/PostgreSQL. VALUES(col) (vs new.col aliasing) for MySQL 5.7 compatibility. - Db.php: when DSN is mysql:, SET NAMES utf8mb4 + sql_mode strict on every session so we get sane behaviour regardless of server defaults. SQLite branch (PRAGMA foreign_keys/journal_mode/synchronous) unchanged. - config.php.example: MySQL DSN is now the default + an inline SQLite alternative block. - DEPLOY.md: new "Database — MySQL or SQLite" section explaining when to pick which and showing the CREATE DATABASE / CREATE USER / GRANT statements. Install snippet split so SQLite-only steps (mkdir data, chmod 770) are clearly optional. Verified end-to-end on a live MySQL 8.0.34 box: POST creates session (201), PUT step inserts (200) and updates via the upsert branch (200), GET returns the round-tripped state, /sites lists distinct site_keys. SQLite path still re-applies the migration idempotently locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.5 KiB
Deploying the ATT Healthcheck Server
Single-tenant PHP/SQLite app. Tiny — runs comfortably on the smallest VPS tier.
Requirements
-
PHP 8.1+ is required. The code uses typed properties,
str_starts_with,mixed/neverreturn types, named arguments, and constructor property promotion. PHP 7.x will not even parse the source files. PHP 7.2 has also been EOL since November 2020 — don't run a public service on it.Confirm with
php -v. If the box is on an older default, install a current PHP alongside it (Ondrej Sury's repo is the standard on Debian/Ubuntu) and point this vhost at the new FPM socket. -
PHP extensions:
pdo,pdo_sqlite,json,mbstring(all standard). -
Apache +
mod_rewrite, or nginx with atry_filesfallback. -
HTTPS terminating in front of PHP. Plain HTTP is technically accepted, but the plugin client will refuse to talk to non-
https://endpoints.
Layout on the server
Anywhere works — every path inside the app is resolved relative to its own files.
Example using /home/www/healthcheck:
/home/www/healthcheck/
├── public/ ← docroot
│ ├── .htaccess
│ └── index.php
├── src/
├── migrations/
├── data/ ← SQLite file lives here (writable by web user)
├── config.php ← deployment-specific, NOT in git
└── config.php.example
Perm gotcha for
/home/...locations: home directories often default to750or700, which blocks the web user from traversing into the app.sudo chmod 755 /home/wwwonce is usually enough./var/wwwdoesn't have this problem (always world-readable by default).
Database — MySQL or SQLite
The code runs against either MySQL 8.0+ or SQLite 3.24+. The config.php.example
defaults to MySQL because the php-sqlite3 extension is unmaintained
on some older distributions (Ubuntu 20.04 + Ondrej Sury's PHP 8.3 build is
the case that bit us — the SQLite extension is no longer packaged for that
combination, and you can't upgrade the OS without disturbing other live
services).
MySQL setup
As root on the DB server (root via socket auth works on a stock Ubuntu MySQL):
CREATE DATABASE att_hc DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'att_hc'@'localhost' IDENTIFIED BY 'STRONG_RANDOM_PASSWORD';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, ALTER, REFERENCES
ON att_hc.* TO 'att_hc'@'localhost';
FLUSH PRIVILEGES;
The grant is scoped to att_hc.* only — the user can't see or touch other
databases on a shared box. Generate the password with:
php -r 'echo bin2hex(random_bytes(18)), PHP_EOL;'
Then in config.php:
'db_dsn' => 'mysql:host=localhost;dbname=att_hc;charset=utf8mb4',
'db_user' => 'att_hc',
'db_pass' => 'STRONG_RANDOM_PASSWORD',
The schema is created on the first authenticated request (the migration runner runs CREATE TABLE IF NOT EXISTS for everything). You can pre-trigger it by hitting any authenticated endpoint, e.g.:
curl -s -H "Authorization: Bearer <api_key>" \
https://healthcheck.example.com/sites?limit=1
After which SHOW TABLES IN att_hc; should list healthchecks,
step_updates, migrations.
SQLite setup (if pdo_sqlite is available)
Set in config.php:
'db_dsn' => 'sqlite:' . __DIR__ . '/data/att_hc.sqlite',
'db_user' => null,
'db_pass' => null,
The DB file is created automatically inside data/ on first request, so
that directory needs to be writable by the web user. See the install
snippet below for the mkdir + chmod 770 step.
First-time install
# As you, on the server:
sudo mkdir -p /home/www/healthcheck
sudo chown $USER:www-data /home/www/healthcheck
sudo chmod 755 /home/www # see perm gotcha above
# From your dev box:
rsync -avz --exclude='data/' --exclude='config.php' \
~/dev/att-site-healthcheck/server/ \
user@vps:/home/www/healthcheck/
# Back on the server:
cd /home/www/healthcheck
cp config.php.example config.php
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate api_key
$EDITOR config.php # paste it in + DB creds
# Only needed for the SQLite backend — MySQL skips this:
mkdir -p data
sudo chown -R www-data:www-data data/
sudo chmod 770 data/
sudo chown www-data:www-data config.php
sudo chmod 640 config.php
Apache vhost (example)
<VirtualHost *:443>
ServerName healthcheck-history.example.com
DocumentRoot /home/www/healthcheck/public
<Directory /home/www/healthcheck/public>
AllowOverride All
Require all granted
</Directory>
# Pass Authorization header through to PHP — Apache strips it by default with FPM.
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem
</VirtualHost>
nginx (example)
server {
listen 443 ssl http2;
server_name healthcheck-history.example.com;
root /home/www/healthcheck/public;
index index.php;
location / {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
# Version-agnostic — Ondrej Sury's packaging installs this symlink
# pointing at whichever php-fpm is the current "alternative". If the
# symlink doesn't exist on your box, hardcode e.g. php8.3-fpm.sock.
# Check with: ls -la /run/php/
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
ssl_certificate /etc/letsencrypt/live/healthcheck-history.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/healthcheck-history.example.com/privkey.pem;
}
Gotcha: nginx loads every file in
sites-enabled/regardless of extension. Don'tcp healthcheck.conf healthcheck.conf.bakinside that directory — the backup becomes a second vhost claiming the sameserver_name, and nginx silently ignores one of them ("conflicting server name … ignored" in the error log) which manifests as the new config not taking effect. Back up outsidesites-enabled/.
TLS
Use Let's Encrypt + certbot. Example:
sudo certbot --apache -d healthcheck-history.example.com
# or for nginx:
sudo certbot --nginx -d healthcheck-history.example.com
Configure the plugin to talk to it
In wp-config.php on each WordPress install:
define('ATT_HC_API_URL', 'https://healthcheck-history.example.com');
define('ATT_HC_API_KEY', '<the long random string from config.php>');
Backups
The SQLite DB is a single file. Snapshot it on a cron:
# Daily at 03:15, rotate 14 days. SQLite-safe online backup.
15 3 * * * /usr/bin/sqlite3 /home/www/healthcheck/data/att_hc.sqlite ".backup '/var/backups/att-hc/att_hc-$(date +\%F).sqlite'" && find /var/backups/att-hc -name 'att_hc-*.sqlite' -mtime +14 -delete
Pull those backups offsite with rsync / rclone to whatever you already use.
Updating
Just rsync the source again — migrations run on first request after deploy.
rsync -avz --delete --exclude='data/' --exclude='config.php' \
~/dev/att-site-healthcheck/server/ \
user@vps:/home/www/healthcheck/
Quick health check
curl https://healthcheck-history.example.com/
# → {"ok":true,"service":"att-site-healthcheck-server","version":"..."}