Traefik custom plugins are a powerful way to extend Traefik’s functionality, but most people think of them as just adding new features, when in reality, they’re more about rearranging and transforming existing requests and responses in ways Traefik doesn’t directly support out-of-the-box.
Let’s see this in action. Imagine we have a simple API running on localhost:8080 and Traefik is routing to it.
# docker-compose.yml
version: '3.8'
services:
traefik:
image: traefik:v2.9
ports:
- "80:80"
- "8080:8080" # For API
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml
- ./dynamic_conf:/etc/traefik/dynamic_conf
whoami:
image: traefik/whoami
ports:
- "8080:80"
# traefik.yml
entryPoints:
web:
address: ":80"
api:
dashboard: true
insecure: true
providers:
docker:
exposedByDefault: false
file:
directory: /etc/traefik/dynamic_conf
watch: true
# dynamic_conf/route.yml
http:
routers:
whoami-router:
rule: "Host(`localhost`)"
service: "whoami"
services:
whoami:
loadBalancer:
servers:
- url: "http://whoami:8080"
If you docker-compose up and hit localhost, you’ll see the whoami service responding. Now, let’s say we want to add a custom header to every request before it hits the whoami service, but only if a specific query parameter is present. Traefik doesn’t have a built-in middleware for conditional header injection based on query params. This is where a custom plugin shines.
The core problem custom plugins solve is bridging the gap between Traefik’s declarative configuration and the imperative logic needed for complex request manipulation. They allow you to write Go code that intercepts requests and responses, inspects them, modifies them, and then passes them along.
Here’s a simplified custom plugin that adds a X-Custom-Header: MyValue to requests if the add-header query parameter is present.
First, you need a Go module for your plugin. Let’s call it traefik-header-plugin.
mkdir traefik-header-plugin
cd traefik-header-plugin
go mod init traefik-header-plugin
Create main.go:
package main
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/traefik/traefik/v2/pkg/config/dynamic"
"github.com/traefik/traefik/v2/pkg/log"
"github.com/traefik/traefik/v2/pkg/plugin"
)
func main() {
plugin.Register("customheader", plugin.Config{
// A function that creates the plugin.
// It receives the plugin's configuration and returns an HTTP handler.
CreateHandler: func(conf dynamic.Configuration, p plugin.Provider) (http.Handler, error) {
// Cast the configuration to our specific plugin configuration struct.
config := conf.(Config)
// Return an http.Handler that wraps the next handler in the chain.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the 'add-header' query parameter is present.
if r.URL.Query().Get("add-header") == "true" {
// Add our custom header.
r.Header.Add("X-Custom-Header", "MyValue")
log.FromContext(r.Context()).Debug("Added X-Custom-Header")
}
// Call the next handler in the chain.
p.ServeHTTP(w, r)
}), nil
},
})
}
// Config is the plugin's configuration structure.
// This struct will be populated from the Traefik configuration.
type Config struct {
// No specific configuration needed for this simple example,
// but you can add fields here to customize plugin behavior via Traefik's config.
}
And a go.mod file:
module traefik-header-plugin
go 1.18
require github.com/traefik/traefik/v2 v2.9.0 // Or your Traefik version
Now, build this plugin into a shared library (.so file).
go build -buildmode=plugin -o plugin.so
You’ll need to tell Traefik where to find this plugin. Mount the directory containing plugin.so into Traefik’s container, and configure it.
Modify your docker-compose.yml:
# docker-compose.yml
version: '3.8'
services:
traefik:
image: traefik:v2.9
ports:
- "80:80"
- "8080:8080" # For API
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml
- ./dynamic_conf:/etc/traefik/dynamic_conf
- ./traefik-header-plugin:/plugins # Mount the plugin directory
environment:
- TRAEFIK_PLUGINS_ORDER=forwarded,headers,customheader # Ensure customheader runs early
whoami:
image: traefik/whoami
ports:
- "8080:80"
And update traefik.yml:
# traefik.yml
entryPoints:
web:
address: ":80"
api:
dashboard: true
insecure: true
providers:
docker:
exposedByDefault: false
file:
directory: /etc/traefik/dynamic_conf
watch: true
experimental: # Or plugins in older versions
plugins:
customheader:
moduleName: "traefik-header-plugin" # Matches go.mod module name
location: "/plugins/plugin.so" # Path inside the container
Finally, update your dynamic_conf/route.yml to use the plugin as middleware:
# dynamic_conf/route.yml
http:
routers:
whoami-router:
rule: "Host(`localhost`)"
service: "whoami"
middlewares:
- "customheader@file" # Reference our plugin by name and provider (file)
services:
whoami:
loadBalancer:
servers:
- url: "http://whoami:8080"
middlewares:
customheader:
plugin: {} # Empty configuration for our plugin
Restart Traefik (docker-compose up -d --force-recreate traefik).
Now, if you curl localhost, you’ll see the whoami response. But if you curl "localhost?add-header=true", you’ll see an X-Custom-Header: MyValue in the response headers, because the whoami service echoes back the request headers. The plugin intercepted the request, saw the query parameter, added the header, and then passed it to the whoami service.
The one piece of Traefik configuration that often trips people up with plugins is the plugin: {} part. For plugins that don’t require any specific configuration options (like our simple header adder), you still need to provide an empty plugin: {} block in your middlewares section to instantiate it. If your plugin did have configuration options defined in its Config struct (e.g., HeaderName string, HeaderValue string), you would populate that here like plugin: { headerName: "X-Another-Header", headerValue: "AnotherValue" }.
The next hurdle is understanding how to debug these plugins when they don’t behave as expected, often leading to Traefik failing to start or requests silently failing.