Cron & backups

Per-site cron

Each site owns a crontab source file at /data/web/<site>/cron/crontab. The site owner can edit it. create_vhost.sh materializes the file into /etc/cron.d/tulixhost-<site> on creation; if you edit the source after that, re-materialize manually:

sudo cp /data/web/example.com/cron/crontab /etc/cron.d/tulixhost-example.com
sudo chmod 0644 /etc/cron.d/tulixhost-example.com

Files in /etc/cron.d/ use the /etc/cron.d format: each line includes a user field. Always use the per-site unix user (e.g. web_example_com), never root. Example:

# Per-site crontab for example.com.
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# every 5 minutes
*/5 *  *  *  *  web_example_com  /usr/bin/php /data/web/example.com/public/cron.php

# nightly cleanup at 04:00 UTC
0    4  *  *  *  web_example_com  /usr/bin/php /data/web/example.com/public/cleanup.php

Crontab edits are picked up by cron within a minute; no reload required. Watch the log:

journalctl -u cron --since "10 min ago"
# or for the site's own output:
tail -F /data/web/example.com/logs/app.log

Backup and restore preserve the materialized cron file. If you restore on a fresh host, your site's cron entries come back exactly as they were.

Master backup cron

setup.sh installs /etc/cron.d/tulixhost:

17 3 * * * root /usr/local/sbin/tulixhost-backup_all >>/var/log/tulixhost/backup.log 2>&1

03:17 UTC was chosen to avoid round-hour congestion. Adjust if it clashes with anything else on your host (heavy log rotation typically runs at 06:25 UTC; standard apt updates around 06:00–07:00). Backup runs serially by default; pass --parallel=N in the cron line if you want concurrency.

What's in a backup tarball

example.com-20260526T031700Z-scheduled.tar.gz
├── site/
│   └── example.com/                ← full /data/web/example.com tree
│       ├── public/...
│       ├── conf/...                ← includes .envtulix
│       ├── logs/...                ← current (uncompressed) logs only
│       └── cron/crontab
├── nginx/example.com.conf          ← server-side nginx vhost
├── php-fpm/example.com.conf        ← server-side FPM pool
├── cron/tulixhost-example.com      ← materialized /etc/cron.d entry
├── db/example.com.sql.gz           ← mysqldump --single-transaction ...
├── redis/example.com.txt.gz        ← SCAN + DUMP of all prefixed keys
└── meta.json                       ← site name, sizes, versions, timestamp

The tarball is mode 0600 and lives in /data/backups/<site>/ (also mode 0700, owned by root). A running SHA256SUMS file in the same directory tracks every backup's hash for integrity checking.

The Redis dump format

Redis backups are line-oriented for streaming-friendliness. Each line is:

<base64-key> <ttl-ms> <base64-DUMP-payload>

The DUMP payload is Redis's native serialization format — same one used by RESTORE. This survives binary keys and values, hash field structures, sorted sets, streams, etc. TTL is preserved (or -1 for keys without expiry).

Rotation

Keeps the newest BACKUP_RETENTION_DAYS tarballs per site (default 7), regardless of calendar age. This means a quiet site that gets one backup per week still keeps a week of history. Adjust in /etc/tulixhost/tulixhost.conf:

BACKUP_RETENTION_DAYS="14"

Offsite rsync

Set BACKUP_RSYNC_TARGET in the main config to enable post-backup replication:

BACKUP_RSYNC_TARGET="backup@offsite.example.com:/srv/tulix-backups"

You'll need SSH keys set up for the root account to that destination (since the cron runs as root). Each backup_site.sh run rsyncs its tarball to $TARGET/<site>/ automatically. Failed rsyncs warn but don't fail the backup.

Restore flows

Restore one site to its latest backup (after corruption / mistake)

sudo ./restore_site.sh example.com --latest

You'll be prompted to confirm by typing the site name. If you're certain, add --yes.

Restore from a specific tarball

sudo ./restore_site.sh /data/backups/example.com/example.com-20260520T031700Z-scheduled.tar.gz

Migrate to a new host

  1. Set up the new host: sudo ./setup.sh
  2. Copy /data/backups/ across (rsync, scp, restic, whatever).
  3. For each site, run sudo ./restore_site.sh <site> --latest --yes, OR run sudo ./restore_all.sh --yes to do them all.
  4. Update DNS to point at the new host.
  5. If sites use Let's Encrypt, the certs in the backup are the old host's symlinks (/etc/letsencrypt/live/...). Run sudo certbot --nginx -d <site> on the new host to obtain fresh ones, then re-run create_vhost.sh --force to rewrite the vhost.

Clone a site (e.g. to set up a staging copy)

sudo ./restore_site.sh /data/backups/example.com/latest.tar.gz --site=staging.example.com

The --site=NAME override unpacks the same content under a different site name. You'll need to fix up the database name and Redis prefix manually if you want the new site to use isolated backends (otherwise it shares the original's). For a proper clone, the cleaner path is:

  1. sudo ./create_vhost.sh staging.example.com --ssl=auto (fresh site)
  2. rsync the public/ tree across
  3. Dump original DB, edit any hardcoded URLs, load into the new DB

Configs only, no data

sudo ./restore_site.sh example.com --latest --no-data

Restores filesystem and configs but skips the DB load and Redis replay. Useful when you're moving a site to a new box and intend to seed fresh data.

Verifying a backup

tarballs are mode 0600 and not auto-verified after creation. To check integrity:

cd /data/backups/example.com
sha256sum -c SHA256SUMS

To peek inside without extracting:

tar -tzf example.com-20260526T031700Z-scheduled.tar.gz | head
tar -xzOf  example.com-20260526T031700Z-scheduled.tar.gz meta.json | jq .