# Deploying HERMES

For a Linux server running Apache, MySQL 8 or MariaDB 10.4+, and PHP 8.1+.

Assumes `/var/www/hermes` with the document root at `/var/www/hermes/public`,
matching the shape already used elsewhere in this estate.

---

## 1. Packages

```bash
sudo apt update
sudo apt install -y apache2 mysql-server \
     php php-fpm php-mysql php-mbstring php-xml php-curl php-zip php-gd

sudo a2enmod rewrite headers expires deflate ssl
sudo systemctl restart apache2
```

`pdo_mysql`, `mbstring`, `fileinfo` and `openssl` are required. `curl` is
needed for SMS and Web Push; `zip` for the uploads backup; `gd` for image
validation on upload. The diagnostics page checks all of these.

---

## 2. Files and ownership

```bash
sudo mkdir -p /var/www/hermes
sudo rsync -a --exclude 'storage/*' --exclude 'config/local.php' \
     --exclude 'node_modules' ./ /var/www/hermes/

cd /var/www/hermes
sudo mkdir -p storage/{logs,uploads,backups,cache,tmp}
```

The application must not be able to modify its own code. Only `storage/` is
writable:

```bash
sudo chown -R root:www-data /var/www/hermes
sudo find /var/www/hermes -type d -exec chmod 750 {} \;
sudo find /var/www/hermes -type f -exec chmod 640 {} \;

sudo chown -R www-data:www-data /var/www/hermes/storage
sudo chmod -R 770 /var/www/hermes/storage
```

If that ever needs relaxing to make something work, the something is wrong.

---

## 3. Configuration

```bash
sudo cp config/config.example.php config/local.php
sudo chown root:www-data config/local.php
sudo chmod 640 config/local.php
sudo nano config/local.php
```

Minimum for production:

```php
return [
    'app' => [
        'debug'      => false,              // MUST be false — it exposes stack traces
        'public_url' => 'https://hermes.example.org',
        'org'        => 'Your Organisation',
    ],
    'db' => [
        'host'     => '127.0.0.1',
        'database' => 'hermes',
        'username' => 'hermes',
        'password' => '…',                  // not root
    ],
    'mail' => [
        'transport' => 'smtp',              // or 'mail' for a local MTA
        'from'      => 'hermes@example.org',
        'smtp'      => ['host' => '…', 'username' => '…', 'password' => '…'],
    ],
    'security' => [
        'trusted_proxies' => [],            // ONLY if a load balancer is really in front
    ],
];
```

**`config/local.php` is gitignored and must stay that way.** Nothing in the
committed tree contains a credential.

### Database user

Give the application its own account with no more rights than it needs:

```sql
CREATE DATABASE hermes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'hermes'@'127.0.0.1' IDENTIFIED BY '…';
GRANT SELECT, INSERT, UPDATE, DELETE ON hermes.* TO 'hermes'@'127.0.0.1';
FLUSH PRIVILEGES;
```

`CREATE`/`ALTER`/`DROP` are deliberately withheld. Grant them temporarily when
running migrations, then revoke:

```sql
GRANT ALL ON hermes.* TO 'hermes'@'127.0.0.1';   -- before migrating
-- php database/migrate.php
REVOKE CREATE, ALTER, DROP, INDEX, REFERENCES ON hermes.* FROM 'hermes'@'127.0.0.1';
```

---

## 4. Schema

```bash
cd /var/www/hermes
sudo -u www-data php database/migrate.php
sudo -u www-data php database/seed.php          # prints the founding password ONCE
```

Write the founding password down, sign in, change it immediately, enrol a
second factor, then create the real accounts through the UI so their creation
appears in the audit trail.

Then load the real constituency register:

```bash
sudo -u www-data php database/import-constituencies.php /path/to/ec-register.csv
```

And backfill analytics if you imported any history:

```bash
sudo -u www-data php bin/cron.php rollup 365
```

---

## 5. Apache

