437 lines
13 KiB
Markdown
437 lines
13 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 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 <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`.
|