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.
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.
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). Per-user tokens 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 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 refuses to start unauthenticated non-loopback HTTP by default.
Configure AI_MEMORY_AUTH_TOKEN or bind loopback; the dangerous
--allow-insecure-no-auth escape hatch exists only for intentional plain-HTTP
LAN use. Authenticated non-loopback plain HTTP remains available and logs a
loud warning: bearer tokens and /web cookies are still sniffable without
TLS. The decision to add TLS is yours; this page is the recipes.
| 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.
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.
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: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_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:49374The AI_MEMORY_ALLOWED_HOSTS must include the public hostname or
ai-memory's DNS-rebinding guard will refuse Caddy's forwarded
requests.
If ai-memory shares a hostname with other apps, keep the prefix when proxying and tell ai-memory about it:
AI_MEMORY_BASE_PATH=/wikimemory.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 built-in browser is then at https://memory.example.com/wiki/web; add
AI_MEMORY_WEB_SLUG=/ if you want the browser or custom --web-ui-dir SPA at
https://memory.example.com/wiki itself.
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.
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.
- 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.
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.
{
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.
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.crtThen 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.
Substitute https://homelab.local (or whichever SAN you set) for the
public domain. Everything else is identical.
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.
- 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.
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-dataCLOUDFLARE_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_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...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"- 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.
You already have a CA issuing certs to your services (corporate PKI, homelab Vault, anything). You don't want Caddy issuing its own.
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:/dataCaddy hot-reloads the cert when files change. No reload required.
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.
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.
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 Basic-auth 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. Enabling
secure_cookie on a direct HTTP deployment makes browsers withhold the cookie,
which is expected safety behavior. It remains false by default so loopback and
plain-HTTP local /web continue to work.
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.
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.