```apache
<VirtualHost *:80>
    ServerName hermes.example.org
    Redirect permanent / https://hermes.example.org/
</VirtualHost>

<VirtualHost *:443>
    ServerName hermes.example.org

    # The document root is public/, NOT the application root. Everything
    # above it — app/, config/, storage/ — is then unreachable by URL no
    # matter what else is misconfigured.
    DocumentRoot /var/www/hermes/public

    <Directory /var/www/hermes/public>
        AllowOverride All
        Require all granted
        Options -Indexes +FollowSymLinks
    </Directory>

    # Belt and braces: refuse these paths even if the docroot is ever wrong.
    <DirectoryMatch "/var/www/hermes/(app|config|database|storage|bin|tests|resources)">
        Require all denied
    </DirectoryMatch>

    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/hermes.example.org/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/hermes.example.org/privkey.pem
    SSLProtocol -all +TLSv1.2 +TLSv1.3

    <FilesMatch \.php$>
        SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
    </FilesMatch>

    ErrorLog  ${APACHE_LOG_DIR}/hermes-error.log
    CustomLog ${APACHE_LOG_DIR}/hermes-access.log combined
</VirtualHost>
```

```bash
sudo certbot --apache -d hermes.example.org
sudo a2ensite hermes && sudo systemctl reload apache2
```

### PHP settings

In the FPM pool or `/etc/php/8.3/fpm/conf.d/99-hermes.ini`:

```ini
expose_php = Off
display_errors = Off
log_errors = On

upload_max_filesize = 64M      ; matches uploads.max_bytes_media
post_max_size = 72M
max_file_uploads = 20
memory_limit = 256M
max_execution_time = 60

session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
```

`upload_max_filesize` must be at least `uploads.max_bytes_media`, or officers
get a confusing server error instead of the application's own message.

---

## 6. Cron

```bash
sudo crontab -u www-data -e
```

```cron
# Notifications and scheduled jobs. Safe to overlap.
* * * * * cd /var/www/hermes && php bin/cron.php >> storage/logs/cron.log 2>&1

# Nightly backup, before the prune.
15 2 * * * cd /var/www/hermes && php bin/backup.php >> storage/logs/backup.log 2>&1

# Weekly retention, after a backup exists.
30 3 * * 0 cd /var/www/hermes && php bin/prune.php --apply >> storage/logs/prune.log 2>&1

# Weekly integrity check on the stored backups.
45 3 * * 0 cd /var/www/hermes && php bin/backup.php --verify >> storage/logs/backup.log 2>&1
```

Without the first line nothing is emailed, no reminder goes out, no recurring
assignment is generated and the dashboards freeze. Confirm it is running from
Administration → Diagnostics, which shows the queue depth.

---

## 7. Backup and recovery

`bin/backup.php` writes a gzipped `mysqldump` and a zip of `storage/uploads`
to `storage/backups`, records a SHA-256 of each, and applies a
grandfather-father-son retention (14 daily, 8 weekly, 12 monthly).

### Off-site

A backup on the same disk as the database protects against a mistaken DELETE
and nothing else. Set an off-site command in `config/local.php`:

```php
'backup' => [
    'offsite_command' => 'rclone copy {file} remote:hermes-backups/',
],
```

`{file}` is replaced with the shell-escaped path. The result is recorded
against the backup run.

### Restore drill

**Test this before you need it.** Restoring into a scratch database costs ten
minutes and is the only way to know the backups are real.

```bash
# 1. Restore into a scratch database
mysql -u root -e "CREATE DATABASE hermes_restore_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
gunzip -c storage/backups/hermes-db-YYYY-MM-DD_HHMMSS.sql.gz | mysql -u root hermes_restore_test

# 2. Compare against the live database
mysql -u root -e "SELECT COUNT(*) FROM hermes.users;"
mysql -u root -e "SELECT COUNT(*) FROM hermes_restore_test.users;"
mysql -u root -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='hermes_restore_test';"

# 3. Tidy up
mysql -u root -e "DROP DATABASE hermes_restore_test;"
```

A real restore:

```bash
sudo systemctl stop apache2
gunzip -c storage/backups/hermes-db-….sql.gz | mysql -u root hermes
unzip -o storage/backups/hermes-uploads-….zip -d storage/uploads
sudo chown -R www-data:www-data storage/uploads
sudo systemctl start apache2
```

