Files
ai-memory/docs/https-via-proxy.md
T
AkitaOnRailsandClaude Opus 4.8 da13f091f9 docs(https-via-proxy): recipe for TLS-inspecting firewalls / MITM roots (#954)
ai-memory's reqwest client is built with rustls-tls-native-roots, so it trusts
the OS store and honors SSL_CERT_FILE/SSL_CERT_DIR. Document how to give the
container a combined CA bundle (public roots + the interception root) so
outbound LLM/embedding calls stop failing with UnknownIssuer behind a
corporate MITM appliance or inspecting antivirus. Cross-referenced from
deploy.md's provider-failures troubleshooting bullet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDbhmszrjG9s5MrPrTuNtm
2026-09-29 12:15:43 -03:00

575 lines
23 KiB
Markdown

# HTTPS via a reverse proxy
> ai-memory does **not** terminate TLS itself, by design. This page is
> the operator's guide to fronting it with a mature TLS terminator
> (Caddy, Cloudflare Tunnel, nginx) so tokens and `/web` cookies
> travel encrypted between clients and the server. Default install
> stays plain HTTP on loopback — no change for existing users on
> upgrade.
## When you don't need this
Skip TLS entirely if you're in one of these shapes — the security
budget is better spent elsewhere:
- **Single-user, stdio MCP transport.** `claude mcp add ai-memory -- ai-memory serve --transport stdio` never touches the network. No TLS to worry about.
- **Loopback-only HTTP server**, single user, no `/web` access from another machine. `127.0.0.1:49374` is unreachable from outside the host; TLS protects nothing here that the kernel's loopback boundary doesn't already.
- **Local dev / one-off experiments.** Bring TLS in when the deployment shape calls for it; not before.
The single-user happy path documented in the README's Quick Start is
this case. Most ai-memory installs never need a proxy.
## When you do need this
Add a TLS-terminating proxy in front of ai-memory when any of these
apply:
- **Multi-user mode is on** (at least one user row exists; `[auth].token_pepper`
is the credential prerequisite). Native `aim_` keys travel between clients and
the server — sniffable over plain HTTP on the LAN. See
[`docs/users.md`](users.md).
- **The server is bound beyond loopback** (`AI_MEMORY_BIND=0.0.0.0:49374` or a LAN-routable IP). Anyone on the network segment sees plaintext token traffic and `/web` cookies.
- **You access `/web` from a different machine** than the one running ai-memory. The browser session cookie set after password login lives in the clear over HTTP.
- **You're exposing ai-memory beyond the LAN.** Cloudflare Tunnel or a public-domain Caddy with Let's Encrypt are the two patterns most homelab operators land on.
ai-memory refuses unauthenticated non-loopback HTTP by default. Machine-only
Bearer deployments may still bind authenticated plain HTTP and receive a loud
warning because those credentials remain sniffable. Human authentication is
stricter: a non-loopback listener refuses startup unless
`AI_MEMORY_AUTH__SECURE_COOKIE=true`, which is the operator's explicit signal
that a trusted HTTPS proxy owns the browser-facing edge. Non-Secure human
cookies are supported only on an actual loopback listener.
## Pick a path
| Path | Best for | What's needed externally |
|---|---|---|
| **Caddy + public domain + Let's Encrypt** | Operators with a domain name + port 80/443 reachable from the internet (most homelabs behind a forwarding router). | DNS A/AAAA record pointing at your IP. |
| **Caddy + internal CA (LAN-only)** | LAN-only multi-user, no public exposure. Each client machine has to trust Caddy's root cert once. | One-time root cert install per client. |
| **Cloudflare Tunnel** | "I don't want to open ports on my router" — outbound-only tunnel, TLS terminated at Cloudflare's edge. | A Cloudflare account (free tier works) + a domain on Cloudflare. |
| **External cert files (Caddy or nginx)** | You already have a corporate or homelab CA issuing certs to your services. | The cert/key files, however your environment produces them. |
| **nginx** | You already run nginx for other services and want one config language. | Same as Caddy: a domain or files. |
The compose templates in `docker/` are ready to copy:
- [`docker/compose.tls.caddy.yml`](../docker/compose.tls.caddy.yml) — Caddy front, both LE and internal-CA variants documented inline.
- [`docker/compose.tls.cloudflared.yml`](../docker/compose.tls.cloudflared.yml) — Cloudflare Tunnel sidecar, zero open ports.
The sections below walk through each.
---
## Path 1 — Caddy + public domain + Let's Encrypt
Cleanest path when you have a domain and port 80/443 reachable. Caddy
auto-issues + auto-renews from Let's Encrypt with no operator
involvement after first start.
### Compose template
Copy `docker/compose.tls.caddy.yml` to your deploy directory. The
relevant block is:
```yaml
services:
ai-memory:
image: akitaonrails/ai-memory:latest
container_name: ai-memory
restart: unless-stopped
expose:
- "49374" # internal only — Caddy reaches it over the docker network
volumes:
- ai-memory-data:/data
env_file:
- .env.production # AI_MEMORY_AUTH_TOKEN + AI_MEMORY_ALLOWED_HOSTS + your LLM provider creds
caddy:
image: caddy:2-alpine
container_name: ai-memory-caddy
restart: unless-stopped
ports:
- "80:80" # for Let's Encrypt HTTP-01 challenges
- "443:443" # the only port your clients touch
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
ai-memory-data:
name: ai-memory-data
caddy-data: # cert + ACME account key live here. Back this up.
caddy-config:
```
### Caddyfile
A complete one, three lines that actually matter:
```caddyfile
memory.example.com {
reverse_proxy ai-memory:49374
}
```
Caddy will:
1. Solve the HTTP-01 ACME challenge on first request to that hostname.
2. Issue a Let's Encrypt cert.
3. Renew automatically 30 days before expiry.
4. Forward `Authorization: Bearer ...` headers (and your auth flow) untouched.
5. Set `X-Forwarded-Proto: https` and `X-Forwarded-For: <client-ip>` automatically.
### ai-memory `.env.production` adjustments
```bash
AI_MEMORY_AUTH_TOKEN=...long-random-token-from-generate-auth-token...
AI_MEMORY_AUTH__SECURE_COOKIE=true
AI_MEMORY_ALLOWED_HOSTS=memory.example.com,localhost,127.0.0.1
AI_MEMORY_BIND=0.0.0.0:49374
```
The `AI_MEMORY_ALLOWED_HOSTS` must include the public hostname or
ai-memory's DNS-rebinding guard will refuse Caddy's forwarded
requests.
### Hosting under a subpath
If ai-memory shares a hostname with other apps, keep the prefix when proxying
and tell ai-memory about it:
```bash
AI_MEMORY_BASE_PATH=/wiki
```
```caddyfile
memory.example.com {
handle /wiki/* {
reverse_proxy ai-memory:49374
}
}
```
Do **not** use `handle_path /wiki/*` for this deployment: it strips `/wiki`
before forwarding, while ai-memory intentionally serves all routes under the
configured prefix. With the example above, clients use:
```bash
ai-memory install-mcp --client claude-code --apply \
--server-url "https://memory.example.com/wiki/mcp" --auth-token "$AI_MEMORY_AUTH_TOKEN"
ai-memory install-hooks --agent claude-code --apply \
--server-url "https://memory.example.com/wiki" --auth-token "$AI_MEMORY_AUTH_TOKEN"
```
The web surface is then at `https://memory.example.com/wiki/web`; add
`AI_MEMORY_WEB_SLUG=/` if you want the built-in browser or custom
`--web-ui-dir` SPA at `https://memory.example.com/wiki` itself. Human console
login requires the custom SPA; the built-in server-rendered wiki remains
protected data rather than an authentication page.
**Safety rules on both flags.** `AI_MEMORY_BASE_PATH` and
`AI_MEMORY_WEB_SLUG` go through the same normaliser. Segments must be
RFC 3986 unreserved characters (`[A-Za-z0-9-._~]`). Dot-segments
(`.` / `..`) are rejected — they mean "current" and "parent" at a
segment boundary, so accepting them would let a typo turn the prefix
into traversal. Anything outside the unreserved set falls back to a
root mount, and the startup log says why. The trailing-slash redirect
at `{base_path}{web_slug}/` keeps the query string on its way to the
canonical form.
### MCP client config (Claude Code shown — others follow the same shape)
```bash
ai-memory install-mcp --client claude-code --apply \
--server-url "https://memory.example.com/mcp" --auth-token "$AI_MEMORY_AUTH_TOKEN"
ai-memory install-hooks --agent claude-code --apply \
--server-url "https://memory.example.com" --auth-token "$AI_MEMORY_AUTH_TOKEN"
```
`https://` flips on, the token rides in `Authorization: Bearer`, and
Caddy's cert is browser/curl/MCP-client trusted everywhere because
Let's Encrypt is in every system trust store.
### What can go wrong
- **Port 80 not reachable from the internet** → ACME fails. Symptom: Caddy logs `Get "https://acme-v02.api.letsencrypt.org/...": ...` errors. Fix: forward 80 and 443 from your router to the Caddy host, OR switch to Cloudflare Tunnel (Path 3) which doesn't need open ports.
- **DNS not propagated yet** → first cert issuance fails with `unauthorized: ...DNS name does not have any address`. Fix: wait, or check the A record points at your public IP.
- **Cert renews silently fail months later** → Caddy logs the failure but you don't read Caddy logs. Fix: subscribe to `journalctl -u docker-compose@... | grep -i 'renew\|error'` or front Caddy with healthchecks.
---
## Path 2 — Caddy with internal CA (LAN-only)
You don't have a public domain or you don't want to expose anything
to the internet. Caddy's internal CA generates a per-server root cert
the operator installs **once** into each client machine's OS trust
store. Same wire shape as Path 1, no internet dependency, no port
forward.
### Caddyfile
```caddyfile
{
local_certs # tells Caddy to use the internal CA instead of LE
}
homelab.local, 192.168.1.50 {
reverse_proxy ai-memory:49374
}
```
List every name + IP clients will use (browser, MCP client, curl)
in the site address. Caddy puts all of them in the cert's SAN.
### The trust-install step (the load-bearing one)
Caddy's root cert lives at `<caddy-data>/caddy/pki/authorities/local/root.crt`
inside the volume. Extract it once:
```bash
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy-root.crt
```
Then install it into each client OS's trust store:
| OS | Command |
|---|---|
| macOS | `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root.crt` |
| Linux (Debian/Ubuntu) | `sudo cp caddy-root.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates` |
| Linux (Arch/openSUSE) | `sudo trust anchor --store caddy-root.crt` |
| Windows | `certutil -addstore -f "Root" caddy-root.crt` (Administrator PowerShell) |
| iOS / Android | Email the file to the device, open it, install as a profile in Settings → General → VPN & Device Management. Then **also** explicitly trust it under Settings → General → About → Certificate Trust Settings. |
**The warning that has to be loud**: if you skip the trust-install
step on a client, that client will either refuse TLS connections
(MCP clients, curl) or train the operator to click through warnings
(browsers). In the latter case **you have neither HTTP's transparency
nor HTTPS's protection** — you have a security theatre cert that
makes everyone less safe. Install the root cert on every client
machine you connect from, or use Path 1 / Path 3 instead.
### Same `.env` + client config shape as Path 1
Substitute `https://homelab.local` (or whichever SAN you set) for the
public domain. Everything else is identical.
---
## Path 3 — Cloudflare Tunnel
Cloudflare's `cloudflared` daemon establishes an outbound-only tunnel
to Cloudflare's edge. **No ports open on your router**, no public IP
needed, TLS terminated at the Cloudflare edge with their cert. Pairs
particularly well with the homelab multi-user case because the trust
story is "Cloudflare is the CA" — universally trusted, no per-client
install dance.
### One-time Cloudflare setup
1. Have a domain on Cloudflare (the registrar can be elsewhere; the DNS must be on Cloudflare).
2. In the Cloudflare dashboard, go to **Zero Trust → Networks → Tunnels** → **Create a tunnel** → name it `ai-memory-homelab` (or whatever) → save.
3. Cloudflare gives you a long token string. Save it for the compose file.
4. Add a public hostname to the tunnel: `memory.example.com` → service `http://ai-memory:49374`. Save.
5. (Optional but recommended) Wrap the hostname in a **Cloudflare Access** application — Cloudflare's zero-trust SSO sits in front of the tunnel and you get human auth via Google/GitHub/etc. **on top of** ai-memory's bearer token.
### Compose template
Copy `docker/compose.tls.cloudflared.yml`. The relevant block:
```yaml
services:
ai-memory:
image: akitaonrails/ai-memory:latest
container_name: ai-memory
restart: unless-stopped
expose:
- "49374" # tunnel reaches it over the docker network — no host port
volumes:
- ai-memory-data:/data
env_file:
- .env.production
cloudflared:
image: cloudflare/cloudflared:latest
container_name: ai-memory-tunnel
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
volumes:
ai-memory-data:
name: ai-memory-data
```
`CLOUDFLARE_TUNNEL_TOKEN` goes in your `.env` (or compose env). No
ports exposed on the host. No DNS configuration beyond the dashboard
step above (Cloudflare manages the CNAME automatically).
### ai-memory `.env.production` adjustments
```bash
AI_MEMORY_AUTH_TOKEN=...long-random-token...
AI_MEMORY_AUTH__SECURE_COOKIE=true
AI_MEMORY_ALLOWED_HOSTS=memory.example.com,localhost,127.0.0.1
AI_MEMORY_BIND=0.0.0.0:49374
CLOUDFLARE_TUNNEL_TOKEN=eyJ...long-base64-from-the-cf-dashboard...
```
### Client config
Same as Path 1:
```bash
ai-memory install-mcp --client claude-code --apply \
--server-url "https://memory.example.com/mcp" --auth-token "$AI_MEMORY_AUTH_TOKEN"
```
### What can go wrong
- **Token leak**. Anyone with `CLOUDFLARE_TUNNEL_TOKEN` can run a tunnel for your hostname. Keep the env file `0600`, don't commit it.
- **Tunnel down + cf cached old DNS** → Cloudflare returns 502 for a few minutes after restart. Usually self-heals.
- **Access policies confused with bearer auth**. Cloudflare Access (the optional SSO layer) is a separate layer from ai-memory's bearer token. Both run; both must pass. If Access blocks a request, ai-memory never sees it.
---
## Path 4 — External cert files (Caddy or nginx)
You already have a CA issuing certs to your services (corporate PKI,
homelab Vault, anything). You don't want Caddy issuing its own.
### Caddyfile
```caddyfile
memory.example.com {
tls /etc/caddy/certs/memory.crt /etc/caddy/certs/memory.key
reverse_proxy ai-memory:49374
}
```
Mount the cert + key:
```yaml
services:
caddy:
# ... rest as Path 1 ...
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- /your/cert/path:/etc/caddy/certs:ro # the cert dir
- caddy-data:/data
```
Caddy hot-reloads the cert when files change. No reload required.
### nginx equivalent
```nginx
server {
listen 443 ssl http2;
server_name memory.example.com;
ssl_certificate /etc/nginx/certs/memory.crt;
ssl_certificate_key /etc/nginx/certs/memory.key;
location / {
proxy_pass http://ai-memory:49374;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# MCP Streamable HTTP transport is request-response; chunked
# bodies and SSE both rely on the next two lines.
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
The `proxy_http_version 1.1` + empty `Connection` are required for
MCP's Streamable HTTP transport to stream correctly.
---
## Native (non-Docker) Caddy
For operators running ai-memory from source / AUR / `cargo run`
without Docker:
```caddyfile
memory.example.com {
reverse_proxy 127.0.0.1:49374
}
```
Install Caddy natively (`brew install caddy` / `pacman -S caddy` /
`apt install caddy`), drop the Caddyfile at the OS-canonical path
(`/etc/caddy/Caddyfile` on Linux, `/opt/homebrew/etc/Caddyfile` on
macOS), and `systemctl enable --now caddy` / `brew services start
caddy`. Everything else (LE, internal CA, external certs) works the
same as the Docker paths above — Caddy doesn't care which side of the
container boundary it's on.
For Cloudflare Tunnel: `cloudflared service install ${CLOUDFLARE_TUNNEL_TOKEN}`
installs and starts the tunnel as a systemd service on Linux or a
LaunchDaemon on macOS. Same shape as the Docker variant.
---
## Long-running requests: `bootstrap` and proxy idle timeouts
`ai-memory bootstrap` on a large repository holds a **single POST open for
the whole multi-chunk run** — often 20+ minutes — while the server makes
LLM calls, with no bytes flowing over the wire in between. A reverse proxy
with a default idle/read timeout in front of the server will cut that
connection (`Connection reset by peer`), and the run is lost.
If you run `bootstrap` through a proxy, raise or disable the upstream
read/write timeout for ai-memory's route.
**Caddy** — disable the backend read/write timeouts on the `reverse_proxy`:
```caddyfile
memory.example.com {
reverse_proxy ai-memory:49374 {
transport http {
read_timeout 0
write_timeout 0
}
}
}
```
**nginx** — raise `proxy_read_timeout` / `proxy_send_timeout` (default 60s)
well past your longest run:
```nginx
location / {
proxy_pass http://127.0.0.1:49374;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# plus the http/1.1 + Connection "" lines from the nginx template above
}
```
This only matters for the long-held `bootstrap` POST; ordinary MCP and
`/api/v1` requests are short and unaffected. (A genuinely failed chunk still
loses the run today — the durable-progress/`--resume` question is tracked
separately; see #614.)
---
## Outbound LLM calls fail behind a TLS-inspecting firewall
The paths above are about clients trusting *ai-memory's* certificate. This
section is the mirror image: **ai-memory trusting an upstream interceptor** so
its own outbound LLM/embedding calls succeed.
**Symptom.** Consolidation, lint, or embeddings fail and the logs show
`invalid peer certificate: UnknownIssuer` on calls to your LLM/embedding
provider. Your network runs an HTTPS-inspecting proxy (a corporate MITM
appliance, an inspecting antivirus, a Zscaler/Netskope-style gateway) that
re-signs TLS with a private interception root the container doesn't trust.
**Why it happens.** ai-memory's HTTP client (reqwest + rustls, built with
`rustls-tls-native-roots`) trusts the **operating system** certificate store,
not a bundled root list — it reads the container's `/etc/ssl/certs` and honors
`SSL_CERT_FILE` / `SSL_CERT_DIR`. The stock image ships only the public Debian
roots, so the interception root is unknown. `curl` from your host may succeed
because your host already trusts that root; the container does not.
**Fix.** Give the container a CA bundle that includes the interception root.
1. Export the interception root your gateway presents (from a trusted host),
or — better — get it from your IT team, which is the authoritative source:
```bash
openssl s_client -showcerts -connect api.openai.com:443 </dev/null 2>/dev/null \
| openssl x509 -outform PEM > corp-root.crt
```
2. Build a combined bundle = the container's public roots **plus** your root.
`SSL_CERT_FILE` *replaces* the default file, so it must contain both:
```bash
docker run --rm --entrypoint cat ai-memory:latest \
/etc/ssl/certs/ca-certificates.crt > ca-bundle.crt
cat corp-root.crt >> ca-bundle.crt
```
3. Mount it and point the container at it. Either option works:
```yaml
# docker-compose.yml — option A: SSL_CERT_FILE
services:
ai-memory:
volumes:
- ./ca-bundle.crt:/etc/ai-memory/ca-bundle.crt:ro
environment:
SSL_CERT_FILE: /etc/ai-memory/ca-bundle.crt
```
```yaml
# option B: overwrite the default path, no env var needed
volumes:
- ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro
```
4. Recreate the container (`docker compose up -d`). The trust store is read at
process start, so a running container must be restarted to pick up the
change.
**Notes.**
- Point `SSL_CERT_FILE` at the *combined* bundle, never at the interception
root alone — that would drop every public root and break all other TLS.
- Native (non-Docker) installs: on Linux, install the root system-wide
(`update-ca-certificates` / `trust anchor`) or set `SSL_CERT_FILE`; on
Windows the client reads the schannel store, so import the root there
instead — `SSL_CERT_FILE` does not apply.
## What ai-memory does to support being behind a proxy
Nothing special — the server intentionally generates no absolute URLs
in responses, so it doesn't matter whether `https://` or `http://`
sits in front. The bearer token middleware reads `Authorization`
directly off the request, which proxies forward verbatim. The
`/api/v1` ETag is computed from request-independent fields.
For browser access to `/web` through HTTPS, set
`AI_MEMORY_AUTH__SECURE_COOKIE=true` (or `[auth] secure_cookie = true`). This
marks the `ai_memory_session` cookie `Secure`; it is always `HttpOnly`,
`SameSite=Strict`, and `Path=/`. ai-memory intentionally does **not** infer
HTTPS from `X-Forwarded-Proto` or any other proxy header. Close direct HTTP
access to the public hostname, or redirect it to HTTPS. Human auth on a
non-loopback listener will not start without this setting. It remains false by
default only so direct loopback smoke/development can use plain HTTP.
The only thing to mind: **`AI_MEMORY_ALLOWED_HOSTS` must include the
public hostname**, not just `localhost`. The host-allowlist middleware
runs before any header rewriting, so it sees the proxy's forwarded
`Host: memory.example.com` and would reject it otherwise.
## Don't paper over the security gap
Three things to actively avoid:
1. **Don't disable the allowed-hosts guard.** It's the DNS-rebinding defence; pruning it because the proxy "should be" filtering is exactly the kind of "the other layer handles it" assumption that ships bugs. Add the public hostname; don't widen to `*`.
2. **Don't skip the trust-install step in Path 2.** The temptation is to add `-k` (curl) or `--insecure` (MCP clients that support it) "just to get it working." If you do, you have a security theatre cert: TLS without authentication, which is worse than HTTP with the bearer because it looks safe and isn't.
3. **Don't run cloudflared with `--no-tls-verify`.** Cloudflare's tunnel daemon validates ai-memory's cert by default — which is fine because ai-memory is on plain HTTP inside the docker network. Don't override the flag; you'd be reaching for it because something else is misconfigured.
If you can't take one of these paths cleanly, the honest answer is
"keep ai-memory loopback-only" or "front it with the proxy you
already trust." The configuration that gives operators the wrong
mental model — looking secure, not being secure — is worse than
either.
## The session-aware MCP bridge and HTTPS
`ai-memory mcp-bridge` reaches `https://` server URLs. Earlier releases could not:
its transport pulled in a second `reqwest` with no TLS backend compiled, so any
non-`http` scheme was refused before a connection was attempted. If you front
ai-memory with a TLS-terminating proxy as described above, point the bridge at the
proxied `https://` URL directly — a second, local proxy on each client machine is
not needed.
The bridge uses the platform certificate verifier, so it trusts the same roots the
operating system does. A certificate the OS does not trust — a self-signed one, or a
private CA that has not been installed into the system trust store — is rejected.