Repository navigation
Nginx and API Gateway
Canonical source:
docs/08-runtime-devops/nginx-reference.md— this page is rendered from it on every publish; edit the source.
Nginx runs as a reverse proxy on port 4000, routing all API requests from the frontend to the appropriate backend service. It handles DNS resolution, timeout management, security headers, and SSE (Server-Sent Events) streaming support.
Container: claw-nginx (nginx:alpine)
Host ports: 443 (HTTPS), 4000 → 80, and in prod also 80 → 80
The configuration is split across three files, all bind-mounted read-only:
| File | Mounted at | Holds |
|---|---|---|
infra/nginx/nginx.conf |
/etc/nginx/nginx.conf |
http{} settings, the port-80 block, the default (mkcert) TLS server block |
infra/nginx/locations.conf |
/etc/nginx/claw/locations.conf |
every location — the routing table below |
infra/nginx/public-tls/*.conf |
/etc/nginx/claw/public-tls/ |
generated Let's Encrypt server blocks (gitignored; empty on a laptop) |
The routing table is a separate file because a production server serves the same
routes under two certificates — mkcert internally, Let's Encrypt for the public
domain — and each TLS server block includes it. Duplicating ~52 location
blocks per certificate guarantees the copies drift, and a drifted route is an
outage that reproduces on only one hostname. See
tls-setup.md.
All routes are prefixed with /api/v1/. Nginx uses dynamic variables for upstream resolution to survive service restarts without stale DNS.
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/auth/* |
http://auth-service:4001 |
Login, refresh, logout, me |
/api/v1/users/* |
http://auth-service:4001 |
User CRUD (admin only) |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/threads/* |
http://chat-service:4002 |
Rewritten to /api/v1/chat-threads/*
|
/api/v1/messages/* |
http://chat-service:4002 |
Rewritten to /api/v1/chat-messages/*
|
/api/v1/chat-threads/* |
http://chat-service:4002 |
Direct path |
/api/v1/chat-messages/stream |
http://chat-service:4002 |
SSE endpoint (special config) |
/api/v1/chat-messages/* |
http://chat-service:4002 |
Message CRUD, feedback, regenerate |
Important: The /api/v1/chat-messages/stream location must be defined BEFORE the general /api/v1/chat-messages location to ensure Nginx matches the more specific SSE route first.
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/connectors/* |
http://connector-service:4003 |
Connector CRUD, test, sync |
/api/v1/models/* |
http://connector-service:4003 |
Model listing |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/routing/* |
http://routing-service:4004 |
Policies, decisions, evaluate |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/memory/* |
http://memory-service:4005 |
Rewritten to /api/v1/memories/*
|
/api/v1/context/* |
http://memory-service:4005 |
Rewritten to /api/v1/context-packs/*
|
/api/v1/memories/* |
http://memory-service:4005 |
Direct path |
/api/v1/context-packs/* |
http://memory-service:4005 |
Direct path |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/files/* |
http://file-service:4006 |
Upload, list, chunks |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/audits/* |
http://audit-service:4007 |
Audit log queries |
/api/v1/usage/* |
http://audit-service:4007 |
Usage statistics |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/ollama/* |
http://ollama-service:4008 |
Model management, pull, generate |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/health |
http://health-service:4009 |
Aggregated health |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/grafana/* |
http://grafana:3000 |
Behind auth_request → POST /api/v1/auth/grafana-access/verify (auth-service). See ADR-115. |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/client-logs/* |
http://client-logs-service:4010 |
Frontend log ingestion |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/server-logs/* |
http://server-logs-service:4011 |
Backend log viewer |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/images |
http://image-service:4012 |
Image generation requests |
/api/v1/images/* |
http://image-service:4012 |
SSE endpoint (special config) |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/thread-publications* |
https://threads-service:4019 |
Public Thread publications; the legacy /api/v1/threads alias stays on chat |
| Frontend Path | Backend Destination | Notes |
|---|---|---|
/api/v1/plugins/* |
https://plugin-service:4021 |
Catalog, installs, consent, credentials, admin. /api/v1/internal/* is never routed (closed by the 404 block) |
Two endpoints use SSE for real-time streaming: chat message streaming and image generation progress.
location /api/v1/chat-messages/stream {
set $chat_backend http://chat-service:4002;
proxy_pass $chat_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 86400;
proxy_buffering off;
proxy_cache off;
}location /api/v1/images/ {
set $image_backend http://image-service:4012;
proxy_pass $image_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 86400;
proxy_buffering off;
proxy_cache off;
}| Directive | Value | Purpose |
|---|---|---|
proxy_http_version |
1.1 |
Required for persistent connections (HTTP/1.0 closes after each response) |
proxy_set_header Connection |
"" |
Removes the Connection: close header to keep the connection open |
proxy_read_timeout |
86400 |
24-hour timeout to prevent Nginx from closing long-lived SSE connections |
proxy_buffering |
off |
Disables response buffering so events are sent to the client immediately |
proxy_cache |
off |
Disables caching of SSE responses |
proxy_connect_timeout 10s; # Time to establish connection to upstream
proxy_send_timeout 60s; # Time to send request to upstream
proxy_read_timeout 60s; # Time to read response from upstream (overridden for SSE)These apply to all non-SSE routes. SSE endpoints override proxy_read_timeout to 86400s (24 hours).
client_max_body_size 75m;This allows file uploads up to 75 MB. This limit applies to all routes. If you need larger uploads, increase this value and restart the nginx container.
Applied globally to all responses:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "0" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
X-Frame-Options |
DENY |
Prevents clickjacking (no iframe embedding) |
X-XSS-Protection |
0 |
Disables legacy XSS filter (modern CSP preferred) |
Referrer-Policy |
strict-origin-when-cross-origin |
Controls referrer information sent with requests |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Disables access to sensitive browser APIs |
Additionally, server_tokens off; hides the Nginx version from response headers.
Set on all proxied requests:
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Origin $http_origin;| Header | Value | Purpose |
|---|---|---|
Host |
$host |
Preserves the original Host header |
X-Real-IP |
$remote_addr |
Passes the client's real IP to backend |
X-Forwarded-For |
$proxy_add_x_forwarded_for |
Appends client IP to the forwarded chain |
X-Forwarded-Proto |
$scheme |
Indicates the original protocol (http/https) |
Origin |
$http_origin |
Passes the Origin header for CORS handling |
CF-IPCountry, CF-Connecting-IP and True-Client-IP are blanked next to them,
so a client cannot pass one off as something the edge produced.
Every service's global throttler counts anonymous traffic by X-Real-IP
(buildThrottlerOptions in @claw/shared-auth; rule:
rules/08-security-rules.md §Rate limiting).
nginx overwrites it with $remote_addr, so a client cannot choose its own key.
X-Forwarded-For is never used as a key: nginx appends to it, and its left-most
entry is client-controlled.
A location that declares any proxy_set_header (an SSE Connection "", a
webhook Content-Type, the frontend Upgrade) inherits NONE of the server-level
headers. Until 2026-10-02 the chat/agent/image/file-generation streams, the
llama.cpp inference and pull-progress routes, the payment webhooks, the Grafana
auth subrequest and the frontend catch-all all dropped X-Real-IP, so every
visitor of those routes landed in one bucket. Each now restates the shared set;
the frontend block restates only the client-address ones, to leave the Host the
Next.js server sees unchanged. tools/__tests__/nginx-real-ip-restated.test.mjs
fails on any block that sets headers without X-Real-IP $remote_addr, in both
locations.conf and nginx.distributed.conf.template.
Verify live (forged headers must not move the counter, a second real client must start its own):
curl -sk -D - -o /dev/null -H 'X-Real-IP: 1.1.1.1' https://claw.local/api/v1/health | grep -i ratelimit-remaining
curl -sk -D - -o /dev/null -H 'X-Real-IP: 2.2.2.2' https://claw.local/api/v1/health | grep -i ratelimit-remaining # one lower
MSYS_NO_PATHCONV=1 docker exec claw-nginx curl -sk -D - -o /dev/null -H 'Host: claw.local' https://127.0.0.1/api/v1/health | grep -i ratelimit-remaining # fresh bucketA call carrying the inter-service Service token gets no X-RateLimit-* headers:
it skips the throttler.
map $request_method $auth_post_key { default ""; POST $binary_remote_addr; }
limit_req_zone $auth_post_key zone=auth_routes:10m rate=60r/m;
# location /api/v1/auth and location /api/v1/agent/auth/
limit_req zone=auth_routes burst=30 nodelay;-
POST only. An empty key is not counted, so
GET /auth/meon every page load never spends it. - A backstop, not the limit. The real per-route budgets (login 10 / 15 min per IP + address, register 5 / h per IP, ...) live in auth-service and agent-service (rules/58, ADR-147). This zone is what still limits these routes when Redis is down and the services fail open.
- An nginx 429 has no
codein its body and no service log line; a service 429 hascode: RATE_LIMITEDandRetry-After. Runbook: runbook-auth-rate-limits.md. - Same zone, same location, in
nginx.distributed.conf.template.
resolver 127.0.0.11 valid=5s ipv6=off;
resolver_timeout 3s;-
127.0.0.11is Docker's embedded DNS server -
valid=5smeans DNS results are cached for 5 seconds, then re-resolved -
ipv6=offdisables IPv6 resolution (not needed in the bridge network) -
resolver_timeout 3ssets the maximum time for a DNS query
This configuration allows Nginx to survive service restarts. When a service container is recreated, its IP address changes. The 5-second TTL ensures Nginx picks up the new IP quickly.
Each location block uses a variable for the upstream:
location /api/v1/auth {
set $auth_backend http://auth-service:4001;
proxy_pass $auth_backend;
}This is required for dynamic DNS resolution. If the URL were passed directly to proxy_pass without a variable, Nginx would resolve the hostname at startup only and cache it indefinitely, causing failures when containers restart.
Some frontend paths differ from backend API paths. Nginx rewrites these:
| Frontend Path | Rewritten To | Service |
|---|---|---|
/api/v1/threads/* |
/api/v1/chat-threads/* |
chat-service |
/api/v1/messages/* |
/api/v1/chat-messages/* |
chat-service |
/api/v1/memory/* |
/api/v1/memories/* |
memory-service |
/api/v1/context/* |
/api/v1/context-packs/* |
memory-service |
The rewrite uses break flag, which means the rewritten URL is processed internally without an external redirect:
rewrite ^/api/v1/threads/(.*)$ /api/v1/chat-threads/$1 break;The current configuration does not use upstream blocks because each service runs as a single instance. If horizontal scaling is needed, add upstream blocks:
upstream chat-backend {
server chat-service-1:4002;
server chat-service-2:4002;
server chat-service-3:4002;
}Then replace proxy_pass $chat_backend; with proxy_pass http://chat-backend;.
Nginx waits for ALL backend services to be healthy before starting:
depends_on:
auth-service: { condition: service_healthy }
chat-service: { condition: service_healthy }
connector-service: { condition: service_healthy }
routing-service: { condition: service_healthy }
memory-service: { condition: service_healthy }
file-service: { condition: service_healthy }
audit-service: { condition: service_healthy }
ollama-service: { condition: service_healthy }
client-logs-service: { condition: service_healthy }
server-logs-service: { condition: service_healthy }
health-service: { condition: service_healthy }This means Nginx is the last infrastructure component to start, ensuring all routes are functional when the proxy becomes available.
- Edit the right file:
-
adding or changing a route →
infra/nginx/locations.conf. It is included by every TLS server block, so the route is served under both the mkcert and the Let's Encrypt certificate automatically. Never copy alocationinto a server block. -
TLS, timeouts, rate-limit zones, security headers →
infra/nginx/nginx.conf. -
never hand-edit
infra/nginx/public-tls/*.conf; it is generated byscripts/install-letsencrypt.shand rewritten on every run.
-
adding or changing a route →
- Check the config parses before applying it — a bad reload on a running
server is avoidable:
docker exec claw-nginx nginx -t - Apply it. A reload is graceful and drops no connections:
A restart is only needed when the mount set itself changed (a new file or a new published port), and that requires a recreate, not a restart:
docker exec claw-nginx nginx -s reload./scripts/claw.sh --prod up. - Verify routes work:
curl -s -o /dev/null -w "%{http_code}\n" https://localhost/api/v1/health
All three files are mounted read-only (:ro), so edits on the host take effect
on the next reload — but a file that was not mounted at container-create time
will not appear by reloading.
After
nginx -s reload, old workers keep accepting connections until they drain. A check fired immediately afterwards can still be answered by the old config; retry for a second or two before concluding a change did not apply.
nginx.conf and nginx.distributed.conf.template run proxy_hide_header X-Content-Type-Options; before adding nosniff, so the copy services set
themselves (helmet, download and stream controllers, kept for direct access)
does not double up. A location with its own add_header stops inheriting the
outer ones, so each such block re-adds nosniff. Guarded by
tools/__tests__/nginx-nosniff-single.test.mjs.
ClawAI Wiki · source-controlled under wiki/ · generated/mirrored from the current repository knowledge layer.
ClawAI Wiki
Product
- Product-Vision
- Flagship-Features
- Business-Overview
- Requirements-Register
- Drift-Log
- Runtime-Managed-Connector-Providers
- Threads
- ADR-Index
Architecture
- System-at-a-Glance
- Architecture-Map
- System-Architecture
- Message-Flow
- End-to-End-Data-Flow
- Routing-Engine
- Event-Bus
- Security-Architecture
Platform
- Backend-Services
- Frontend-Architecture
- Shared-Packages
- Database-Reference
- API-Reference
- Provider-Catalog
- Memory-and-Context-Architecture
- File-Support-Architecture
- Research-and-Web-Crawling
Runtime & Ops
- Docker-Guide
- Nginx-Reference
- CI-CD-Pipeline
- Logging-and-Observability
- Operational-Runbooks
- Troubleshooting
Quality
Engineering OS
- AI-Native-Engineering-OS
- Generated-Manifests
- Rules-Catalog
- Skills-Catalog
- Context-Layer
- Engineering-Memory
- Tooling-and-Automation
- Scripts-and-Operations
- ESLint-and-Architecture-Enforcement
- Extended-Work-Skills
Agents
Reference