mirror of
https://github.com/akitaonrails/ai-memory.git
synced 2026-10-02 03:24:46 +08:00
docs: HTTPS-via-proxy guide + compose templates + sharpened startup warning
Per Phase 2 decision to NOT terminate TLS in-binary: ai-memory
stays HTTP-on-loopback by default (zero change for existing users
on upgrade) and operators front it with a battle-tested reverse
proxy when they cross the deployment shapes that need TLS.
Three new files:
- docs/https-via-proxy.md — the deployment guide. Covers:
- When you DON'T need TLS (loopback + stdio cases honestly
don't, called out up front so we don't add ceremony where
it doesn't earn its keep).
- When you DO need TLS (multi-user, non-loopback bind, /web
from another machine, public exposure).
- Five deployment paths with copy-paste configs:
- Caddy + public domain + Let's Encrypt
- Caddy + internal CA (LAN-only)
- Cloudflare Tunnel (no open ports, TLS at the edge)
- External cert files (Caddy or nginx)
- Native (non-Docker) Caddy
- Each path documents what can go wrong + the trust-install
step for the internal-CA case (the load-bearing manual
step — skipping it produces security theatre).
- Final section "Don't paper over the security gap" calls out
three specific anti-patterns operators reach for when
things "don't work" (disable allowed-hosts, --insecure on
clients, --no-tls-verify on cloudflared).
- docker/compose.tls.caddy.yml — copy-paste compose template
with Caddy front. Three variants documented inline (LE,
internal CA, external cert files). Same service definition
across all three; only the Caddyfile differs.
- docker/compose.tls.cloudflared.yml — Cloudflare Tunnel sidecar
compose template. Walks through the one-time CF dashboard
setup, the .env.production shape (incl. CLOUDFLARE_TUNNEL_TOKEN),
and the client-config commands. No ports exposed; the tunnel is
outbound-only.
Code change (small):
- crates/ai-memory-cli/src/commands/serve.rs: extend the
existing non-loopback startup warning. Previously fired only
when bind wasn't loopback AND no auth token was configured;
now ALSO fires (with different message) when bind isn't
loopback AND auth IS configured — to remind the operator
that bearer tokens still travel cleartext on plain HTTP and
point them at docs/https-via-proxy.md. One-shot at startup,
not a refusal to serve (operators may already be behind a
proxy we can't reliably detect; refusing to bind would break
their flow). Single line of log output, links to the doc.
Cross-linking:
- README "Security" section gets a clear "Want HTTPS?"
paragraph pointing at docs/https-via-proxy.md + the two
compose templates. Spells out the loopback exception so the
single-user happy-path user doesn't think they're missing
something.
- README docs table updated: docs/deploy.md row reframed as
"pointers to the TLS guide"; new row added for
docs/https-via-proxy.md with a full description of what it
covers.
- docs/deploy.md's brief "encrypted transport" section
trimmed from inline two-paragraph table to pointers at the
new dedicated doc + the compose templates. Avoids drift
between two places documenting the same thing.
Why this shape (instead of in-binary TLS):
- Reverse proxies are the production-grade answer anyway.
- Caddy does Let's Encrypt better than we ever would; we don't
have to chase rcgen / instant-acme / rustls CVEs.
- Scope discipline — TLS termination is infrastructure, not
ai-memory's job. Bundling them would repeat cognee's
LiteLLM mistake at a smaller scale.
- The four "thinking you're secure when you're not" failure
modes all become the proxy's problem, not ours: silent ACME
expiry, trust-store dance, cert reload races, fail-open
fallback.
No behavior change for existing single-user installs.
720/720 tests pass.
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
# 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** (`[auth].token_pepper` set, users created via `ai-memory user add`). Per-user tokens 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 Basic auth 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 will warn at startup when it binds to a non-loopback address
|
||||
without auth, and again (one-shot) on the first request that didn't
|
||||
arrive via `X-Forwarded-Proto: https`. The warnings are advisory —
|
||||
the server doesn't refuse to start over plain HTTP. The decision to
|
||||
add TLS is yours; this page is the recipes.
|
||||
|
||||
## 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_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.
|
||||
|
||||
### 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_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.
|
||||
|
||||
---
|
||||
|
||||
## 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. The
|
||||
`/web` cookie set after Basic auth uses `SameSite=Lax` without
|
||||
`Secure`, which lets it ride either transport — when fronted with
|
||||
HTTPS, modern browsers automatically tighten the cookie to the proxy
|
||||
origin's secure flag set anyway.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user