Skip to content

Writing rewriters

Every ingress controller invented its own annotation language. HAProxy puts path rewrites in haproxy.org/path-rewrite. Nginx puts them in nginx.ingress.kubernetes.io/rewrite-target. Traefik decided annotations were beneath it and uses CRDs instead (but also supports annotations, because consistency is for the weak). A rewriter translates one controller's dialect into provider-agnostic ingress entries that any IngressProvider can consume.

The gatekeepers of old each bore a different sigil, yet all opened the same threshold. The pilgrim need not know the sigil — only declare which gate he approaches, and the keeper shall answer in kind.

— Cultes des Goules, On the Many Gates (on good authority)

The contract is small — three methods, one name — because the hard part isn't the interface. The hard part is reading the annotations of an ingress controller you didn't choose, translating semantics that were never designed to be portable, and producing something that a completely different reverse proxy can understand. The contract just gives you a place to put that suffering.

The contract

A rewriter class must have:

  1. name — a string identifying the rewriter (e.g. "haproxy", "nginx"). Used for override matching: an external rewriter with the same name as a built-in one replaces it.
  2. match(manifest, ctx) — return True if this rewriter handles this Ingress manifest. Typically checks ingressClassName (resolved through ingress_types config) or annotation prefixes.
  3. rewrite(manifest, ctx) — convert one Ingress manifest to a list of entry dicts (see entry format below).
  4. priority (optional) — integer, default 1000. Lower = checked earlier. External and built-in rewriters are sorted together; on a tie, an external rewriter is checked before a built-in one. The built-in HAProxy rewriter sits at 1100 so that annotation-specific rewriters at the default 1000 get first pick.
from dekube import IngressRewriter, get_ingress_class, resolve_backend

class NginxRewriter(IngressRewriter):
    name = "nginx"

    def match(self, manifest, ctx):
        ingress_types = ctx.config.get("ingress_types") or {}
        cls = get_ingress_class(manifest, ingress_types)
        if cls == "nginx":
            return True
        if cls in ("haproxy", "traefik"):  # another known controller's class wins
            return False
        annotations = (manifest.get("metadata") or {}).get("annotations") or {}
        return any(k.startswith("nginx.ingress.kubernetes.io/") for k in annotations)

    def rewrite(self, manifest, ctx):
        entries = []
        for rule in (manifest.get("spec") or {}).get("rules") or []:
            if not rule:
                continue
            host = rule.get("host") or ""
            if not host:
                continue
            for path_entry in (rule.get("http") or {}).get("paths") or []:
                if not path_entry:
                    continue
                backend = resolve_backend(path_entry, manifest, ctx)
                entries.append({
                    "host": host,
                    "path": path_entry.get("path") or "/",
                    "upstream": backend["upstream"],
                    "scheme": "http",
                })
        return entries

Entry format

Each entry dict returned by rewrite() must have:

Key Type Required Description
host str yes The hostname (e.g. app.example.com)
path str yes The path (/ for catch-all, /api for specific)
upstream str yes The upstream address (host:port)
scheme str yes http or https
server_ca_secret str no Secret name containing CA cert for backend TLS
server_sni str no SNI server name for backend TLS
strip_prefix str no Path prefix to strip before proxying. On multi-path rules, scope it to the matching path — don't apply a global annotation blindly to every entry.
response_headers dict[str, str] no Headers to add to responses (e.g. CORS headers, security headers)
max_body_size str no Max client body size (e.g. "100M")
extra_directives list[str] no Deprecated. Provider-specific raw directives. See below.

Structured fields vs extra_directives

Prefer response_headers and max_body_size over extra_directives. Structured fields are provider-agnostic — every IngressProvider (Caddy, Nginx, future providers) can consume them. extra_directives was Caddy-specific syntax that other providers couldn't interpret.

entries.append({
    "host": "app.example.com",
    "path": "/",
    "upstream": "app:8080",
    "scheme": "http",
    "response_headers": {
        "X-Frame-Options": "DENY",
        "Access-Control-Allow-Origin": "*",
    },
    "max_body_size": "100M",
})

extra_directives still works — the CaddyProvider reads it as a fallback for third-party rewriters that haven't migrated. New rewriters should use structured fields exclusively.

How dispatch works

When the IngressProvider processes Ingress manifests, each manifest is dispatched to the first matching rewriter:

  1. Rewriters with extensions.<name>.enabled: false are dropped (Rewriter disabled: <name>)
  2. The rest are checked in priority order, external and built-in together (ties: external first). Each sees its own extensions.<name> block as ctx.extension_config during match(), and the winner keeps it during rewrite()
  3. If no rewriter matches, a warning is emitted and the manifest is skipped

The built-in HAProxyRewriter (priority 1100, checked last) matches:

  • ingressClassName: haproxy or empty/absent class (acts as default fallback)
  • Any manifest with haproxy.org/* annotations

The official nginx and traefik rewriters claim their own class, refuse another known class (haproxy, nginx, traefik) — as the real controller would — and otherwise (classless or an unmapped custom class) claim any Ingress carrying their annotation prefix. So a classless Ingress with nginx annotations goes to nginx when it's loaded, and to the HAProxy fallback when it isn't.

Custom ingress class names (ingress_types)

When clusters use custom ingressClassName values (e.g. haproxy-controller-internal, nginx-external), add an ingress_types mapping in dekube.yaml to resolve them to canonical rewriter names:

ingress_types:
  haproxy-controller-internal: haproxy
  haproxy-controller-external: haproxy
  nginx-internal: nginx

The mapping is applied before rewriter dispatch — rewriters see the canonical name. Without it, a custom class name only matches through a rewriter's annotation heuristic (nginx/traefik annotations, haproxy.org/*); an Ingress with neither is skipped with a warning.

Inside your rewriter, use get_ingress_class(manifest, ctx.config.get("ingress_types") or {}) to get the resolved class name. Both get_ingress_class and resolve_backend are part of the public interface — import them from dekube.

For building a complete reverse proxy backend (not just an annotation translator), see Writing ingress providers.

Override mechanism

An external rewriter with the same name as a built-in one replaces it entirely. For example, a custom HAProxyRewriter with name = "haproxy" would replace the built-in HAProxy handling.

When an override occurs, dekube prints:

Rewriter overrides built-in: haproxy

See Writing extensions for testing, repo structure, publishing, and available imports.