# 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 @ # Which reverse proxy is in front? Step 7 covers both nginx (native) and # Caddy (in Docker, e.g. alongside Forgejo) - this decides which branch. systemctl is-active nginx apache2 2>/dev/null || echo "no native web server" docker ps # Is port 3001 free? Forgejo/Gitea commonly sits on 3000, 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, 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. Reverse proxy vhost Which of the two applies was already checked in step 1. If unsure, check now: ```bash which nginx 2>/dev/null && echo "nginx" || echo "no native nginx" docker ps # look for a caddy/nginx/traefik container ``` ### 7a. nginx runs natively on this machine 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 ``` Then continue with **8a** below. ### 7b. Caddy runs in Docker (e.g. alongside Forgejo) This is the actual setup found on this VPS: Forgejo + Caddy run via docker-compose (project `forgejo`, at `/root/forgejo/docker-compose.yml`), Caddy terminating TLS for `git.sascha-bach.de` and `talk.sascha-bach.de`. Since Caddy itself runs in a container, `127.0.0.1` from its point of view is the container's own loopback, not this host - it cannot reach the audit services' `127.0.0.1` listeners at all. This is why `server.js` and `webhook/server.js` also bind an additional address when `DOCKER_BRIDGE_HOST` is set - **do that first**, before touching Caddy: ```bash # Confirm the gateway IP for the network Caddy is on (should match what was # found earlier: forgejo_web -> 172.19.0.1). Re-run this if the network is # ever recreated - the address is not guaranteed to stay the same forever. docker inspect forgejo-caddy-1 --format \ '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}: {{$v.Gateway}}{{"\n"}}{{end}}' ``` Add to `/opt/audit-endpoint/backend/.env`: ```env DOCKER_BRIDGE_HOST=172.19.0.1 ``` ```bash sudo systemctl restart audit-endpoint sudo systemctl restart audit-webhook ``` Then append a **new, independent** block to the existing Caddyfile - this does not touch the `git.sascha-bach.de` or `talk.sascha-bach.de` blocks: ```bash sudo tee -a /root/forgejo/Caddyfile > /dev/null <<'EOF' api.sascha-bach.de { handle /deploy-webhook { reverse_proxy 172.19.0.1:3002 } handle { reverse_proxy 172.19.0.1:3001 } } EOF ``` Reload without restarting the container (existing connections to Forgejo/Talk are not dropped): ```bash docker exec forgejo-caddy-1 caddy reload --config /etc/caddy/Caddyfile ``` Caddy provisions its own Let's Encrypt certificate for `api.sascha-bach.de` automatically the first time it is requested - **skip step 8 entirely**, go straight to step 9. --- ## 8. TLS certificate (nginx only - Caddy already did this in 7b) ```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 - see step 7 for the same branch applied to the audit endpoint itself. **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 Caddy/nginx part of step 11.3. **Gitea runs in Docker** (the actual case on this VPS - Forgejo alongside Caddy): 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. Route the webhook through the same reverse proxy vhost set up for the audit endpoint in step 7 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. Step 7b's Caddy block already includes the `/deploy-webhook` route - 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**, the `/deploy-webhook` route was already added in step 7b - nothing further to do here. If step 7 was done with nginx instead (7a), add the same location manually to `nginx-api.sascha-bach.de.conf` (mirroring the `/api/audit-request` block, just pointing at port 3002) and reload. ### 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 `. 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`.