### Objectives

| | Default | With binary logging |
|---|---|---|
| **RPO** (data that could be lost) | 24 hours | ~15 minutes |
| **RTO** (time to be back) | ~2 hours | ~2 hours |

Enable binary logging to close the RPO gap — it is the single highest-value
change to this plan:

```ini
[mysqld]
log_bin = /var/log/mysql/mysql-bin.log
binlog_expire_logs_seconds = 1209600
```

---

## 8. Hardening checklist

Work through this before the directorate depends on the system. Administration
→ Diagnostics checks most of it automatically.

- [ ] `app.debug` is `false` in `config/local.php`
- [ ] HTTPS is enforced and HTTP redirects to it
- [ ] `config/local.php` is `640 root:www-data` and not in version control
- [ ] The database user is not `root` and lacks DDL rights
- [ ] Only `storage/` is writable by `www-data`
- [ ] `curl -I https://…/config/config.php` returns 404 — application internals are not reachable
- [ ] `curl -I https://…/storage/uploads/…` returns 404 — uploads are not URL-addressable
- [ ] `mail.transport` is not `log`
- [ ] Cron is running: the queue depth on Diagnostics is not climbing
- [ ] A backup has succeeded in the last 24 hours
- [ ] **The restore drill above has been performed at least once**
- [ ] Two-factor is enrolled on every account holding an approval permission
- [ ] The founding `admin` password has been changed from the seeded one
- [ ] The real constituency register has been imported
- [ ] `php tests/run.php` passes on the server
- [ ] `security.trusted_proxies` is empty unless a load balancer is genuinely in front

### A note on `trusted_proxies`

Leave it empty unless a proxy is really in front of Apache. Every address
listed is permitted to set `X-Forwarded-For`, and therefore to choose what the
audit trail records about it. An empty list means the connecting address is
used as-is and cannot be forged.

---

## 9. Upgrading

```bash
cd /var/www/hermes
sudo -u www-data php bin/backup.php database     # always, first

sudo systemctl stop apache2
sudo rsync -a --exclude 'storage/*' --exclude 'config/local.php' /path/to/new/ ./
sudo -u www-data php database/migrate.php --status
sudo -u www-data php database/migrate.php
sudo -u www-data php database/seed.php           # idempotent: adds new permissions
sudo systemctl start apache2

sudo -u www-data php tests/run.php
```

Migrations are immutable once applied. If a schema change is needed, add a new
numbered file — never edit an old one. The runner detects a changed file and
reports it rather than silently re-running it.

### OPcache will hide your deployment

Check this before anything else when a deployed change appears to have had no
effect:

```bash
php -i | grep opcache.validate_timestamps
```

If it is `0`, PHP never re-reads a file once it is cached. Copying new code onto
the server then changes **nothing** — Apache keeps running the old bytecode, the
files on disk look correct, and `php -r` from the shell (a fresh process, with no
shared cache) reports the new behaviour. Every symptom points away from the real
cause.

Two ways to be safe, and you want one of them:

```bash
# Anywhere that gets deployed to by copying files — set this once:
opcache.validate_timestamps=1
opcache.revalidate_freq=0

# Or keep it 0 for the small performance gain, and make every deploy end with:
sudo systemctl restart apache2
```

A `reload` is enough for configuration but **not** for flushing OPcache on all
setups; `restart` always is. If you leave `validate_timestamps=0`, treat the
restart as part of the deployment, not an optional extra.

---

## 10. Monitoring

- `GET /health` returns `{"status":"ok"}` and 200, or 503 when the database is
  unreachable. It deliberately reveals nothing else — no version, no counts —
  because it is the one endpoint the whole internet can reach.
- `storage/logs/php-error.log` is where uncaught exceptions land.
- Administration → Audit Trail, filtered to security events, shows failed
  sign-ins, lockouts, denied access and rejected uploads.

Worth alerting on: `/health` non-200; the queue depth climbing past a few
hundred (cron has stopped); no successful backup in 36 hours; a spike in
`login_failed` or `access_denied` from one address.
