9.8 KiB
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:
dig +short api.sascha-bach.de
Nothing below works until this answers.
1. Survey the machine first
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)
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.
sudo useradd --system --create-home --home-dir /var/lib/auditapi \
--shell /usr/sbin/nologin auditapi
4. Fetch the code
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.
cd /opt/audit-endpoint/backend
sudo -u auditapi npm install --omit=dev
5. Configuration
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:
sudo chmod 600 /opt/audit-endpoint/backend/.env
sudo chown auditapi:auditapi /opt/audit-endpoint/backend/.env
Smoke-test before wiring up systemd:
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
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.
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
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:
sudo certbot renew --dry-run
9. Verify from outside
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:
# 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:
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.deVITE_AUDIT_ENDPOINT- the nginx
server_nameand 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.
Since Git already runs on this same VPS, 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. Nothing new is exposed publicly.
Check this first: if Gitea/Forgejo runs inside Docker, 127.0.0.1 inside
its container is the container's own loopback, not the host's - the webhook
target below would not connect. Run docker ps to check. If Gitea is
containerized, either add the webhook service to the same Docker network and
call it by container name, or expose the webhook through the nginx vhost
(same pattern as /api/audit-request in step 7) with an allow/deny block
restricting it to the container network's subnet, instead of the loopback
target used below.
11.1 Generate a secret and configure it
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:
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
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
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 |
| 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.
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
# 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:
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)
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
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.