Tech Expert & Vibe Coder

With 14+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Setting up Traefik middleware chains to add security headers and authentication for exposing local LLM APIs through WireGuard

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.