# Deployment runbook

## Provision and secrets

Use a supported Linux VPS/container host with Docker Compose v2, at least 2 CPU/4 GB RAM initially, external HTTPS termination, and monitored backups. This project cannot run on the JavaScript-only Sites hosting runtime. Do not expose MySQL, Redis, PHP-FPM, ClamAV or Meilisearch to the internet.

Copy `.env.example` to `.env`. Set unique, high-entropy database/root/Redis/Meilisearch credentials, an APP_KEY (generate once and back it up securely), real APP_URL, and ANALYTICS_SALT. Supply secrets via a deployment secret manager rather than Git. Keep APP_ENV=production, APP_DEBUG=false, SESSION_SECURE_COOKIE=true. Do not rotate APP_KEY casually: existing encrypted records and 2FA secrets depend on it.

## Build and initialize

```bash
docker compose build
docker compose up -d mysql redis
# Generate APP_KEY privately with key:generate --show and store it in host .env first.
docker compose run --rm app php artisan migrate --force --seed
docker compose run --rm app php artisan platform:create-user owner@example.com --owner
docker compose up -d
```

Prefer generating APP_KEY with `php artisan key:generate --show` in a private terminal and storing the returned value directly in your secret manager before boot. Never paste it into a ticket/log. The interactive bootstrap command asks for the owner password without echoing it. There is no seeded default password.

The Dockerfile builds separate `app` and `web` targets from the same source. Nginx includes the matching compiled assets; no persistent asset volume can retain stale bundles. Tag and deploy both images from the same release commit.

Run `php artisan config:cache`, `route:cache`, `view:cache` during deployment in each runtime container after its environment is available. Do not bake cached production secrets into a shared image. Restart Horizon using `horizon:terminate` after code updates. Ensure scheduler runs once (use a shared cache lock for multiple replicas).

## TLS and edge

Docker Nginx binds only to 127.0.0.1:8080. Put a host Nginx/Caddy/load balancer in front of it. Obtain a Let's Encrypt certificate for your actual domain (for example `certbot --nginx -d your-domain.example` on the host) and redirect HTTP to HTTPS. Forward `Host`, `X-Forwarded-For` and `X-Forwarded-Proto` from the trusted proxy only. Set TRUSTED_PROXIES to its exact IP/CIDR; do not trust every address. The app redirects insecure requests, so incorrect proxy headers cause redirect loops.

For Cloudflare use Full (strict), a valid origin certificate, restricted origin ingress, managed WAF and rate rules for `/login`, `/two-factor-challenge`, `/livewire/update` and `/tools/*/run`. Never edge-cache login/admin/security/run responses or HTML containing CSRF tokens. Static `/build` assets can have immutable caching. Enable Brotli/gzip at the edge. Verify real client-IP handling and WAF bypass resistance from outside the network.

## Optional services

- Meilisearch: set a strong MEILISEARCH_KEY and start `docker compose --profile search up -d`; set SCOUT_DRIVER=meilisearch and run `php artisan scout:import 'App\Models\Tool'`. The public route uses Meilisearch when SCOUT_DRIVER=meilisearch and rechecks publication filters in the database.
- GeoIP: install `geoip2/geoip2`, supply licensed Country MMDB in the private volume; update regularly. Enable analytics only with notices and a generated salt.
- ClamAV: run a private `clamav/clamav` service with fresh signatures and bounded scan limits. The upload helper connects to `CLAMAV_HOST`; there are no public upload endpoints yet.
- Sentry: set SENTRY_LARAVEL_DSN and configure the package's Laravel exception integration before claiming error capture; scrub request bodies, authorization headers, cookies, query strings and sensitive job data. No sensitive input should be sent to Sentry.
- SMTP: configure authenticated TLS delivery before adding password reset or email verification workflows.

## Daily encrypted backups

Use a dedicated backup identity, restricted S3-compatible bucket, retention/versioning and a maintained encryption client such as restic. This is an operator runbook, not an enabled backup service.

1. Store RESTIC_REPOSITORY/RESTIC_PASSWORD/AWS credentials in the backup service's secret manager. Initialize the repository once.
2. Use a MySQL option file with mode 0600 (password never in argv). Run `mysqldump --defaults-extra-file=/run/secrets/mysql-backup.cnf --single-transaction --routines --triggers toolforge` into a private, encrypted staging volume.
3. Run `restic backup /backup/database.sql /mounted/private-files`. Restic encrypts before upload. Store APP_KEY separately in the secret vault and include recovery instructions.
4. Delete the staged SQL immediately after success. Schedule daily via a systemd timer; alert on failures and age of last successful backup.
5. Retain an appropriate daily/weekly/monthly policy; run `restic check` and restore drills on a separate host. Restore database, private data and matching APP_KEY together. Document RPO/RTO.

Never place SQL dumps in `public/` or the release archive. Backup/restore, provider keys, DNS, certificates, Ads/CMP and WAF cannot be validated without actual infrastructure.
