Why I Built This
I run local LLM APIs on my home network using Ollama and LM Studio. These APIs sit behind WireGuard because I want to access them from anywhere without exposing them to the open internet. The problem is that even over WireGuard, these endpoints are still accessible to anyone who gets on my VPN — including devices I might hand to someone temporarily or services I'm testing.
I needed a way to add authentication and security headers to these APIs without modifying the LLM servers themselves. Traefik middleware chains solved this cleanly because I could stack authentication, rate limiting, and security headers in a reusable way across multiple services.
My Setup
I run Traefik in a Docker container on my Proxmox host. The LLM APIs (Ollama on one VM, LM Studio on another) connect to Traefik through a Docker network. WireGuard runs on the same host, and clients connect through a specific subnet (10.8.0.0/24).
Traefik's configuration is split into static config (traefik.yml) and dynamic config files loaded from a directory. I define middleware chains in the dynamic config and attach them to routers using Docker labels.
Static Configuration
My traefik.yml looks like this:
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
directory: /etc/traefik/dynamic
watch: true
api:
dashboard: true
insecure: false
The file provider watches /etc/traefik/dynamic for middleware definitions. I mount this directory from the host so I can edit middleware without restarting Traefik.
Dynamic Middleware Configuration
I created a file called middlewares.yml in the dynamic directory:
http:
middlewares:
llm-auth:
basicAuth:
users:
- "vipin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"
llm-security:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
stsSeconds: 31536000
stsIncludeSubdomains: true
customResponseHeaders:
server: ""
x-powered-by: ""
contentSecurityPolicy: "default-src 'self'; script-src 'none'; object-src 'none';"
llm-ratelimit:
rateLimit:
average: 10
burst: 20
period: 1m
The basicAuth password is hashed using htpasswd. I generated it with:
htpasswd -nb vipin mypassword
The security headers block XSS, disable content sniffing, enforce HSTS, and strip server identification headers. The CSP is strict because LLM APIs don't serve HTML — they only return JSON.
The rate limit allows 10 requests per minute on average, with bursts up to 20. This protects against accidental loops or runaway scripts hitting the API.
Docker Labels for Ollama
I run Ollama in Docker with these labels:
services:
ollama:
image: ollama/ollama:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.ollama.rule=Host(`ollama.local.vipinpg.com`)"
- "traefik.http.routers.ollama.entrypoints=websecure"
- "traefik.http.routers.ollama.tls=true"
- "traefik.http.routers.ollama.middlewares=llm-auth@file,llm-security@file,llm-ratelimit@file"
- "traefik.http.services.ollama.loadbalancer.server.port=11434"
The middleware chain is applied in order: authentication first, then security headers, then rate limiting. The @file suffix tells Traefik to load these from the file provider.
What Worked
The middleware chain works exactly as expected. When I hit the Ollama API through WireGuard, I get prompted for basic auth credentials. After authenticating, all responses include the security headers, and rate limiting kicks in if I spam requests.
I tested this with curl:
curl -u vipin:mypassword https://ollama.local.vipinpg.com/api/tags
The response includes headers like:
X-Content-Type-Options: nosniff X-Frame-Options: DENY Strict-Transport-Security: max-age=31536000; includeSubDomains
Rate limiting works as expected. If I run a loop that hits the API 30 times in 10 seconds, the first 20 go through, and the rest get a 429 Too Many Requests response.
The setup is reusable. I applied the same middleware chain to LM Studio with different Docker labels, and it worked without modification.
What Didn't Work
My first attempt used forwardAuth instead of basicAuth. I wanted to validate tokens against a separate auth service, but I couldn't get the timing right. The LLM APIs respond slowly (especially for large models), and the auth service would time out waiting for Traefik to complete the request. I switched to basicAuth because it's stateless and doesn't depend on external services.
I initially set the rate limit to 5 requests per minute, which was too aggressive. Some LLM workflows (like chat with context) make multiple API calls in quick succession. I bumped it to 10 average with 20 burst, which handles normal usage without blocking legitimate traffic.
I tried adding a CSP that allowed inline scripts, thinking it might be useful if I ever built a web UI for these APIs. But that weakened the security posture for no immediate benefit, so I removed it. The strict CSP (script-src 'none') is fine for JSON APIs.
I also tried using Traefik's ipWhiteList middleware to restrict access to the WireGuard subnet (10.8.0.0/24). This worked in theory, but it broke when I connected from a different device on the same WireGuard network because the source IP Traefik saw was the WireGuard gateway, not the client. I removed the IP whitelist and relied on WireGuard's built-in access control instead.
Key Takeaways
Middleware chains in Traefik are powerful for layering security without touching the backend services. I can add authentication, headers, and rate limiting to any service just by changing Docker labels.
BasicAuth is simple and works well for personal use. It's not suitable for public APIs, but for WireGuard-only access, it's enough. The credentials are hashed, and HTTPS prevents them from being sniffed.
Security headers are easy to get wrong. I had to test the CSP carefully to make sure it didn't block legitimate API responses. The strict policy (script-src 'none') works for JSON APIs but would break any service that serves HTML.
Rate limiting needs tuning based on actual usage. Start conservative, monitor the logs, and adjust. Too strict breaks workflows; too loose defeats the purpose.
IP whitelisting doesn't work well with WireGuard in my setup because Traefik sees the gateway IP, not the client. If you need IP-based access control, handle it at the WireGuard level instead.
The file provider for dynamic config is convenient. I can edit middleware definitions without restarting Traefik, and changes apply within seconds.