When the built-in middlewares stop being enough — you need a custom header derivation, a request-shape validator, a per-tenant rule — Traefik plugins fill the gap. They run Go code inside Traefik via the yaegi interpreter, which means no native compilation but also no third-party CGo dependencies. This article walks through writing a plugin, the local development workflow, and the trade-offs of publishing to the Plugin Catalog versus shipping a private fork.
How to verify
# A loaded plugin shows up in the experimental block of the dashboard or via API
curl -s http://127.0.0.1:8082/api/http/middlewares | jq '.[] | select(.type=="plugin")'
# The plugin's source must be present in the configured directory
ls /opt/traefik/plugins-local/src/github.com/your-org/header-derive/
# A test request should trigger the plugin behaviour observable in headers or logs
curl -sI -H "X-Tenant: acme" https://app.example.com/ | grep X-Derived-Tenant
What’s happening
Traefik plugins are Go code interpreted at runtime by yaegi. The plugin lifecycle has three stages. At startup, Traefik reads the experimental config block and loads the plugin’s source from a path inside /plugins-local/src/... (local mode) or pulls it from GitHub by module path (catalog mode). It then runs New(ctx, next, config, name) once to construct an instance. At request time, the middleware’s ServeHTTP(rw, req) runs in the request path like any other middleware.
The key constraints from yaegi are: no CGo, no unsafe, no third-party packages outside the standard library by default (a plugin can vendor pure-Go packages but they must be syntactically interpretable). Most network/IO patterns are fine — you can call out to an HTTP service, parse JSON, manipulate headers. What you cannot do is anything that touches the FFI boundary.
The Plugin Catalog at plugins.traefik.io publishes plugins by tagging a GitHub repo. Traefik resolves them at startup by fetching the tagged commit. The trade-off: catalog plugins are easy to install but you ship every dependency on GitHub being available at install time. For production we use local plugins — same code path, but the source ships in our image, not pulled at runtime.
The procedure
-
Lay out the plugin tree. The path mirrors a Go module path so yaegi can resolve imports.
/opt/traefik/plugins-local/ src/ github.com/ your-org/ header-derive/ go.mod .traefik.yml header_derive.go header_derive_test.go -
The
.traefik.ymlmanifest declares the plugin name, version, and default config schema. This is what the catalog reads; for local mode it must still be present.displayName: Header Derive type: middleware import: github.com/your-org/header-derive summary: Derive an X-Derived-* response header from an incoming header testData: sourceHeader: X-Tenant targetHeader: X-Derived-Tenant transform: lowercase -
Write the plugin. The two methods that matter are
CreateConfig()returning the default config struct, andNew()returning ahttp.Handler-compatible struct.// header_derive.go package header_derive import ( "context" "net/http" "strings" ) type Config struct { SourceHeader string `json:"sourceHeader,omitempty"` TargetHeader string `json:"targetHeader,omitempty"` Transform string `json:"transform,omitempty"` // "lowercase" | "uppercase" | "noop" } func CreateConfig() *Config { return &Config{Transform: "noop"} } type HeaderDerive struct { next http.Handler sourceHeader string targetHeader string transform string name string } func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.Handler, error) { return &HeaderDerive{ next: next, sourceHeader: cfg.SourceHeader, targetHeader: cfg.TargetHeader, transform: cfg.Transform, name: name, }, nil } func (h *HeaderDerive) ServeHTTP(rw http.ResponseWriter, req *http.Request) { v := req.Header.Get(h.sourceHeader) switch h.transform { case "lowercase": v = strings.ToLower(v) case "uppercase": v = strings.ToUpper(v) } if v != "" { rw.Header().Set(h.targetHeader, v) } h.next.ServeHTTP(rw, req) } -
Declare the plugin in Traefik static config and wire it as a middleware.
experimental: localPlugins: headerDerive: moduleName: github.com/your-org/header-derive# dynamic.yml http: middlewares: derive-tenant: plugin: headerDerive: sourceHeader: X-Tenant targetHeader: X-Derived-Tenant transform: lowercase routers: api: rule: "Host(`api.example.com`)" entryPoints: [websecure] service: api-backend middlewares: [derive-tenant] -
Mount the plugin tree into the Traefik container.
services: traefik: image: traefik:v3.1 volumes: - /opt/traefik/plugins-local:/plugins-local:ro - /opt/traefik/conf:/etc/traefik:ro -
Reload Traefik. The plugin compiles at startup; a syntax error fails the boot loudly.
cd /opt/traefik && docker compose restart traefik docker logs traefik 2>&1 | grep -i plugin | tail
Operational notes
- A plugin failure at startup stops Traefik from accepting traffic — keep local plugins behind feature flags or in canary deployments before promoting to production fleets.
- The yaegi interpreter is slower than compiled Go; a plugin in the request path costs more than a built-in middleware. For high-RPS code, benchmark before committing.
- Stateful plugins (rate-limiters, cache layers) need careful thought about Traefik restarts — state in memory is gone, state shared across replicas needs an external store.
- The Plugin Catalog version pin is a Git tag — if upstream force-pushes the tag, your install pulls different code on next restart. We mirror catalog plugins into our own org for that reason.
- Logging from inside a plugin uses standard
logorfmt— it flows to Traefik’s log stream but does not get the structured-log treatment unless you build the JSON line yourself.
Plugins are the right answer when you need behaviour that isn’t a stock middleware and isn’t worth fronting Traefik with a separate sidecar. The version control, deployment, and rollback discipline that keeps custom plugins from becoming a black box at the edge is part of the managed operations we run. For the broader middleware surface that plugins extend, see traefik-routers-services-middlewares.