Runbook (VPS operations)

Host: HestiaCP server, domain krunc.com, document root /home/nerdy/web/krunc.com/public_html, PHP-FPM 8.3 runs as user nerdy, MariaDB database nerdy_krunc.

Deploy

cd /home/nerdy/web/krunc.com/public_html
git pull
composer dump-autoload -o
php bin/migrate            # applies pending SQL migrations exactly once
php bin/lint && php bin/test
chown -R nerdy:nerdy . && chmod 600 .env
curl -fsS https://krunc.com/healthz && curl -fsS https://krunc.com/readyz

readyz returns 503 when the database is unreachable or storage/ is not writable.

Environment

All configuration lives in .env (chmod 600, never committed). .env.example lists every variable. Generate secrets with php bin/keygen. Rotating ENCRYPTION_KEY requires re-encrypting stored tokens — do not rotate casually.

Reverse proxies / Cloudflare

krunc.com is proxied by Cloudflare (orange cloud) in front of nginx → Apache → PHP-FPM. Client IPs are recovered in layers: nginx trusts CF-Connecting-IP from Cloudflare's published ranges (/etc/nginx/conf.d/cloudflare.inc), Apache's mod_remoteip trusts X-Real-IP from nginx, and the app (Request::clientIp()) honours CF-Connecting-IP / X-Real-IP / X-Forwarded-For only when the direct peer is loopback or listed in TRUSTED_PROXIES. A client that reaches the origin directly therefore cannot spoof its IP for rate limits or audit logs. Keep Cloudflare SSL mode on Full (strict) — the origin has a real Let's Encrypt certificate.

Worker & scheduler

The queue driver defaults to sync (jobs run inline). Scheduled work (live polling, scheduled posts, token refresh, offline detection, roll-ups) still needs the worker tick. Cron (installed for user nerdy):

* * * * * php bin/worker --once >> storage/logs/worker.log 2>&1

For higher throughput switch to QUEUE_DRIVER=database and run the worker as a long-lived process:

# /etc/systemd/system/creatoros-worker.service
[Unit]
Description=CreatorOS worker
After=network.target mariadb.service
[Service]
User=nerdy
WorkingDirectory=/home/nerdy/web/krunc.com/public_html
ExecStart=/usr/bin/php bin/worker
Restart=always
[Install]
WantedBy=multi-user.target

Logs

  • App: storage/logs/app.log (JSON lines, secrets redacted)
  • Mail (log driver): storage/logs/mail.log
  • Worker: storage/logs/worker.log
  • Web: /var/log/apache2/domains/krunc.com.*.log

Backups

Nightly database dump with 14-day retention (add to root cron and sync the folder off-VPS):

0 3 * * * mysqldump --single-transaction nerdy_krunc | gzip > /home/nerdy/backups/krunc-$(date +\%F).sql.gz && find /home/nerdy/backups -name 'krunc-*.sql.gz' -mtime +14 -delete

Also sync storage/uploads (media) off-VPS. Hestia's own backups (v-backup-user nerdy) cover both.

Restore

gunzip -c krunc-YYYY-MM-DD.sql.gz | mysql nerdy_krunc
rsync -a backup/uploads/ storage/uploads/
php bin/migrate --status   # confirm schema matches

Test a restore into a scratch database (v-add-database nerdy scratch ...) before relying on it.

Stripe webhooks

Endpoint: https://krunc.com/webhooks/stripe. Set STRIPE_WEBHOOK_SECRET from the Stripe dashboard. Locally: stripe listen --forward-to https://krunc.com/webhooks/stripe. Failed events show up in /admin/webhooks with a reprocess button.

Troubleshooting

  • 500 on every page: check storage/logs/app.log and Apache error log; usually .env unreadable by nerdy or a bad ENCRYPTION_KEY.
  • HTTP (port 80) shows a different site: a catch-all Apache vhost (/etc/apache2/conf.d/custom-domains-catchall.conf, ServerAlias *) captures plain-HTTP requests on this host. Force-SSL is enabled for krunc.com, so HTTP redirects to HTTPS which is served correctly.
  • Jobs stuck: /admin/jobs lists failed jobs; retry there or php bin/worker --once.
  • Migrations: never edit an applied file; add a new numbered .sql under database/migrations.