Writing ingress providers¶
In Kubernetes, the ingress controller is a load-balanced, health-checked, horizontally scaled reverse proxy backed by an entire control plane. We replaced that steel bridge with an assembly of twigs — a single-instance reverse proxy on a single host, configured by a generated flat file. Don't expect a freight train to cross it. But you're here, reading this page, which means you've already accepted the premise. So let's build the best twigs we can.
The gatekeeper was not merely a guard — he was the gate itself, the threshold, and the architect of the path beyond. When the temple replaced him, it did not hire a new guard. It built a new door.
— Unaussprechlichen Kulten, On the Replacement of Thresholds (if memory serves)
What it is¶
IngressProvider is an abstract class (subclass of Provider) that owns the entire Ingress conversion lifecycle — from raw manifests to a running reverse proxy with a config file. Everything flows through it. Every route, every TLS termination, every path rewrite ultimately becomes whatever your provider decides to emit. It lives in dekube.core.ingress internally, but extensions should import it from the pacts API.
The lifecycle:
- Receives all
Ingressmanifests (viaconvert()) - Dispatches each manifest to the first matching
IngressRewriter(via_find_rewriter()) - Collects all rewriter entries — the abstract routing rules, stripped of their controller-specific dialects
- Calls
build_service(entries, ctx)to produce the reverse proxy compose service - Calls
write_config(entries, output_dir, config)to write the proxy config file
Base class attributes¶
| Attribute | Value | Description |
|---|---|---|
kinds |
["Ingress"] |
Hardcoded — IngressProvider always claims Ingress |
priority |
900 |
Runs after all other converters/providers |
name |
"ingress" |
Override with your provider's name (e.g. "caddy", "traefik") |
The contract¶
Two methods to implement:
build_service(entries, ctx) -> dict¶
Receives all collected rewriter entries and the ConvertContext. Returns a dict of compose service definitions (keyed by service name), or empty dict if disabled.
def build_service(self, entries, ctx):
return {"my-proxy": {
"image": "my-proxy:latest",
"restart": "always",
"ports": ["80:80", "443:443"],
"volumes": ["./my-proxy.conf:/etc/proxy.conf:ro"],
}}
write_config(entries, output_dir, config)¶
Writes the reverse proxy configuration file to output_dir. Receives the collected entries, the output directory, and the raw config dict (from dekube.yaml).
def write_config(self, entries, output_dir, config):
import os
path = os.path.join(output_dir, "my-proxy.conf")
with open(path, "w", encoding="utf-8") as f:
for entry in entries:
f.write(f"route {entry['host']}{entry['path']} -> {entry['upstream']}\n")
How dispatch works¶
The convert() method on IngressProvider is already implemented — you don't override it. It does:
def convert(self, _kind, manifests, ctx):
entries = []
for m in manifests:
rewriter = self._find_rewriter(m, ctx)
entries.extend(rewriter.rewrite(m, ctx))
services = {}
if entries and not ctx.config.get("disable_ingress"):
services = self.build_service(entries, ctx)
return ProviderResult(services=services, ingress_entries=entries)
Each Ingress manifest is matched against loaded IngressRewriter classes. The rewriter translates controller-specific annotations into a list of entry dicts (see Writing rewriters for the entry format). Your provider then consumes those entries to build the service and config.
Entry format¶
Each entry dict produced by rewriters has these fields:
| Field | Type | Required | Description |
|---|---|---|---|
host |
str |
yes | Hostname (e.g. api.example.com) |
path |
str |
yes | URL path (e.g. /api/, /) |
upstream |
str |
yes | Backend address (host:port) |
scheme |
str |
yes | http or https (backend protocol) |
strip_prefix |
str \| None |
no | Path prefix to strip before proxying |
server_ca_secret |
str |
no | Secret name containing CA cert for backend TLS |
server_sni |
str |
no | SNI hostname for backend TLS |
response_headers |
dict[str, str] |
no | Headers to add to responses |
max_body_size |
str |
no | Max client body size (e.g. "100M") |
extra_directives |
list[str] |
no | Deprecated. Provider-specific raw directives (Caddy syntax). Kept for third-party rewriter backward compat. |
Providers should read response_headers and max_body_size first, then fall back to extra_directives for backward compat with third-party rewriters that haven't migrated yet.
Reference: CaddyProvider¶
The built-in CaddyProvider (in dekube-provider-caddy) is the reference implementation:
from dekube import IngressProvider
class CaddyProvider(IngressProvider):
name = "caddy"
def build_service(self, entries, ctx):
ext_cfg = ctx.extension_config
if ext_cfg.get("disabled"):
return {}
volume_root = ctx.config.get("volume_root", "./data")
caddy_volumes = [
"./Caddyfile:/etc/caddy/Caddyfile:ro",
f"{volume_root}/caddy:/data",
f"{volume_root}/caddy-config:/config",
]
# Mount CA secrets for backend TLS
ca_secrets = {e["server_ca_secret"] for e in entries
if e.get("server_ca_secret")}
for secret_name in sorted(ca_secrets):
caddy_volumes.append(
f"./secrets/{secret_name}"
f":/etc/caddy/certs/{secret_name}:ro")
return {"caddy": {
"image": "caddy:2-alpine", "restart": "always",
"ports": ["80:80", "443:443"],
"volumes": caddy_volumes,
}}
def write_config(self, entries, output_dir, config):
ext_cfg = config.get("extensions", {}).get(self.name, {})
caddy_email = ext_cfg.get("email")
tls_internal = bool(ext_cfg.get("tls_internal"))
filename = "Caddyfile"
if config.get("disable_ingress"):
filename = f"Caddyfile-{config.get('name', 'project')}"
path = os.path.join(output_dir, filename)
with open(path, "w", encoding="utf-8") as f:
f.write("# Generated by helmfile2compose\n\n")
if caddy_email:
f.write(f"{{\n\temail {caddy_email}\n}}\n\n")
# Group entries by host, write Caddy host blocks
# (specific paths first, catch-all last)
by_host = {}
for e in entries:
by_host.setdefault(e["host"], []).append(e)
for host, host_entries in by_host.items():
_write_caddy_host_block(f, host, host_entries, tls_internal)
See the full implementation in dekube-provider-caddy (125 lines, including the host block writer and reverse_proxy directive helpers).
When to write one¶
Write an IngressProvider when you want to replace the entire reverse proxy backend — Caddy with Traefik, Nginx Proxy Manager, HAProxy (as a proxy, not just a rewriter), etc. You're rebuilding the front door, not just changing the lock. Make sure you actually need a new door before committing to the carpentry.
If you only need to translate a different ingress controller's annotations into entry dicts, write an IngressRewriter instead — that's much simpler, and much harder to break.
Distribution-level vs external¶
IngressProvider subclasses are typically bundled into distributions at build time via build-distribution.py — that's the intended pattern. The ingress provider isn't just another extension. It defines the shape of the output: Caddyfile or nginx.conf or traefik.yml. The gate defines the temple.
That said, loading an IngressProvider from --extensions-dir works — the extension loader detects it as a converter (it has kinds and convert()), and the CLI scans for the active IngressProvider at runtime. It's just not the recommended path: shipping your gateway as a loose extension means every user must remember to install it, and forgetting turns every Ingress manifest into a warning and a shrug. Prefer building a custom distribution instead.