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
- Set up the new host:
sudo ./setup.sh - Copy
/data/backups/across (rsync, scp, restic, whatever). - For each site, run
sudo ./restore_site.sh <site> --latest --yes, OR runsudo ./restore_all.sh --yesto do them all. - Update DNS to point at the new host.
- If sites use Let's Encrypt, the certs in the backup are the old host's symlinks
(
/etc/letsencrypt/live/...). Runsudo certbot --nginx -d <site>on the new host to obtain fresh ones, then re-runcreate_vhost.sh --forceto 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:
sudo ./create_vhost.sh staging.example.com --ssl=auto(fresh site)rsyncthepublic/tree across- 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 .