deploy: target jwilder-proxy production stack
Some checks failed
CI / ruff (push) Has been cancelled
CI / mypy --strict (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / bandit (push) Has been cancelled
CI / pip-audit (push) Has been cancelled

Production deployment now matches the host setup that already runs
neuronetz.ai / neuro-landing: the gateway sits behind the jwilder
nginx-proxy + acme-companion already on the host, instead of bundling
its own Caddy sidecar.

- docker-compose.yml: drop the Caddy service entirely. The gateway joins
  an external `proxy` Docker network (the same one neuronetz-web /
  neuronetz-www use) and advertises itself with VIRTUAL_HOST /
  VIRTUAL_PORT / LETSENCRYPT_HOST / LETSENCRYPT_EMAIL. nginx-proxy
  routes TLS-terminated traffic to it on the shared network;
  acme-companion handles Let's Encrypt issuance + renewal for
  api.neuronetz.ai automatically. NO host ports are published in this
  compose file anywhere — gateway, postgres, redis, ollama all stay
  unreachable from the host. Pinned container_names
  (neuronetz-gateway / -postgres / -redis / -ollama) for stable
  identification by nginx-proxy and ops scripts.
- .env.example: add GATEWAY_VIRTUAL_HOST + LETSENCRYPT_EMAIL; flip the
  default GATEWAY_TRUSTED_PROXIES to `127.0.0.1,nginx-proxy`.
- docs/DEPLOYMENT.md: the canonical path is now jwilder-proxy.
  Reorganized prerequisites + steps around it; documented adding HSTS
  and the other security headers via the nginx-proxy custom-config
  mechanism (/etc/nginx/vhost.d/<host>). The Caddy sidecar lives on as
  a documented alternative for hosts without jwilder-proxy
  (ops/caddy/Caddyfile.example is kept).

The Ollama-never-exposed non-negotiable is unchanged.
This commit is contained in:
Stephan Berbig
2026-05-26 20:55:20 +02:00
parent b47a09db91
commit b2ec32c852
3 changed files with 119 additions and 68 deletions

View File

@@ -1,8 +1,17 @@
# neuronetz-gateway — Deployment
Production deployment is a single Docker Compose stack: **Caddy + gateway + Postgres + Redis
+ Ollama**. Caddy is the only public-facing component; it terminates TLS via Let's Encrypt
for `api.neuronetz.ai` and reverse-proxies to the internal-only gateway.
Production deployment is a Docker Compose stack**gateway + Postgres + Redis + Ollama**
that sits behind the host's existing **jwilder/nginx-proxy** stack (the same one already
serving `neuronetz.ai` / `neuro-landing`). Public traffic enters via `nginx-proxy` and
`acme-companion`, which terminate TLS and obtain/renew the Let's Encrypt certificate for
`api.neuronetz.ai`. The gateway joins the host's external `proxy` Docker network alongside
the other public-facing containers and advertises itself with `VIRTUAL_HOST` /
`VIRTUAL_PORT`. Postgres, Redis, and Ollama stay on a private internal network with no
published ports.
> ▶ Don't have jwilder-proxy on the host? See
> [§ "Alternative: TLS via Caddy sidecar"](#alternative-tls-via-caddy-sidecar) — the
> `ops/caddy/Caddyfile.example` is shipped for that case.
> For the local, no-GPU demo (mock Ollama + playground), see [`PLAYGROUND.md`](PLAYGROUND.md)
> and run `./demo.sh`. This document is the **production** path.
@@ -18,53 +27,61 @@ for `api.neuronetz.ai` and reverse-proxies to the internal-only gateway.
> Publishing it would re-open the exact unauthenticated exposure this whole project exists
> to close (SPEC §1, §3; AGENT_PROMPT non-negotiable #2).
The same posture applies to **Postgres** and **Redis** in the production compose file — no
published ports. Only **Caddy** binds host ports (80/443, 443/udp for HTTP/3).
The same posture applies to **Postgres**, **Redis**, and the gateway itself in the
production compose file — **no published ports anywhere in this compose file**. Only
the host's jwilder `nginx-proxy` container binds 80/443; the gateway is reached via the
shared external `proxy` Docker network.
---
## Prerequisites
- A host with Docker + Docker Compose.
- DNS: `api.neuronetz.ai` → the host's public IP (for Let's Encrypt).
- Ports 80 and 443 reachable from the internet (ACME HTTP/TLS challenge + serving).
- A jwilder-proxy stack already running on the host, attached to an external Docker
network named `proxy`. Typically `jwilder/nginx-proxy` + `nginxproxy/acme-companion`,
the same setup serving `neuronetz.ai` / `neuro-landing`.
- DNS: `api.neuronetz.ai` → the host's public IP.
- Ports 80 and 443 already published by the jwilder-proxy container on that host (for
ACME HTTP-01 + serving). This compose file does **not** publish them itself.
---
## Steps
## Steps (production — jwilder-proxy)
```bash
git clone <repo> neuronetz-gateway && cd neuronetz-gateway
git clone ssh://git@gitea.neuronetz.ai:222/m17hr1l/neuronetz-gateway.git
cd neuronetz-gateway
# 1. Configure. Copy the example env and change EVERY secret.
cp .env.example .env
# - POSTGRES_PASSWORD: a strong, unique value
# - DATABASE_URL: must match the POSTGRES_* values
# - GATEWAY_LOG_FORMAT=json for production
# - POSTGRES_PASSWORD : a strong, unique value
# - GATEWAY_VIRTUAL_HOST : api.neuronetz.ai (read by nginx-proxy)
# - LETSENCRYPT_EMAIL : admin@neuronetz.ai (read by acme-companion)
# - GATEWAY_LOG_FORMAT=json : for production
# - GATEWAY_TRUSTED_PROXIES : 127.0.0.1,nginx-proxy
# 2. Configure Caddy for your domain + ACME email.
cp ops/caddy/Caddyfile.example ops/caddy/Caddyfile # then edit the site + email
# (docker-compose.yml mounts Caddyfile.example by default; point it at your edited file
# or edit in place.)
# 3. Bring up the full stack. The gateway runs `alembic upgrade head`, then serves.
# 2. Bring up the stack. The gateway joins the external `proxy` network and
# runs `alembic upgrade head` before serving.
docker compose up -d --build
# nginx-proxy observes the new container, generates an nginx vhost for
# api.neuronetz.ai, and acme-companion issues the cert via Let's Encrypt.
# Cert renewals are automatic.
# 4. Bootstrap a tenant + key (CLI runs inside the gateway container).
# 3. Bootstrap a tenant + key (CLI runs inside the gateway container).
docker compose exec gateway neuronetz-gateway create-tenant --name acme --rpm 120 --tpm 200000
docker compose exec gateway neuronetz-gateway create-key --tenant acme --name prod-server-1
# ^ prints the full key ONCE — store it in your secret manager now.
# 5. Smoke test (through Caddy / TLS).
# 4. Smoke test through public TLS.
curl https://api.neuronetz.ai/healthz
curl -N https://api.neuronetz.ai/v1/chat/completions \
-H "Authorization: Bearer nz_…" -H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","stream":true,"messages":[{"role":"user","content":"hi"}]}'
```
Caddy obtains and renews the certificate automatically. For local testing without a public
domain, use the `localhost { tls internal … }` block documented in `Caddyfile.example`
(trust Caddy's local CA or pass `-k` to curl).
The compose file pins `container_name: neuronetz-gateway` (and `neuronetz-postgres` /
`neuronetz-redis` / `neuronetz-ollama`) for stable identification by nginx-proxy and
for ops scripts.
---
@@ -102,7 +119,9 @@ All configuration is via environment variables, validated by Pydantic Settings o
| `GATEWAY_LOG_LEVEL` | `INFO` | |
| `GATEWAY_LOG_FORMAT` | `json` | `json` in prod, `console` for local dev. |
| `GATEWAY_REQUEST_ID_HEADER` | `X-Request-ID` | |
| `GATEWAY_TRUSTED_PROXIES` | `127.0.0.1,caddy` | Sources trusted for `X-Forwarded-For`. |
| `GATEWAY_TRUSTED_PROXIES` | `127.0.0.1,nginx-proxy` | Sources trusted for `X-Forwarded-For`. Set to your front-proxy's container name / IP. |
| `GATEWAY_VIRTUAL_HOST` | `api.neuronetz.ai` | Read by jwilder `nginx-proxy` and `acme-companion`. |
| `LETSENCRYPT_EMAIL` | `admin@neuronetz.ai` | Read by `acme-companion`. |
### Upstream (Ollama)
| Var | Default | Notes |
@@ -157,17 +176,31 @@ All configuration is via environment variables, validated by Pydantic Settings o
---
## TLS & security headers (Caddy)
## TLS & security headers
`ops/caddy/Caddyfile.example` already sets:
In the canonical (jwilder-proxy) setup, TLS termination and security headers belong on
the host's `nginx-proxy` container, not in this repo. Use the standard nginx-proxy
custom-config mechanism (`/etc/nginx/vhost.d/api.neuronetz.ai`) to add HSTS and the rest:
- **HSTS** `max-age=63072000; includeSubDomains; preload`
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer`
- strips `Server` and `X-Powered-By`
```
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
```
Edit the site address and ACME `email` before deploying.
If you prefer to terminate TLS in this repo (no jwilder-proxy on the host), see the
section below.
<a id="alternative-tls-via-caddy-sidecar"></a>
## Alternative: TLS via Caddy sidecar
`ops/caddy/Caddyfile.example` is provided for hosts without jwilder-proxy. It sets HSTS,
the security headers above, strips the `Server` header, and obtains a Let's Encrypt
cert. To use it, add a `caddy` service to your local copy of `docker-compose.yml`
(binding host 80/443), drop the gateway's `VIRTUAL_HOST` / `LETSENCRYPT_HOST` env vars,
and remove the `proxy` external-network requirement. The Caddyfile itself is self-
documenting; edit the site address and ACME `email` before deploying.
---