millivolt Run it locally
Documentation menu

Reverse proxy and remote access

Use a protected ingress when accessing millivolt beyond a trusted local machine. The whole dashboard is gated by the shared MILLIVOLT_OPERATOR_TOKEN credential; there is no tenant isolation and no per-user identity. At the application gate, only the unauthenticated /healthz and /readyz probes, the POST /session login handshake, origin-root brand/PWA files and transparent inference stay open, so a load balancer can health-check the service without holding the credential and a browser can reach the login handshake. The canonical open-path list is in operator access; this guide only adds the ingress boundary. Read Security first.

Reverse proxy topology: the API client and dashboard browser reach NGINX over TLS, NGINX forwards over loopback to millivolt, and millivolt sends requests to the selected upstream

The diagram is a text-equivalent summary of the example below: NGINX terminates TLS and enforces access; millivolt owns routing, the operator session, and the history; the upstream receives the selected credentials.

NGINX setup

deploy/nginx.conf is the canonical example. It serves millivolt at the origin root, with NGINX on the same host as the loopback-published Compose service. Use a maintained NGINX release with the http2 on directive (introduced in 1.25.1), not the deprecated listen ... http2 syntax.

The sample splits two prefix locations because they do not share an access model. location /v1 is the OpenAI-compatible inference base: preserve provider Authorization and routing headers, and allowlist API clients. location / covers everything else, including the dashboard and operator plane (/dash/, /metrics/, /admin/) plus the application-open POST /session, /healthz, /readyz and brand/PWA paths: allowlist operators, and keep Host plus the session cookie so Settings origin checks and the live feed work; the comparison is case-insensitive, so a case-preserving ingress is fine. The /session handshake is open at millivolt so login works, but this location still allowlists it at the edge. A URL-capable MCP client uses this location with /mcp appended and the operator Bearer header: the application's MCP endpoint is Bearer-only and refuses the session cookie. Shared streaming proxy settings live on the server so both SSE paths stay unbuffered. Prefix /v1 wins over / for /v1/chat/completions and /v1/models; GET /models without that prefix follows location / unless you add an exact /models block with the inference rules.

  1. Start the normal Compose deployment, retaining its loopback-only host port.
  2. Copy the example into your NGINX HTTP configuration. Replace its example hostname, certificate paths and the two TEST-NET allowlists. The supplied address is a placeholder; unmatched clients are denied. The lists may differ when operators and API clients are not the same sources. Set proxy_pass to the chosen host port if MILLIVOLT_PORT is not 8080.
  3. Validate with nginx -t before reloading your existing NGINX service. Confirm a trusted client and a denied client on both / and /v1.
  4. Use https://your-hostname/ for the dashboard and https://your-hostname/v1 as the client API base. Keep sending the actual upstream base/key as described in the client setup.

The sample is a server block, included under NGINX's existing http context, not a replacement for the entire installation configuration. It does not issue certificates or configure your firewall. Do not expose the original proxy port alongside the protected HTTPS endpoint.

Streaming and request handling

The example disables response/request buffering and proxy retries so the edge does not batch SSE events or independently replay inference. It leaves HTTP statuses intact, disables edge compression/caching, and ignores special upstream X-Accel-* controls for buffering, redirects, rate limits, caching and charset. Millivolt uses SSE, not a WebSocket endpoint; no Upgrade block is needed.

It preserves the external Host, including a nondefault port, so browser-origin checks on Settings and other mutations keep working. It also overwrites X-Forwarded-Proto with this server's $scheme, so a TLS presentation reaches millivolt as https and the session cookie carries Secure; a client-supplied value cannot spoof it. Overwriting is the recommended form. If your ingress appends its scheme instead ($http_x_forwarded_proto,$scheme), the application still marks the request secure when any token does, so a client-supplied http in first position cannot downgrade the cookie.

The sample also resolves the client address with real_ip:

set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;

Without it, millivolt sees this hop's address as RemoteAddr for every request and the per-source authentication throttle gets one bucket, so a single wrong guess locks out every operator. set_real_ip_from lists the addresses allowed to send a replacement address: this host's own loopback. If another load balancer, tunnel or proxy fronts NGINX, add its address range there and set real_ip_recursive on once every hop in the chain is listed. real_ip walks X-Forwarded-For from the right and keeps the last untrusted address, so a client that prepends a forged entry cannot displace the real one; NGINX's default $proxy_add_x_forwarded_for then appends the resolved address on the way inbound. The application reads the rightmost element and never trusts the left side. The resolved address is a throttle key, not an identity: keep the location allowlists in force. Those lists evaluate the resolved address, so a caller that already reaches this host's loopback chooses what they see: they separate remote networks from each other and from loopback callers, not loopback callers from each other.

NGINX's body-size gate is disabled in the sample: millivolt's existing max_request_bytes remains the application limit, rather than an accidentally smaller duplicate at ingress. Choose edge idle timeouts for your deployment. proxy_read_timeout limits the gap between upstream reads, not the total lifetime of a stream.

These behaviors follow the official proxy module, HTTP core module, HTTP/2 module and realip module.

Credentials and access control

The sample uses a trusted IP/VPN allowlist on each location, preserving provider Authorization on /v1 and the operator Bearer or session cookie on /. Replace each placeholder with the addresses you intend to trust for that plane. If another load balancer is in front of NGINX, list its addresses in set_real_ip_from and switch real_ip_recursive on, so the trusted chain matches reality; a hop that appends to X-Forwarded-For without being listed is ignored on the left of the real client. TLS encrypts traffic but does not authorize callers. A secure presentation also makes millivolt answer Strict-Transport-Security: max-age=15552000 (180 days, no includeSubDomains) so a browser pins the scheme; NGINX's own add_header is not needed for it, and adding a second HSTS header at the edge would send two values. See NGINX's access module and TLS configuration.

If you instead add HTTP Basic authentication on /v1, Authorization contains the ingress credential and cannot simultaneously contain the provider bearer key. Supply the provider key through X-Proxy-Key, and clear the consumed ingress header before forwarding with proxy_set_header Authorization "";. The same conflict applies on /: a browser behind Basic auth cannot also present the dashboard's Bearer credential in the same header, so gated actions fail. Prefer an address/VPN allowlist as in the sample, or an ingress that adds its own credential through a different mechanism and forwards Authorization untouched. Do not put Basic auth on /v1 to "protect inference" while leaving / on Bearer: that still overwrites the provider key. The same separation applies to other ingress-only credentials: do not forward them to an LLM provider. Clients must support your ingress authentication and the proxy's custom routing headers.

The session handshake lives at /session, outside the admin plane, which supports one specific pattern: an external authentication layer may front /admin/* alone, because millivolt's own login stays reachable and the mutation and settings routes become double-gated, the external layer plus millivolt's credential. Operators fronting /admin this way must still pass the session cookie and Host through to the proxy, since the dashboard's fetch-based mutations rely on both.

Container networking and other ingress servers

When NGINX itself is a container, 127.0.0.1 points back to NGINX, not millivolt. Attach it to the same private Docker network and use http://millivolt:8080 as its upstream instead. Only the edge needs a publicly reachable port. Do not bind millivolt to loopback inside its container, which would prevent peer access. MILLIVOLT_PORT changes host publication, not this internal service port.

For another ingress implementation, preserve these same boundaries: protect the origin, keep inference and the operator plane as separate access rules, forward the original authority and required custom headers, allow streaming without response buffering, avoid automatic inference retries, and choose upload/idle limits deliberately. Subpath hosting is not the supplied deployment: dashboard assets and routes expect the origin root.

search

Search the docs

Search titles, headings, and body text. Press / from anywhere.