portfolio-page/backend/deploy/DEPLOYMENT.md

13 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 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)

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. Reverse proxy vhost

Which of the two applies was already checked in step 1. If unsure, check now:

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.

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:

# 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:

DOCKER_BRIDGE_HOST=172.19.0.1
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:

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):

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)

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/.htaccessconnect-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

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

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.

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.