portfolio-page/backend/deploy/DEPLOYMENT.md

370 lines
11 KiB
Markdown

# Deploying the audit endpoint on the IONOS VPS
The website itself stays on the Bitpalast webspace (static files via FTP). Only
this endpoint runs on the VPS, alongside the existing Git and Nextcloud
services. Nothing here modifies their configuration.
Assumes Debian or Ubuntu with `sudo`. Adjust package commands for other
distributions.
---
## 0. DNS
Create an **A record** `api.sascha-bach.de` pointing at the VPS IP address
(and an AAAA record if the VPS has IPv6). Wait until it resolves:
```bash
dig +short api.sascha-bach.de
```
Nothing below works until this answers.
---
## 1. Survey the machine first
```bash
ssh <user>@<vps>
# Which web server is in front? The nginx config below assumes nginx.
systemctl is-active nginx apache2 2>/dev/null
# Is port 3001 free? Gitea commonly sits on 3000, so check before assuming.
sudo ss -tlnp | grep -E ':(3000|3001)\b' || echo "3001 frei"
# Node 20 or newer present?
node --version 2>/dev/null || echo "Node fehlt"
```
If **Apache** is in front instead of nginx, skip step 6 and set up an Apache
`ProxyPass` for `api.sascha-bach.de` instead — the rest is unchanged.
If **3001 is taken**, pick another free port and change it in three places:
`.env` (`PORT`), the nginx `proxy_pass` lines, and nothing else.
---
## 2. Install Node (only if missing)
```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version # must be >= 20
```
---
## 3. Dedicated service user
The endpoint must not run as root and must not share the Git user's account.
```bash
sudo useradd --system --create-home --home-dir /var/lib/auditapi \
--shell /usr/sbin/nologin auditapi
```
---
## 4. Fetch the code
```bash
sudo mkdir -p /opt/audit-endpoint
sudo chown auditapi:auditapi /opt/audit-endpoint
sudo -u auditapi git clone https://git.sascha-bach.de/saschabach/portfolio-page.git \
/opt/audit-endpoint
```
If the repository is private, use a deploy token or an SSH key belonging to
`auditapi`.
```bash
cd /opt/audit-endpoint/backend
sudo -u auditapi npm install --omit=dev
```
---
## 5. Configuration
```bash
sudo -u auditapi cp /opt/audit-endpoint/backend/.env.example \
/opt/audit-endpoint/backend/.env
sudo -u auditapi nano /opt/audit-endpoint/backend/.env
```
Fill in:
| Variable | Value |
| --- | --- |
| `PORT` | `3001` (or the free port from step 1) |
| `ALLOWED_ORIGINS` | `https://sascha-bach.de,https://www.sascha-bach.de` — the **website** origin, not the API origin |
| `AUDIT_RECIPIENT` | where requests are delivered |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | mailbox credentials |
| `SMTP_FROM` | an address **on your own domain**, so SPF and DMARC pass |
Lock the file down — it holds the mailbox password:
```bash
sudo chmod 600 /opt/audit-endpoint/backend/.env
sudo chown auditapi:auditapi /opt/audit-endpoint/backend/.env
```
Smoke-test before wiring up systemd:
```bash
cd /opt/audit-endpoint/backend && sudo -u auditapi node server.js
# expect: Audit endpoint listening on 127.0.0.1:3001
# Ctrl+C
```
---
## 6. systemd service
```bash
sudo cp /opt/audit-endpoint/backend/deploy/audit-endpoint.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now audit-endpoint
systemctl status audit-endpoint --no-pager
curl -s http://127.0.0.1:3001/health # → {"status":"ok"}
```
---
## 7. nginx vhost
Adding a file and reloading never touches the running Git or Nextcloud vhosts.
```bash
sudo cp /opt/audit-endpoint/backend/deploy/nginx-api.sascha-bach.de.conf \
/etc/nginx/sites-available/api.sascha-bach.de
sudo ln -s /etc/nginx/sites-available/api.sascha-bach.de \
/etc/nginx/sites-enabled/
# Validates the WHOLE config. If this fails, nothing has changed yet.
sudo nginx -t
# reload, not restart - existing connections to git/nextcloud survive
sudo systemctl reload nginx
```
---
## 8. TLS certificate
```bash
sudo apt-get install -y certbot python3-certbot-nginx # if missing
sudo certbot --nginx -d api.sascha-bach.de
```
certbot only edits the `api.sascha-bach.de` block. Verify renewal works:
```bash
sudo certbot renew --dry-run
```
---
## 9. Verify from outside
```bash
curl -sI https://api.sascha-bach.de/health # → HTTP/2 200
curl -s -X POST https://api.sascha-bach.de/api/audit-request \
-H 'Content-Type: application/json' \
-H 'Origin: https://sascha-bach.de' \
-d '{"name":"Test","company":"Test GmbH","email":"du@example.de",
"url":"https://example.de","role":"client","motivation":"Test",
"businessConfirmation":"true","gdprConsent":"true","website":""}'
# → {"success":true,...} and an email arrives
```
Then confirm the protections actually bite:
```bash
# Foreign origin → rejected
curl -s -X POST https://api.sascha-bach.de/api/audit-request \
-H 'Content-Type: application/json' -H 'Origin: https://evil.example' \
-d '{}' -o /dev/null -w '%{http_code}\n'
# Fourth request within 15 minutes → 429
```
---
## 10. Point the website at it
Back on the workstation, in the project root:
```bash
echo 'VITE_AUDIT_ENDPOINT=https://api.sascha-bach.de/api/audit-request' > .env
npm run deploy
```
Vite bakes the value in at build time, so this must happen **before** the
upload. `.env` is gitignored.
The hostname must match in three places or the browser blocks the request:
- `public/.htaccess``connect-src 'self' https://api.sascha-bach.de`
- `VITE_AUDIT_ENDPOINT`
- the nginx `server_name` and its certificate
---
## 11. Auto-deploy via webhook (optional)
Without this, updating means SSHing in and running the commands under
"Updating later" by hand every time. This section makes a `git push` to
your own Gitea/Forgejo trigger a pull + restart automatically.
**Check this first:** run `docker ps` to see whether Gitea/Forgejo runs in a
Docker container. This decides which of the two setups below applies.
**Gitea runs natively on this machine (not in Docker):** the webhook listener
never needs to be reachable from the internet - it binds to `127.0.0.1`
only, and Gitea calls it over loopback. Use `http://127.0.0.1:3002/deploy-webhook`
as the Target URL in step 11.4 and skip the nginx part of step 11.3.
**Gitea runs in Docker:** inside that container, `127.0.0.1` is the
container's own loopback, not this host - a target of `127.0.0.1:3002` would
never connect, regardless of Docker's networking mode. Rather than depending
on that container's specific network setup (bridge vs. host, custom networks,
`host.docker.internal` availability - all of which vary by how it was set
up), route the webhook through the same nginx vhost as the audit endpoint
instead: the request just goes out over HTTPS to `api.sascha-bach.de` and
back in, exactly like any other webhook call, independent of how Gitea's
container is networked. `backend/deploy/nginx-api.sascha-bach.de.conf`
already has the `/deploy-webhook` location for this - see step 11.3. Use
`https://api.sascha-bach.de/deploy-webhook` as the Target URL in step 11.4.
### 11.1 Generate a secret and configure it
```bash
openssl rand -hex 32
```
Add the output as `WEBHOOK_SECRET` in `/opt/audit-endpoint/backend/.env`
(same file as the SMTP settings - `WEBHOOK_PORT` and `WEBHOOK_BRANCH` already
have sane defaults in `.env.example` and normally do not need changing).
### 11.2 Grant the narrow sudo rule
The webhook process runs as the unprivileged `auditapi` user but needs to
restart a systemd unit, which normally requires root. This grants exactly
that, and nothing else:
```bash
sudo visudo -cf /opt/audit-endpoint/backend/deploy/audit-deploy-sudoers
# only proceed if that reports "parsed OK"
sudo cp /opt/audit-endpoint/backend/deploy/audit-deploy-sudoers \
/etc/sudoers.d/audit-deploy
sudo chmod 440 /etc/sudoers.d/audit-deploy
```
`visudo -cf` validates the file **before** it takes effect - a broken
`/etc/sudoers.d/` file can lock out sudo for the entire machine, so never
skip this check.
### 11.3 systemd service for the webhook
```bash
sudo cp /opt/audit-endpoint/backend/deploy/audit-webhook.service \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now audit-webhook
systemctl status audit-webhook --no-pager
```
**Only if Gitea runs in Docker**, additionally expose it through nginx (the
config was already updated when you copied it in step 7 - if that was before
this section existed, re-copy it):
```bash
sudo cp /opt/audit-endpoint/backend/deploy/nginx-api.sascha-bach.de.conf \
/etc/nginx/sites-available/api.sascha-bach.de
sudo nginx -t
sudo systemctl reload nginx
```
### 11.4 Register the webhook in Gitea/Forgejo
In the repository on `git.sascha-bach.de`: **Settings → Webhooks → Add
Webhook → Gitea** (Forgejo: the same menu, still labelled "Gitea" - it is a
Gitea fork and uses the same webhook format).
| Field | Value |
| --- | --- |
| Target URL | `http://127.0.0.1:3002/deploy-webhook` (native Gitea) **or** `https://api.sascha-bach.de/deploy-webhook` (Gitea in Docker) |
| HTTP Method | `POST` |
| POST Content Type | `application/json` |
| Secret | the value generated in 11.1 |
| Trigger On | Push events |
| Branch filter | `master` |
Save, then use Gitea's "Test Delivery" button.
```bash
journalctl -u audit-webhook -f
```
Expect `Deploy started.` followed by `Deployed <short-sha>`. If it instead
shows a signature mismatch, the secret in Gitea and in `.env` do not match.
### 11.5 Try it for real
```bash
# on the workstation, after a real commit
git push origin master
```
Within a few seconds, `journalctl -u audit-endpoint -f` should show a
restart, and `curl http://127.0.0.1:3001/health` (run locally on the VPS, or
`curl https://api.sascha-bach.de/health` from anywhere) keeps answering `200`
throughout - restart happens fast enough that a health check running once a
minute would not even notice a gap.
### Known limitation
Changes to `backend/webhook/` itself (the webhook listener's own code) take
effect only after the **next** manual restart:
```bash
sudo systemctl restart audit-webhook
```
It cannot cleanly restart its own systemd unit mid-deploy - see the comment
in `backend/webhook/deploy.sh`. This code changes far less often than the
audit endpoint's own logic, so it is a fair trade for not adding fragile
self-restart handling.
---
## Updating later (manual, without the webhook)
```bash
cd /opt/audit-endpoint
sudo -u auditapi git fetch origin master
sudo -u auditapi git reset --hard origin/master
cd backend && sudo -u auditapi npm install --omit=dev
sudo systemctl restart audit-endpoint
```
Still useful even with the webhook configured: for a manual redeploy without
waiting for the next push, or if the webhook service itself is down.
## Logs
```bash
journalctl -u audit-endpoint -f
journalctl -u audit-webhook -f # only relevant if step 11 is set up
```
Request contents are never logged, only that a request was forwarded — see the
note in `services/emailService.js`.