One hostname a charger can be told about and actually reach

Charger control needs TLS, and the stack speaks plain HTTP, so the README
said "terminate TLS in a reverse proxy" and left the operator to work out
which four settings have to agree. This adds the proxy: a Caddy overlay
that fronts the Web App BFF — which already carries /api/ and /ocpp/ — so
browsers and chargers arrive at the same name and the certificate is issued
on first boot.

The four settings are derived from DV_DOMAIN, since one value getting typed
right is better odds than four: OCPP_PUBLIC_URL, OCPP_REQUIRE_TLS back on,
CORS, and TRUST_FORWARDED_PROTO on the Web App. That last one is the
non-obvious one — without it the BFF overwrites Caddy's X-Forwarded-Proto
with its own plaintext hop and the API Server rejects the charger it just
told to connect over wss.

The README's OCPP section was stale besides: it still sent chargers to the
API Server port alone, from before the BFF carried that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 15:36:56 +02:00
co-authored by Claude Opus 5
parent 9f5c8dc49a
commit e9a82a1cca
5 changed files with 141 additions and 9 deletions
+8
View File
@@ -66,3 +66,11 @@ VITE_API_BASE=
# CORS_ALLOW_ORIGINS browser origins allowed to call the API Server directly.
# POCKETBASE_URL=http://pocketbase:8070
# WEBAPP_URL=http://web-app:8090
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
# host from the internet, with ports 80 and 443 reaching it; the certificate is
# issued automatically on first boot. Setting it also points chargers at
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
DV_DOMAIN=
DV_ACME_EMAIL=
+8
View File
@@ -88,3 +88,11 @@ API_BIND=127.0.0.1
# stack: the API Server keeps no state on disk, so everything it owns (plugin
# settings included) is backed up by backing up this one path.
PB_DATA=pb_data
# --- Public hostname + TLS (docker-compose.tls.yml) --------------------------
# Only read when the TLS overlay is layered on. DV_DOMAIN must resolve to this
# host from the internet, with ports 80 and 443 reaching it; the certificate is
# issued automatically on first boot. Setting it also points chargers at
# wss://DV_DOMAIN, which is what lets OCPP_REQUIRE_TLS stay on.
DV_DOMAIN=
DV_ACME_EMAIL=
+24
View File
@@ -0,0 +1,24 @@
# Caddy in front of the stack: one hostname, TLS from Let's Encrypt, everything
# behind it spoken to over the compose network in plain HTTP.
#
# Both doors are the same door here. Browsers get the Web App; chargers dial
# /ocpp/{serial} on the same hostname, and the BFF carries that through to the
# API Server. Caddy proxies WebSocket upgrades without being told to, so there
# is nothing to configure for the charger case.
#
# DV_DOMAIN and DV_ACME_EMAIL come from .env via docker-compose.tls.yml.
{
email {$DV_ACME_EMAIL}
}
{$DV_DOMAIN} {
encode zstd gzip
# X-Forwarded-Proto is set by Caddy to the scheme the client used. The API
# Server reads it to decide a charger arrived over TLS, which is why the
# web-app service is given TRUST_FORWARDED_PROTO=true — without that the BFF
# would overwrite this header with its own plaintext hop and a charger would
# be rejected under OCPP_REQUIRE_TLS.
reverse_proxy web-app:8090
}
+40 -9
View File
@@ -80,16 +80,47 @@ instead. PocketBase runs as root, so a root-owned host directory is fine.
## Charger control (OCPP)
Chargers in own/proxy mode dial in to `/ocpp/{serial}` **on the API Server
port**, authenticating with a per-charger control token in an OCPP Basic-auth
header. A plaintext `ws://` would put that token on the wire in the clear, so
`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected.
Chargers in own/proxy mode dial in to `/ocpp/{serial}`, authenticating with a
per-charger control token in an OCPP Basic-auth header. Two ports answer that
path: the API Server's own, and the Web App's, whose BFF proxies `/ocpp/`
through. The second one matters because it is the address the panel hands out —
the endpoint is derived from the host the panel itself was reached on, which is
the Web App, unless `OCPP_PUBLIC_URL` says otherwise.
This stack serves plain HTTP, so to actually use charger control you need to
terminate TLS in a reverse proxy in front of it and set `OCPP_PUBLIC_URL` to the
public `wss://` base (behind a proxy, deriving it from request headers is
unreliable). `OCPP_REQUIRE_TLS=false` is for trusted networks only. You will
also need `API_BIND` set so the proxy can reach the port.
A plaintext `ws://` puts the control token on the wire in the clear, so
`OCPP_REQUIRE_TLS` defaults to `true` and non-TLS connections are rejected.
`OCPP_REQUIRE_TLS=false` is for a trusted network you own end to end.
### With a public hostname and TLS
`docker-compose.tls.yml` adds Caddy in front of the stack: one hostname, a
certificate issued on first boot, and everything behind it spoken to over the
compose network. Browsers and chargers arrive at the same name.
```sh
# in .env
DV_DOMAIN=drivervault.example.com
DV_ACME_EMAIL=you@example.com
docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
```
The overlay sets the rest for you: `OCPP_PUBLIC_URL=wss://$DV_DOMAIN`,
`OCPP_REQUIRE_TLS=true`, `CORS_ALLOW_ORIGINS=https://$DV_DOMAIN`, and
`TRUST_FORWARDED_PROTO=true` on the Web App so the BFF passes Caddy's
`X-Forwarded-Proto` to the API Server instead of overwriting it with its own
plaintext hop. Point the charger's OCPP backend at the endpoint the panel then
shows, with the control token as its authorization key.
Two things the overlay cannot arrange: `DV_DOMAIN` must resolve to the host from
the internet with ports 80 and 443 reaching it (Caddy needs `:80` for the ACME
challenge), and the charger must be able to resolve that name too — behind NAT
that usually means hairpin NAT or a split-DNS entry pointing it at the LAN
address.
Using a proxy you already run instead? Terminate TLS there, forward to the Web
App port, and set the same four variables by hand — the `X-Forwarded-Proto` one
included, or chargers will be rejected as insecure.
## Notes
+61
View File
@@ -0,0 +1,61 @@
name: drivervault
# TLS overlay — put a public hostname and a real certificate in front of the
# stack. Layer it on top of either base file:
#
# docker compose -f docker-compose.prod.yml -f docker-compose.tls.yml up -d
#
# What it changes:
# • Caddy terminates TLS on :443 and proxies everything to the Web App BFF,
# which already carries /api/ and /ocpp/ through to the API Server. One
# hostname serves browsers and chargers alike.
# • The charger endpoint the panel hands out becomes wss://$DV_DOMAIN/ocpp/…,
# so OCPP_REQUIRE_TLS goes back on and the control token stops crossing the
# network in the clear.
#
# Requirements, none of which this file can arrange for you:
# • DV_DOMAIN resolves to this host from the public internet, and ports 80 and
# 443 reach it (Caddy needs :80 for the ACME challenge, and keeps it for the
# redirect afterwards).
# • The charger can resolve DV_DOMAIN too. On a LAN behind NAT that usually
# means hairpin NAT or a split-DNS entry pointing the name at 10.2.1.10 —
# otherwise the charger looks up a public address it cannot route to.
services:
caddy:
image: "${CADDY_IMAGE:-caddy:2-alpine}"
container_name: drivervault-caddy
restart: unless-stopped
depends_on:
web-app:
condition: service_healthy
environment:
DV_DOMAIN: "${DV_DOMAIN:?set DV_DOMAIN in .env}"
# Let's Encrypt sends expiry warnings here if renewal ever stops working.
DV_ACME_EMAIL: "${DV_ACME_EMAIL:?set DV_ACME_EMAIL in .env}"
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Certificates live here. Keep the volume: wiping it re-issues on next
# boot and Let's Encrypt rate-limits that.
- caddy_data:/data
- caddy_config:/config
api-server:
environment:
# Derived from the one hostname, so there is a single thing to set.
OCPP_REQUIRE_TLS: "true"
OCPP_PUBLIC_URL: "wss://${DV_DOMAIN}"
CORS_ALLOW_ORIGINS: "https://${DV_DOMAIN}"
web-app:
environment:
# Caddy terminates TLS and says so in X-Forwarded-Proto. Believe it —
# that header is now set by the proxy in front, not by whoever dialed in.
TRUST_FORWARDED_PROTO: "true"
volumes:
caddy_data:
caddy_config: