portfolio-page/backend
Sascha f50442a5c8 fix: reach audit services from a Dockerized reverse proxy (Caddy)
The actual VPS setup has no native nginx - Forgejo and Caddy run via
docker-compose, with Caddy terminating TLS for git.sascha-bach.de and
talk.sascha-bach.de. Caddy itself sits inside that Docker network, so
127.0.0.1 from its container's point of view is not this host - it
could not reach either the audit endpoint or the webhook listener.

Both services now optionally bind an additional address via
DOCKER_BRIDGE_HOST (the Docker bridge's gateway IP), alongside their
existing 127.0.0.1 listener. Deliberately not 0.0.0.0: on a VPS with a
public IP and no confirmed firewall, that would also accept connections
arriving on the public interface, bypassing the reverse proxy's TLS
termination entirely.

DEPLOYMENT.md now documents both the nginx and the Caddy-in-Docker
path side by side, since this is the configuration actually in use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 18:43:08 +02:00
..
deploy fix: reach audit services from a Dockerized reverse proxy (Caddy) 2026-07-29 18:43:08 +02:00
routes feat: add free accessibility audit landing page (B2B) 2026-07-29 17:43:50 +02:00
services feat: add free accessibility audit landing page (B2B) 2026-07-29 17:43:50 +02:00
webhook fix: reach audit services from a Dockerized reverse proxy (Caddy) 2026-07-29 18:43:08 +02:00
.env.example fix: reach audit services from a Dockerized reverse proxy (Caddy) 2026-07-29 18:43:08 +02:00
README.md feat: add free accessibility audit landing page (B2B) 2026-07-29 17:43:50 +02:00
package-lock.json fix: reach audit services from a Dockerized reverse proxy (Caddy) 2026-07-29 18:43:08 +02:00
package.json feat: add free accessibility audit landing page (B2B) 2026-07-29 17:43:50 +02:00
server.js fix: reach audit services from a Dockerized reverse proxy (Caddy) 2026-07-29 18:43:08 +02:00

README.md

Audit request endpoint

Small Express service that receives the form on /de/audit and forwards it by email. It is deployed separately from the static site: the website is built with Vite and uploaded by FTP, this service runs on the VPS.

Its dependencies are intentionally kept out of the frontend package.json so they never end up in the browser bundle.

Endpoints

Method Path Purpose
GET /health Liveness probe
POST /api/audit-request Accepts one audit request

Expected JSON body:

{
  "name": "…",
  "email": "…",
  "url": "https://…",
  "role": "agency | client",
  "motivation": "…",
  "gdprConsent": "true",
  "website": ""
}

website is a honeypot and must stay empty. A filled honeypot is answered with 200 on purpose so bots learn nothing and do not retry.

Setup

cd backend
npm install
cp .env.example .env   # then fill in the SMTP credentials
npm start

The process binds to 127.0.0.1 only. Put nginx in front of it with TLS, for example on api.sascha-bach.de, and proxy to the port from .env.

Then point the frontend at it by setting VITE_AUDIT_ENDPOINT before building:

VITE_AUDIT_ENDPOINT=https://api.sascha-bach.de/api/audit-request npm run build

If the CSP connect-src in public/.htaccess names a different host than the one you deploy to, the browser blocks the request - keep the two in sync.

Protections in place

  • CORS allowlist from ALLOWED_ORIGINS, never *
  • Rate limit: 3 requests per IP per 15 minutes
  • Request body capped at 10 kB
  • Server-side validation of every field (express-validator)
  • Honeypot field
  • Consent is required, not assumed
  • CR/LF stripped from values used in mail headers, HTML escaped in the body
  • No personal data written to logs

app.set('trust proxy', 1) assumes exactly one proxy hop (nginx). Adjust it if you add a second one, otherwise the rate limiter sees a single IP for everyone.