HTTPS via a reverse proxy
September 3, 2026 · View on GitHub
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
/webcookies 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 stdionever touches the network. No TLS to worry about. - Loopback-only HTTP server, single user, no
/webaccess from another machine.127.0.0.1:49374is 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_pepperis the credential prerequisite). Nativeaim_keys travel between clients and the server — sniffable over plain HTTP on the LAN. Seedocs/users.md. - The server is bound beyond loopback (
AI_MEMORY_BIND=0.0.0.0:49374or a LAN-routable IP). Anyone on the network segment sees plaintext token traffic and/webcookies. - You access
/webfrom 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— Caddy front, both LE and internal-CA variants documented inline.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:
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:
memory.example.com {
reverse_proxy ai-memory:49374
}
Caddy will:
- Solve the HTTP-01 ACME challenge on first request to that hostname.
- Issue a Let's Encrypt cert.
- Renew automatically 30 days before expiry.
- Forward
Authorization: Bearer ...headers (and your auth flow) untouched. - Set
X-Forwarded-Proto: httpsandX-Forwarded-For: <client-ip>automatically.
ai-memory .env.production adjustments
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:
AI_MEMORY_BASE_PATH=/wiki
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:
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)
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
{
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:
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
- Have a domain on Cloudflare (the registrar can be elsewhere; the DNS must be on Cloudflare).
- In the Cloudflare dashboard, go to Zero Trust → Networks → Tunnels → Create a tunnel → name it
ai-memory-homelab(or whatever) → save. - Cloudflare gives you a long token string. Save it for the compose file.
- Add a public hostname to the tunnel:
memory.example.com→ servicehttp://ai-memory:49374. Save. - (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:
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
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:
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_TOKENcan run a tunnel for your hostname. Keep the env file0600, 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
memory.example.com {
tls /etc/caddy/certs/memory.crt /etc/caddy/certs/memory.key
reverse_proxy ai-memory:49374
}
Mount the cert + key:
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
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:
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:
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:
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.)
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:
- 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
*. - 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. - 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.