Deploying Vanzik in Production
vanzik v2.0updated view on github
This guide covers what you must get right before exposing Vanzik beyond localhost:
- Setting an API key (and what happens if you do not)
- Running behind a TLS-terminating reverse proxy
- Configuring CORS
- Understanding the unauthenticated
/v1/checkendpoint - Deploying the MCP Gateway and LLM API Proxy
- Outbound connections
Each section shows the exact flag/setting, the failure mode if you skip it, and a worked example.
TL;DR — minimum safe flags for a production server behind an HTTPS proxy:
vanzik server \ --policy /etc/vanzik/policy.yaml \ --api-key "$VANZIK_API_KEY" \ --base-url https://guard.example.com \ --allowed-origin https://app.example.com \ --tls-terminated-upstream \ --dashboard \ --audit-log /var/lib/vanzik/audit.jsonl \ --watch
1. Always set an API key#
Vanzik changes how it binds based on whether --api-key is set:
--api-key |
Bind address | Who can reach /v1/approve, /v1/deny, /v1/audit, /api/* |
|---|---|---|
| unset | 127.0.0.1:<port> only |
Processes on the same host (all gated endpoints become open) |
| set | 0.0.0.0:<port> (all interfaces) |
Anything with the Bearer token (or a valid session cookie) |
--bind <host> overrides the address: with --api-key set, Vanzik listens on exactly <host>:<port>. Without a key only loopback hosts (127.0.0.1, ::1, localhost) are accepted; a non-loopback --bind without --api-key exits 2 at startup rather than exposing the open gated endpoints.
Source: the bind-address logic in proxy.NewServer (pkg/proxy/server.go) and validateBind (cmd/vanzik/main.go).
Symptom if you forget: agents running on a different host get connection refused or connect: timed out. The server log shows:
INFO: binding to 127.0.0.1:8080 (localhost only) — set --api-key to listen on all interfaces
Fix:
export VANZIK_API_KEY="$(openssl rand -hex 32)"
vanzik server --api-key "$VANZIK_API_KEY" ...
The same key is used by vanzik approve|deny|status|audit (falls back to VANZIK_API_KEY env; see resolveAPIKey in cmd/vanzik/main.go) and by the Python / TypeScript SDKs (VANZIK_API_KEY env var).
Note:
/v1/check,/health,/metrics,/auth/login, and/auth/logoutare open by design even when--api-keyis set. See section 4.
2. Behind a TLS-terminating reverse proxy#
Most production deployments terminate TLS at an nginx / HAProxy / ALB / Cloudflare edge and speak plaintext HTTP to Vanzik on a private network. Two things break if you do not tell Vanzik about this:
2a. Session cookies without Secure → login loop#
/auth/login sets vanzik_session (HttpOnly) and vanzik_csrf cookies. The Secure attribute is decided at emit time by:
Secure: r.TLS != nil || s.cfg.TLSTerminatedUpstream
(handleLogin in pkg/proxy/auth.go.)
Modern browsers reject non-Secure cookies from SameSite=Strict origins served over HTTPS — so the cookie is set, then silently dropped on the next request, and /dashboard bounces back to the login form. Users see an infinite redirect loop.
Fix: pass --tls-terminated-upstream.
vanzik server --tls-terminated-upstream ...
This forces Secure=true on session cookies regardless of r.TLS. Only use it when TLS is actually terminated upstream — marking cookies Secure over plaintext would make them unreadable.
2b. Approval links point at the wrong host#
approval_url in POST /v1/check responses and in webhook/Slack notifications is built from cfg.BaseURL. If you do not set --base-url, Vanzik defaults to http://localhost:<port> (runServe in cmd/vanzik/main.go) — Slack messages will link to http://localhost:8080/... which is useless to a human approver on their laptop.
Fix: set --base-url to the public URL the dashboard is reachable at:
vanzik server --base-url https://guard.example.com ...
2c. nginx reference config#
server {
listen 443 ssl http2;
server_name guard.example.com;
ssl_certificate /etc/ssl/guard.example.com.crt;
ssl_certificate_key /etc/ssl/guard.example.com.key;
# Vanzik uses Server-Sent Events on /api/stream — disable buffering.
location /api/stream {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 90s;
}
}
With nginx on the same host, start Vanzik with --bind 127.0.0.1 as well as --api-key, so the plaintext port is reachable only through nginx and not from the network:
vanzik server --api-key "$VANZIK_API_KEY" --bind 127.0.0.1 --tls-terminated-upstream \
--base-url https://guard.example.com ...
2d. Docker Compose reference#
Every release publishes a multi-arch image (linux/amd64 and linux/arm64) to
ghcr.io/lictorate/vanzik, tagged with the version and latest. It
holds all three binaries; the server is the default entrypoint. Pin the
version tag in production so an upgrade is a deliberate change. The image sets
VANZIK_DISTRIBUTION=container, so the CLI's update notice (never shown
by server) suggests pulling a new image rather than the one-line installer. To build the
image yourself instead, replace image: with build: . at the repo root.
services:
vanzik:
image: ghcr.io/lictorate/vanzik:2.0.0
restart: unless-stopped
command: >
serve
--policy /etc/vanzik/default.yaml
--audit-log /var/lib/vanzik/audit.jsonl
--api-key ${VANZIK_API_KEY}
--base-url https://guard.example.com
--allowed-origin https://app.example.com
--tls-terminated-upstream
--dashboard --watch
environment:
- VANZIK_API_KEY
volumes:
- ./policy.yaml:/etc/vanzik/default.yaml:ro
- vanzik-audit:/var/lib/vanzik
expose:
- "8080"
networks: [internal]
nginx:
image: nginx:stable
ports: ["443:443"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/ssl:/etc/ssl:ro
depends_on: [vanzik]
networks: [internal]
volumes:
vanzik-audit:
networks:
internal:
In the nginx.conf you mount here, point both proxy_pass lines at the Compose service, http://vanzik:8080, not §2c's http://127.0.0.1:8080: inside the nginx container, 127.0.0.1 is nginx itself.
Mount your own policy over /etc/vanzik/default.yaml — not the whole /etc/vanzik directory, which would hide the image's baked-in default.
2e. Kubernetes stub#
apiVersion: apps/v1
kind: Deployment
metadata: { name: vanzik }
spec:
replicas: 1 # ⚠ per-instance limits on the default SQLite store; replicas > 1 needs --store-dsn postgres:// — see docs/OPERATIONS.md
selector:
matchLabels: { app: vanzik }
template:
metadata:
labels: { app: vanzik }
spec:
containers:
- name: vanzik
# Published per release; or build the Dockerfile and push it to a
# registry your cluster can pull from.
image: ghcr.io/lictorate/vanzik:2.0.0
args: # replaces the image's CMD, so --policy must be repeated
- serve
- --policy=/etc/vanzik/default.yaml
- --api-key=$(VANZIK_API_KEY)
- --base-url=https://guard.example.com
- --tls-terminated-upstream
- --allowed-origin=https://app.example.com
- --audit-log=/var/lib/vanzik/audit.jsonl
- --dashboard
env:
- name: VANZIK_API_KEY
valueFrom: { secretKeyRef: { name: vanzik, key: api-key } }
volumeMounts:
- name: audit
mountPath: /var/lib/vanzik
readinessProbe:
httpGet: { path: /health, port: 8080 }
volumes:
- name: audit
persistentVolumeClaim: { claimName: vanzik-audit }
Multi-instance: on the default SQLite store, rate-limit buckets and session-cost accumulators are per-instance —
replicas: > 1lets an agent burst past per-scope limits by hitting different pods. Point every replica at PostgreSQL (--store-dsn postgres://…) with a distinct--node-id(in Kubernetes, pass the pod name) to share approval / rate-limit / cost state; distributed limiting is bounded-overshoot, converging every--reconcile-interval(default 2s). Seedocs/OPERATIONS.md.
3. CORS#
--allowed-origin has two modes:
| Value | Behavior | Use when |
|---|---|---|
| unset (default) | Permissive-localhost: reflects any http://localhost:* or http://127.0.0.1:* origin. Everything else is rejected. |
Local development, same-host deployments. |
https://app.example.com (exact) |
Strict: only the named origin gets Access-Control-Allow-Origin back. |
Production, dashboard served from a known SPA origin. |
Source: withCORS in pkg/proxy/server.go.
Permissive-localhost is safe because:
- Session cookies are
SameSite=Strict, so cross-site pages cannot ride them. - State-changing endpoints require a CSRF token (
X-CSRF-Token) that a cross-origin page cannot read. - Origins like
http://localhost.evil.comare rejected — the trailing:on the localhost prefix blocks DNS-rebinding-style lookalikes.
For production, set the exact origin of whatever frontend calls the Vanzik HTTP API directly. If the only consumer is agents (which speak HTTP, not browsers), you can leave --allowed-origin empty — agents do not send Origin.
4. /v1/check is intentionally open#
Every other endpoint that accepts state-changing input (/v1/approve, /v1/deny, /v1/audit, all /api/*) requires either a Bearer token or a valid session + CSRF. /v1/check does not.
Why this is safe by design:
/v1/checkanswers a policy question; it does not execute anything. The agent still has to perform the action itself after receivingALLOW.- The response (ALLOW / DENY / REQUIRE_APPROVAL + matched rule name) leaks nothing that a determined attacker could not also derive by reading the policy file.
- Requiring auth on
/v1/checkwould force every guarded agent to hold a long-lived secret, which is a much larger attack surface than the policy decision itself.
If your threat model requires L7 auth on /v1/check anyway (e.g., a multi-tenant deployment where policy decisions must not leak between tenants), gate it at your reverse proxy:
location = /v1/check {
# Only allow requests from the agent subnet.
allow 10.0.0.0/8;
deny all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
Or require a header set by an authenticating proxy (e.g., Cloudflare Access, oauth2-proxy) and reject at the edge.
5. Deploying the MCP Gateway and LLM API Proxy (v0.5+)#
These are the wire-level enforcement points. Both are stateless — they fan every gated request out to the Vanzik server above.
Shape: one per agent host, bound to 127.0.0.1, with --guard-url pointing at the server.
vanzik-llm-proxy --listen 127.0.0.1:8081 --guard-url https://guard.example.com --policy /etc/vanzik/policy.yaml
vanzik-mcp-gateway --upstream "fs:npx -y @modelcontextprotocol/server-filesystem /tmp" \
--guard-url https://guard.example.com --policy /etc/vanzik/policy.yaml
Production defaults that matter:
--fail-mode=deny— if the Vanzik server is unreachable, refuse rather than passthrough.--listen 127.0.0.1:<port>— inbound is not authenticated unless you set--proxy-api-key. The LLM proxy refuses any non-loopback--listenwithout that key, including:<port>with no host (which binds every interface).- A stop timeout above 10 s (systemd
TimeoutStopSec, KubernetesterminationGracePeriodSeconds; their defaults, 90 s and 30 s, already are). On SIGTERM the LLM proxy drains in-flight requests for 10 s, then closes them; a longer host timeout doesn't extend that. It buffers tool calls inside streaming responses, so a hard kill truncates the client's response. - Keep the proxies and the server on the same release. The
/v1/checkwire protocol is frozen across 1.x and 2.x (COMPATIBILITY.md), so a mixed pair interoperates, but only same-release pairs are tested together.
Examples (systemd unit, Kubernetes sidecar, MCP client configs): QUICKSTART_MCP.md, QUICKSTART_LLM_PROXY.md. Wire-format reference: MCP_GATEWAY.md, LLM_API_PROXY.md.
6. Outbound connections#
vanzik server opens exactly the outbound connections you configure and nothing else:
| Connection | Configured by | Notes |
|---|---|---|
| Durable store | --store-dsn |
PostgreSQL only; the default SQLite store is a local file. |
| Notifiers (webhook, Slack) | notifications: in the policy YAML |
Payloads pass through the notifier redactor. |
There is no telemetry, no crash reporting, and no update check from server. The interactive vanzik subcommands (check, status, migrate, …) do query the GitHub Releases API once at startup for the update notice; set VANZIK_NO_UPDATE_CHECK=1 in scripted or air-gapped environments (CLI.md). The MCP gateway connects only to --guard-url and the downstream MCP servers it spawns; the LLM proxy connects only to --guard-url and the configured upstream provider. The complete actor and boundary list is in THREAT_MODEL.md.
Related docs#
docs/OPERATIONS.md— audit log rotation, multi-instance caveats, capacity limits.docs/OBSERVABILITY.md—/metrics, alert rules, response-header timings.docs/TROUBLESHOOTING.md— symptom-keyed diagnostics for the footguns above.docs/CONFIG.md— policy-file tunables (proxy.session.ttl, body caps, audit query bounds).