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.
222 lines
9.5 KiB
Markdown
222 lines
9.5 KiB
Markdown
# neuronetz-gateway — Deployment
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## The one rule that must never break
|
|
|
|
> ## ⛔ Ollama is NEVER exposed to the host or the internet.
|
|
>
|
|
> The `ollama` service in `docker-compose.yml` has **no `ports:` mapping** and must never
|
|
> get one. Ollama is reachable only on the internal Docker network as `ollama:11434`.
|
|
> 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**, **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.
|
|
- 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 (production — jwilder-proxy)
|
|
|
|
```bash
|
|
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
|
|
# - 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. 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.
|
|
|
|
# 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.
|
|
|
|
# 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"}]}'
|
|
```
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Pointing at a real Ollama backend
|
|
|
|
The gateway reaches Ollama via `OLLAMA_BASE_URL`. In the bundled stack this is the in-stack
|
|
`ollama` service: `OLLAMA_BASE_URL=http://ollama:11434`.
|
|
|
|
To use an **existing/external** Ollama host instead:
|
|
|
|
1. Remove the `ollama` service from `docker-compose.yml` (or leave it; it just won't be used).
|
|
2. Set `OLLAMA_BASE_URL` to the backend address reachable from the gateway container, e.g.
|
|
`http://10.0.0.5:11434` or an internal DNS name.
|
|
3. Ensure that backend is itself **not** exposed to the internet — the gateway is the only
|
|
thing that should ever reach it. Use a private network / firewall rule, not a public port.
|
|
4. Pull the models you want available on that backend. They appear in tenants' effective sets
|
|
automatically on the next discovery refresh (SPEC §4.6) — no gateway config change for
|
|
`allow_all_models` tenants.
|
|
|
|
Discovery polls `OLLAMA_BASE_URL/api/tags` every `MODEL_DISCOVERY_REFRESH_S` seconds. If the
|
|
backend is unreachable, the discovered set is empty and requests **fail closed**.
|
|
|
|
---
|
|
|
|
## Environment reference (SPEC §7)
|
|
|
|
All configuration is via environment variables, validated by Pydantic Settings on boot. Boot
|
|
**fails loudly** on invalid config. See [`.env.example`](../.env.example) for a copyable file.
|
|
|
|
### Service
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `GATEWAY_BIND_HOST` | `0.0.0.0` | Bind-all inside the container. |
|
|
| `GATEWAY_BIND_PORT` | `8080` | Internal port; never published directly in prod. |
|
|
| `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,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 |
|
|
|---|---|---|
|
|
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Internal address of the backend. |
|
|
| `OLLAMA_CONNECT_TIMEOUT_S` | `5` | |
|
|
| `OLLAMA_READ_TIMEOUT_S` | `600` | Long, for slow generations. |
|
|
| `OLLAMA_MAX_CONNECTIONS` | `64` | httpx pool size. |
|
|
|
|
### Model discovery (§4.6)
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `MODEL_DISCOVERY_REFRESH_S` | `60` | How often to re-query `/api/tags`. |
|
|
| `MODEL_DISCOVERY_CACHE_TTL_S` | `120` | Redis TTL for the discovered set. |
|
|
|
|
### Database
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `DATABASE_URL` | `postgresql+asyncpg://…` | asyncpg driver. |
|
|
| `DATABASE_POOL_SIZE` | `10` | |
|
|
| `DATABASE_POOL_OVERFLOW` | `20` | |
|
|
|
|
### Redis
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `REDIS_URL` | `redis://redis:6379/0` | |
|
|
| `REDIS_KEY_CACHE_TTL_S` | `60` | Resolved-key cache TTL. |
|
|
|
|
### Limits (defaults; per-tenant/key DB overrides win)
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `DEFAULT_RPM` | `60` | |
|
|
| `DEFAULT_TPM` | `100000` | |
|
|
| `DEFAULT_CONCURRENT` | `8` | |
|
|
| `MAX_REQUEST_BODY_BYTES` | `262144` | 256 KiB request cap. |
|
|
| `MAX_NUM_PREDICT` | `4096` | Hard cap on requested completion tokens. |
|
|
|
|
### Security
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `ARGON2_TIME_COST` | `3` | |
|
|
| `ARGON2_MEMORY_COST_KIB` | `65536` | 64 MiB. |
|
|
| `ARGON2_PARALLELISM` | `4` | |
|
|
| `AUTH_FAILURE_RATE_LIMIT_PER_IP_PER_MIN` | `20` | Throttles auth brute-force per source IP. |
|
|
|
|
### Audit
|
|
| Var | Default | Notes |
|
|
|---|---|---|
|
|
| `AUDIT_BUFFER_SIZE` | `1000` | Ring buffer; full ⇒ deny mode. |
|
|
| `PROMPT_LOG_DEFAULT_RETENTION_DAYS` | `30` | |
|
|
| `AUDIT_LOG_DEFAULT_RETENTION_DAYS` | `365` | |
|
|
|
|
---
|
|
|
|
## TLS & security headers
|
|
|
|
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:
|
|
|
|
```
|
|
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;
|
|
```
|
|
|
|
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.
|
|
|
|
---
|
|
|
|
## Non-Compose (systemd)
|
|
|
|
A systemd unit is provided for hosts that run the image directly (`ops/systemd/`). The
|
|
gateway still requires reachable Postgres, Redis, and Ollama, and the same environment
|
|
variables. TLS in that topology is whatever fronts the host (Caddy, nginx, a load balancer) —
|
|
**Ollama still must not be publicly reachable.**
|
|
|
|
---
|
|
|
|
## Upgrades & migrations
|
|
|
|
The gateway runs `alembic upgrade head` on container start, so a normal
|
|
`docker compose up -d --build` after pulling a new version applies pending migrations. For
|
|
zero-downtime upgrades, run migrations as a one-off
|
|
(`docker compose run --rm gateway alembic upgrade head`) before rolling the service.
|