Skip to content

Getting started

Scheduled jobs and backups

These jobs run on a schedule from cron (not Symfony Scheduler: it only fires while a Messenger worker runs, and the default deployment runs none). Each console command is idempotent, safe to run again, and takes --dry-run to show what it would do without changing anything.

CommandScheduleWhat happens if it never runs
app:account:process-deletionsdailyAccounts whose deletion was requested more than 30 days ago are never anonymised.
app:auth:purgedailyUsed sign-in codes and expired verified devices pile up in the database.
app:matchday:runevery 15 minutesPlayers aren't asked whether they can play, nobody is reminded, captains never hear who hasn't answered, results the other side never confirms stay out of the tables, nobody is reminded to arrange their box matches, box rounds never end, so nobody moves box, ladder challenges never expire, nobody is reminded of a fee they owe, a court held for a payment that never came stays held until someone else books it, a venue's held earnings wait for its next payment or an admin's check after it connects, members aren't reminded before their memberships renew or end, memberships paid by hand never end, and data sources don't run on their schedules.
bin/backup-dbnightlyThere is no copy of the database to go back to.

Crontab

30 2 * * *  cd /path/to/app && DB_USER=sports DB_NAME=sports MYSQL_PWD=... bin/backup-db >> var/log/cron.log 2>&1
*/15 * * * *  cd /path/to/app && php bin/console app:matchday:run >> var/log/cron.log 2>&1
30 3 * * *  cd /path/to/app && php bin/console app:account:process-deletions >> var/log/cron.log 2>&1
50 3 * * *  cd /path/to/app && php bin/console app:auth:purge >> var/log/cron.log 2>&1
10 4 * * *  cd /path/to/app && php bin/console app:privacy:retention >> var/log/cron.log 2>&1

With Docker, the cron service runs the console jobs from docker/crontab. A new scheduled job goes in both places.

Backups

bin/backup-db writes a compressed dump to BACKUP_DIR (default /var/backups/sports), deletes dumps older than BACKUP_KEEP_DAYS (default 14), and copies the new one to BACKUP_S3_URI when it's set (it needs the aws command). It reads the password from MYSQL_PWD, so the password never shows in the process list. Keep a copy off the server: a dump on the same disk is lost with the disk.

DB_HOST and DB_PORT default to 127.0.0.1 and 3306. With Docker, the database container has its own mysqldump, so the host's cron can dump from inside it:

30 2 * * *  cd /path/to/app && docker compose exec -T database sh -c 'exec mysqldump --single-transaction --routines --triggers --no-tablespaces -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' | gzip > /var/backups/sports/sports-$(date +\%Y\%m\%d).sql.gz

A managed database or a volume snapshot taken by the host works as well.

Restoring a backup

  1. Put the platform in maintenance: stop the web server, the worker and cron, so nothing writes meanwhile.

  2. Restore into a new, empty database first, and check it before touching the live one:

    mysql -u sports -p -e "CREATE DATABASE sports_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
    gunzip -c /var/backups/sports/sports-20261106-023000.sql.gz | mysql -u sports -p sports_restore
    mysql -u sports -p sports_restore -e "SELECT COUNT(*) FROM users"
  3. Point DATABASE_URL in .env.local at sports_restore, or rename the databases, then clear the cache (php bin/console cache:clear) and start everything again.

  4. Sign in to the admin and check the latest records you expect, then delete the old database.

Restore with the same client family that made the dump. MariaDB's mysqldump starts a dump with a "sandbox mode" line that MySQL's mysql client refuses; drop that line first with gunzip -c dump.sql.gz | sed 1d | mysql ....

The restore was tried on 2026-11-06: a dump of the development database, restored into an empty database, had the same tables and the same row count in each.

Background queue (optional)

Emails and push notifications go through Messenger. The transport is MESSENGER_TRANSPORT_DSN, which defaults to:

MESSENGER_TRANSPORT_DSN=sync://

sync:// handles each message in the request, so no worker is required. To move that work into the background:

MESSENGER_TRANSPORT_DSN=doctrine://default

then run a worker (the transport creates its own messenger_messages table on first use):

php bin/console messenger:consume async --time-limit=3600

Supervise it, so it starts again after the time limit or a crash: the worker profile of compose.yaml restarts it (restart: unless-stopped), and on a server without Docker a systemd unit does the same:

# /etc/systemd/system/sports-worker.service
[Unit]
Description=Sharkhex Sports queue worker
After=network.target mysql.service

[Service]
User=www-data
WorkingDirectory=/path/to/app
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

systemctl enable --now sports-worker starts it, and systemctl kill sports-worker shows it coming back.

The sign-in code email is always sent at once, whatever the transport: sign-in waits for the code, and is refused when it can't be sent.

Found a mistake? Tell us.