Warning
This project is under active development and evolving quickly. Some features may be missing, incomplete, or subject to change. Use in production at your own discretion.
Dynamic HTTP routing for Kubernetes with Envoy and Istio. Define your routing rules as Kubernetes resources and let CustomRouter handle the rest.
CustomRouter consists of two components that work together:
- Operator: Watches
CustomHTTPRouteresources and compiles them into optimized routing tables - External Processor: An Envoy ext_proc that receives requests from your gateway and routes them based on the compiled rules
flowchart LR
subgraph Kubernetes
CHR[CustomHTTPRoute]
EPA[ExternalProcessorAttachment]
OP[Operator]
CM[ConfigMap]
EF[EnvoyFilter]
end
subgraph "Istio Gateway"
GW[Gateway Pod]
EP[External Processor]
end
subgraph Backend
SVC1[Service A]
SVC2[Service B]
SVC3[Service C]
end
CHR -->|watches| OP
EPA -->|watches| OP
OP -->|generates| CM
OP -->|generates| EF
CM -->|loads routes| EP
EF -->|configures| GW
GW -->|ext_proc call| EP
EP -->|routing decision| GW
GW --> SVC1
GW --> SVC2
GW --> SVC3
- You create
CustomHTTPRouteresources defining your routing rules - The operator compiles these rules into optimized ConfigMaps
- You create an
ExternalProcessorAttachmentto connect the external processor to your gateway - The operator generates EnvoyFilters that configure Istio to use the external processor
- Incoming requests hit the gateway, which calls the external processor
- The external processor matches the request against the routing rules and tells Envoy where to send it
- Kubernetes v1.26+
- Istio v1.18+ (for gateway integration)
- Go v1.25+ (for development)
- Bulk deletion: when many CustomHTTPRoutes sharing a target are deleted together (e.g. a namespace cascade), the operator no longer triggers one full ConfigMap rebuild per deleted route. After the first deletion rebuild (whose output already reflects every terminating sibling), the controller strips its own finalizer from the same-target siblings that are also being deleted, so they are garbage-collected without each forcing another rebuild. Third-party finalizers and live routes are left untouched. No action required.
- Operator memory: ConfigMap rebuilds are now single-flight per target. With
many CustomHTTPRoutes sharing one target and
--max-concurrent-reconciles>1, a reconcile burst (notably the cache resync after a restart) could previously run several full route-table rebuilds for the same target concurrently, multiplying peak memory and OOM-killing the operator. Now at most one rebuild per target runs at a time and the cooldown is re-checked inside that lock. - New
--rebuild-cooldownflag on the operator (default2s) to tune the minimum interval between rebuilds of the same target. No action required; raise it to further reduce rebuild frequency under heavy CustomHTTPRoute churn.
- Performance: the extproc no longer rebuilds its full route table on every
ConfigMap change. Change events are coalesced and the table is rebuilt at
most once per
--routes-reload-debouncewindow (default2s). This bounds CPU when many route ConfigMaps churn rapidly (large sandbox environments). Set it to0to restore the previous rebuild-on-every-event behaviour. - The controller now serializes route ConfigMaps deterministically (routes are ordered by their source CustomHTTPRoute's namespace/name before expansion). This makes the content-hash dedup effective, so an unchanged route set no longer produces spurious ConfigMap rewrites. No action required; no behaviour change to routing (only the relative order of equal-priority routes is now stable, which also makes first-match deterministic).
- New optional extproc flag
--route-partition-header. When set (e.g. toenv), routes are indexed per host by that request header's value, so a request is matched only against the routes sharing its value instead of scanning every route for the host. Intended for environments where a single external processor serves many isolated route sets disambiguated by a header (e.g. sandbox/preview environments keyed byenv). - Disabled by default (empty value) — route lookup behaviour is unchanged unless you opt in. The fast path is provably equivalent to the full scan, so it only affects performance, not which route wins.
ExternalProcessorAttachment.spec.retryPolicyis now richer. In addition tonumRetries, you can configureretryOn,retriableStatusCodes,perTryTimeout. See "Retry policy" below.- New top-level
spec.routeTimeoutreplaces the previously hardcoded30sper-route timeout. Defaults to30s(no behaviour change unless you set it). - New
spec.externalProcessorRef.failureModeAllowexposes Envoy'sfailure_mode_allowfor the ext_proc filter. Defaults tofalse(fail closed, matching the previous hardcoded behaviour). - When
spec.retryPolicyis omitted entirely, the generated EnvoyFilters no longer contain aretry_policyblock at all. Envoy treats this identically tonum_retries: 0, so there is no dataplane behaviour change versus 0.7.0.
Versions prior to 0.7.0 emitted num_retries: 2 hardcoded on every generated
route (routes, catch-all, mirror, CORS). Starting in 0.7.0, the default is
no retries. If you relied on the previous behaviour, set
spec.retryPolicy.numRetries: 2 (and optionally the rest of the retry policy
fields, see below) on each ExternalProcessorAttachment before upgrading.
apiVersion: customrouter.freepik.com/v1alpha1
kind: ExternalProcessorAttachment
metadata:
name: my-attachment
spec:
gatewayRef:
selector:
istio: gateway-production
externalProcessorRef:
service:
name: customrouter-extproc
namespace: customrouter
port: 50051
# Optional: let traffic through when extproc is unavailable.
# Defaults to false (fail closed with 5xx).
failureModeAllow: false
# Optional: per-request timeout applied to all customrouter-managed routes.
# Defaults to 30s.
routeTimeout: 30s
# Optional: emit a retry_policy on every customrouter-managed route.
# Omit the whole retryPolicy field to disable retries entirely (no
# retry_policy block emitted at all).
retryPolicy:
numRetries: 2
# Defaults to: "connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes"
retryOn: "connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes"
# Defaults to: [503]
retriableStatusCodes:
- 503
# Optional: per-attempt timeout. When omitted, the routeTimeout applies
# to the whole request including all retry attempts.
perTryTimeout: 5shelm install customrouter oci://ghcr.io/freepik-company/customrouter/helm-chart/customrouter \
--namespace customrouter \
--create-namespaceapiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
name: my-routes
spec:
# Target identifies which external processor handles these routes
targetRef:
name: default
# Hostnames this route applies to
hostnames:
- www.example.com
- example.com
# Optional path prefixes (e.g., for i18n)
pathPrefixes:
values: [es, fr, de]
policy: Optional # Optional | Required | Disabled
# Control which match types get prefix expansion (default: all)
expandMatchTypes: [PathPrefix, Exact] # Only expand these types
# Rules (max 100 per CustomHTTPRoute)
rules:
# Simple prefix match (default)
- matches: # max 50 matches per rule
- path: /api
backendRefs:
- name: api-service # RFC 1123 label (no dots, max 63 chars)
namespace: backend
port: 8080
# Exact match with high priority
- matches:
- path: /health
type: Exact
priority: 2000 # range: 1-10000
backendRefs:
- name: health-service
namespace: infra
port: 8080
# Regex match
- matches:
- path: ^/users/[0-9]+/profile$
type: Regex
backendRefs:
- name: users-service
namespace: backend
port: 8080
# Default fallback (low priority)
- matches:
- path: /
priority: 100
backendRefs:
- name: default-service
namespace: web
port: 80apiVersion: customrouter.freepik.com/v1alpha1
kind: ExternalProcessorAttachment
metadata:
name: production-gateway
namespace: istio-system
spec:
# Select which gateway pods to attach to
gatewayRef:
selector:
istio: gateway-production
# Reference to the external processor service
externalProcessorRef:
service:
name: customrouter-extproc
namespace: customrouter
port: 9001
# Optional: Generate catch-all routes for hostnames without HTTPRoute
# This allows CustomHTTPRoute to handle requests without requiring
# a base HTTPRoute to be configured separately
catchAllRoute:
hostnames:
- example.com
- www.example.com
backendRef:
name: default-backend
namespace: web
port: 80The chart supports deploying the operator and multiple external processors:
# values.yaml
operator:
enabled: true
replicaCount: 1
args:
- --leader-elect
- --health-probe-bind-address=:8081
# Optional: enable validating webhooks for hostname conflict detection
webhook:
enabled: true
timeoutSeconds: 10
# certManager:
# enabled: true
# issuerName: my-cluster-issuer
externalProcessors:
# Default processor
default:
enabled: true
replicaCount: 2
args:
- --addr=:9001
- --target-name=default
- --routes-configmap-namespace=default
- --access-log=true
# Additional processor for different routes
internal:
enabled: true
replicaCount: 1
args:
- --addr=:9001
- --target-name=internal
- --routes-configmap-namespace=default
- --access-log=false
# Deploy additional resources
extraObjects:
- apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
name: my-routes
spec:
targetRef:
name: default
# ...See chart/values.yaml for all available options.
- Go v1.25+
- Docker
- kubectl
- A Kubernetes cluster (kind, minikube, etc.)
# Build all binaries
make build-all
# Build only the operator
make build
# Build only the external processor
make build-extproc# Install CRDs
make install
# Run the operator locally
make run
# Run the external processor locally
make run-extproc# Run unit tests
make test
# Run e2e tests (requires a cluster)
make test-e2e# Build all images
make docker-build-all
# Build and push all images
make docker-build-all docker-push-all# Generate CRDs, RBAC, and DeepCopy methods
make generate manifestsRun make help to see all available targets:
Usage:
make <target>
General:
help Display this help
Development:
manifests Generate CRDs and RBAC
generate Generate DeepCopy methods
fmt Run go fmt
vet Run go vet
test Run tests
test-e2e Run e2e tests
Build:
build Build operator binary
build-extproc Build external processor binary
build-all Build all binaries
run Run operator locally
run-extproc Run external processor locally
Docker:
docker-build Build operator image
docker-build-extproc Build external processor image
docker-build-all Build all images
docker-push Push operator image
docker-push-extproc Push external processor image
docker-push-all Push all images
Deployment:
install Install CRDs
uninstall Uninstall CRDs
deploy Deploy to cluster
undeploy Undeploy from cluster
sync-chart-crds Copy generated CRDs into the Helm chart directory
The operator watches CustomHTTPRoute resources, compiles routing rules, and writes them to ConfigMaps in the namespace configured by --routes-configmap-namespace (default: default).
| Flag | Default | Description |
|---|---|---|
--routes-configmap-namespace |
default |
Namespace where route ConfigMaps are written |
--leader-elect |
false |
Enable leader election for HA |
--health-probe-bind-address |
:8081 |
Address for health probes |
--enable-webhooks |
false |
Enable validating admission webhooks |
--webhook-port |
9443 |
Port for the webhook server |
--webhook-config-name |
"" |
ValidatingWebhookConfiguration name (auto-cert mode) |
--webhook-service-name |
"" |
Webhook Service name for TLS SAN (auto-cert mode) |
--webhook-cert-path |
"" |
Directory with TLS certs (cert-manager mode) |
Both the operator and external processor containers run with a hardened security context:
runAsNonRoot: true— containers never run as rootreadOnlyRootFilesystem: true— no writes to the container filesystemcapabilities: drop: ["ALL"]— all Linux capabilities are droppedseccompProfile: type: RuntimeDefault— default seccomp filtering
These defaults are applied by the Helm chart and can be customized via operator.securityContext and externalProcessors.<name>.securityContext in values.yaml.
The external processor reads route ConfigMaps and makes routing decisions for Envoy.
| Flag | Default | Description |
|---|---|---|
--addr |
:9001 |
gRPC listen address |
--target-name |
"" |
Target name to filter ConfigMaps (matches spec.targetRef.name) |
--routes-configmap-namespace |
"" |
Namespace to read ConfigMaps from (empty = all namespaces) |
--access-log |
true |
Enable access logging |
--metrics-addr |
:9090 |
Address for Prometheus metrics (empty to disable) |
--debug |
false |
Enable debug logging and gRPC reflection |
--kubeconfig |
"" |
Path to kubeconfig (uses in-cluster config if not set) |
Important: Set
--routes-configmap-namespaceon the external processor to match the operator's--routes-configmap-namespace. This prevents stale ConfigMaps in other namespaces from causing route conflicts.
Defines routing rules for a set of hostnames. Rules are compiled into an optimized routing table stored in ConfigMaps.
| Field | Description |
|---|---|
targetRef.name |
Which external processor handles these routes |
hostnames |
List of hostnames this route applies to (max 50) |
pathPrefixes |
Optional prefixes to prepend to all paths (max 100 values) |
pathPrefixes.expandMatchTypes |
Which match types are expanded with prefixes (default: all) |
rules[].matches |
Path matching conditions (max 50 per rule) |
rules[].actions |
Optional transformations (redirect, rewrite, headers) |
rules[].actions[].rewrite.preservePrefix |
Prepend language prefix to rewrite path in expanded routes |
rules[].actions[].redirect.preservePrefix |
Prepend language prefix to redirect path in expanded routes |
rules[].actions[].redirect.replacePrefixMatch |
Strip matched PathPrefix and append remaining suffix to redirect path (Gateway API-style) |
rules[].backendRefs |
Target services — name must be a valid RFC 1123 label (no dots) |
rules[].allowOverlap |
Permit overlap with other CustomHTTPRoutes (warn instead of reject) |
When a backendRef points to a Kubernetes Service of type ExternalName, the controller automatically resolves spec.externalName and uses it as the backend hostname. This is necessary because Istio/Envoy does not create clusters for the .svc.cluster.local FQDN of ExternalName services.
# Given this Service:
apiVersion: v1
kind: Service
metadata:
name: profile-external
namespace: web
spec:
type: ExternalName
externalName: stable.profile.apps.internal
# This backendRef:
backendRefs:
- name: profile-external
namespace: web
port: 80
# Resolves to: stable.profile.apps.internal:80
# Instead of: profile-external.web.svc.cluster.local:80The controller watches Services and re-reconciles affected CustomHTTPRoutes when an ExternalName service changes.
Connects an external processor to Istio gateway pods by generating EnvoyFilters.
| Field | Description |
|---|---|
gatewayRef.selector |
Labels to match gateway pods |
externalProcessorRef.service |
External processor service reference |
externalProcessorRef.timeout |
gRPC connection timeout — valid duration string, e.g. 5s, 500ms (default: "5s") |
externalProcessorRef.messageTimeout |
Message exchange timeout — valid duration string, e.g. 5s, 500ms (default: "5s") |
catchAllRoute.hostnames |
Hostnames to generate catch-all routes for |
catchAllRoute.backendRef |
Default backend for unmatched requests |
Both CRDs report status via standard Kubernetes conditions. Each condition includes ObservedGeneration so clients can distinguish stale status from the current spec revision.
| CRD | Condition | Description |
|---|---|---|
CustomHTTPRoute |
Reconciled |
Whether the manifest was processed successfully |
CustomHTTPRoute |
ConfigMapSynced |
Whether the ConfigMap was generated and synced |
ExternalProcessorAttachment |
Reconciled |
Whether the attachment was processed successfully |
ExternalProcessorAttachment |
EnvoyFilterSynced |
Whether the EnvoyFilters were generated and synced |
By default, CustomHTTPRoute requires a base HTTPRoute to be configured at the Istio Gateway level. Without it, requests are rejected with 404 before reaching the external processor.
The catchAllRoute field solves this by generating an EnvoyFilter that creates virtual hosts for the specified hostnames:
spec:
catchAllRoute:
hostnames:
- example.com
- api.example.com
backendRef:
name: default-backend
namespace: default
port: 80When configured, the operator generates three EnvoyFilters:
<name>-extproc: Inserts the ext_proc filter<name>-routes: Adds dynamic routing based on ext_proc headers<name>-catchall: Creates catch-all virtual hosts for the specified hostnames
| Type | Description | Example |
|---|---|---|
PathPrefix |
Matches path prefix (default) | /api matches /api/users |
Exact |
Matches exact path | /health only matches /health |
Regex |
Go regexp syntax | ^/users/[0-9]+$ |
By default, all match types (PathPrefix, Exact, Regex) are expanded with path prefixes. You can control which types are expanded using expandMatchTypes:
pathPrefixes:
values: [es, fr]
policy: Optional
expandMatchTypes: [PathPrefix] # Only expand PathPrefix, leave Exact and Regex as-isThis can also be overridden at the rule level:
rules:
- matches:
- path: /user/me
type: Exact
backendRefs:
- name: user-service
namespace: backend
port: 8080
pathPrefixes:
policy: Optional
expandMatchTypes: [PathPrefix] # This rule won't expand Exact matchesRoutes are evaluated by priority (higher first). Default priority is 1000. Valid range: 1–10000.
- Use high priority (e.g., 2000) for specific routes like
/health - Use low priority (e.g., 100) for catch-all routes like
/
Actions allow you to transform requests before forwarding or return immediate responses.
| Action Type | Description |
|---|---|
redirect |
Return HTTP redirect (301, 302, 307, 308). No backend needed. |
rewrite |
Rewrite path and/or hostname before forwarding |
header-set |
Set a request header (overwrite if exists) |
header-add |
Add a request header (append if exists) |
header-remove |
Remove a request header |
response-header-set |
Set a response header (overwrite if exists) |
response-header-add |
Add a response header (append if exists) |
response-header-remove |
Remove a response header |
request-mirror |
Duplicate the request to a secondary backend (native Envoy mirroring; zero ExtProc overhead) |
cors |
Install a CORS policy (native Envoy CORS filter; zero ExtProc overhead) |
rules:
- matches:
- path: /old-page
type: Exact
actions:
- type: redirect
redirect:
path: /new-page
statusCode: 301
# No backendRefs needed for redirects
# ReplacePrefixMatch redirect (Gateway API-style):
# /old-api/users/1 -> 302 Location: /v2/users/1
- matches:
- path: /old-api
type: PathPrefix
actions:
- type: redirect
redirect:
path: /v2
replacePrefixMatch: true
statusCode: 302replacePrefixMatch is opt-in and only effective for PathPrefix matches. When omitted or set to false, the redirect path is used as-is (every matched request redirects to the same URL). Not supported with Regex matches.
For PathPrefix matches, the rewrite replaces only the matched prefix and preserves the remaining path suffix and query parameters. For Exact and Regex matches, the rewrite replaces the entire path.
When a rewrite changes the path, the original request path is automatically preserved in the x-envoy-original-path header. This ensures that access logs and observability tools that rely on this standard header (e.g. %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%) correctly reflect the path originally requested by the client.
rules:
# Prefix rewrite: /mockup-editor-api/unity?page=1 -> /api/v1/unity?page=1
- matches:
- path: /mockup-editor-api
actions:
- type: rewrite
rewrite:
path: /api/v1
backendRefs:
- name: backend-api
namespace: backend
port: 80
# Rewrite with hostname change
- matches:
- path: /blog
actions:
- type: rewrite
rewrite:
path: /cms/blog
hostname: cms-internal.svc.cluster.local
backendRefs:
- name: cms-service
namespace: backend
port: 8080
# Full rewrite with variables: /users/42 -> /api/v2/profile/42
- matches:
- path: /users
actions:
- type: rewrite
rewrite:
path: /api/v2/profile/${path.segment.1}
backendRefs:
- name: user-service
namespace: backend
port: 8080
# Explicit replacePrefixMatch override:
# Force full rewrite even on a PathPrefix match
- matches:
- path: /old-api
actions:
- type: rewrite
rewrite:
path: /v2
replacePrefixMatch: false
backendRefs:
- name: api-service
namespace: backend
port: 8080When using pathPrefixes, expanded routes normally share the same rewrite/redirect path. This means the language prefix is lost during rewrite:
Request: /es/blog/post1 → match /es/blog → rewrite /cms/blog/post1 (prefix /es lost)
The preservePrefix option prepends the language prefix to the rewrite/redirect path at expansion time, so each expanded route gets its own prefixed path:
spec:
pathPrefixes:
values: [es, fr]
policy: Optional
rules:
# Rewrite with preservePrefix
- matches:
- path: /blog
actions:
- type: rewrite
rewrite:
path: /cms/blog
preservePrefix: true
backendRefs:
- name: cms-service
namespace: backend
port: 8080
# Redirect with preservePrefix
- matches:
- path: /old-blog
actions:
- type: redirect
redirect:
path: /new-blog
statusCode: 301
preservePrefix: trueThis generates the following expanded routes:
| Route path | Rewrite/Redirect path |
|---|---|
/blog |
/cms/blog |
/es/blog |
/es/cms/blog |
/fr/blog |
/fr/cms/blog |
/old-blog |
/new-blog |
/es/old-blog |
/es/new-blog |
/fr/old-blog |
/fr/new-blog |
More examples:
Localized app with a CMS backend — the CMS needs the language prefix to serve the right content:
spec:
pathPrefixes:
values: [es, fr, de, it, pt]
policy: Required # All routes must have a language prefix
rules:
- matches:
- path: /
actions:
- type: rewrite
rewrite:
path: /app
preservePrefix: true
backendRefs:
- name: frontend
namespace: web
port: 3000| Request | Route match | Rewrite |
|---|---|---|
/es |
/es |
/es/app |
/fr/products/123 |
/fr |
/fr/app/products/123 |
Selective preservePrefix — only the CMS rewrite needs it, the API rewrite doesn't:
spec:
pathPrefixes:
values: [es, fr]
policy: Optional
rules:
# CMS needs the language to serve localized content
- matches:
- path: /blog
actions:
- type: rewrite
rewrite:
path: /cms/blog
preservePrefix: true
backendRefs:
- name: cms
namespace: backend
port: 8080
# API is language-agnostic, no preservePrefix needed
- matches:
- path: /api
actions:
- type: rewrite
rewrite:
path: /v2/api
backendRefs:
- name: api
namespace: backend
port: 8080| Request | Rewrite |
|---|---|
/es/blog/post1 |
/es/cms/blog/post1 |
/blog/post1 |
/cms/blog/post1 |
/es/api/users |
/v2/api/users |
/api/users |
/v2/api/users |
Notes:
- Works with
PathPrefixandExactmatch types. Not supported forRegex(rejected at validation). - When
preservePrefixisfalseor not set, all expanded routes share the same rewrite/redirect path (existing behavior). - When no
pathPrefixesare defined,preservePrefixis a no-op. - Zero runtime overhead: prefix is resolved at expansion time, not per-request.
Request headers (header-*) are injected by the ExtProc as it forwards the
request. Response headers (response-header-*) are injected on the way back;
they do not affect the primary request path and run without an ExtProc
round-trip on the response side.
rules:
- matches:
- path: /api
actions:
- type: header-set
header:
name: X-Real-IP
value: ${client_ip}
- type: header-add
header:
name: X-Request-ID
value: ${request_id}
- type: header-remove
headerName: X-Internal-Debug
- type: response-header-set
header:
name: X-Cache-Status
value: MISS
- type: response-header-remove
headerName: Server
backendRefs:
- name: api-service
namespace: backend
port: 8080Duplicates matched requests to a secondary backend. The primary request is
served normally; the mirrored response is discarded. Mirroring is dispatched
by Envoy's native request_mirror_policies — the ExtProc hot path is
untouched, so the latency budget of the primary request is preserved.
rules:
- matches:
- path: /api
type: PathPrefix
actions:
- type: request-mirror
mirror:
backendRef:
name: shadow-api
namespace: backend
port: 8080
# Optional: only mirror 25% of matched traffic.
# Omit for 100%.
percent: 25
backendRefs:
- name: api-service
namespace: backend
port: 8080Notes:
- The mirror backend must be a Kubernetes Service in the same Istio mesh.
- Partial mirroring is implemented via Envoy's
runtime_fraction(denominatorHUNDRED). - Multiple
request-mirroractions in the same rule produce one mirror policy each.
Installs a Cross-Origin Resource Sharing policy for the matched routes. Both
preflight (OPTIONS) handling and response-header injection are performed
by Envoy's native CORS filter via typed_per_filter_config, so the ExtProc
never sees the preflight request and the hot path is not affected.
rules:
- matches:
- path: /api
type: PathPrefix
actions:
- type: cors
cors:
allowOrigins:
- https://app.example.com
- https://admin.example.com
allowMethods: [GET, POST, PUT, DELETE, OPTIONS]
allowHeaders: [authorization, content-type, x-request-id]
exposeHeaders: [x-request-id]
allowCredentials: true
maxAge: 3600 # seconds; browsers cache preflight for this long
backendRefs:
- name: api-service
namespace: backend
port: 8080Notes:
allowOriginsmust be"*"or an absolute URI with scheme and host (no path/query/fragment)."*"origin is incompatible withallowCredentials: true; the webhook rejects that combination because browsers reject it at runtime.- If a rule declares multiple
corsactions, the last one wins (a single CORS policy per route is supported, matching Envoy's model).
Variables can be used in redirect.path, rewrite.path, and header.value:
| Variable | Description |
|---|---|
${path} |
Original request path |
${host} |
Original request host |
${method} |
HTTP method (GET, POST, etc.) |
${scheme} |
Request scheme (http or https) |
${client_ip} |
Client IP from X-Forwarded-For |
${request_id} |
Request ID from X-Request-ID header |
${path.segment.N} |
Nth path segment (0-indexed) |
The CRD enforces the following limits to prevent resource exhaustion:
| Field | Limit |
|---|---|
spec.hostnames[] |
Max 50 items |
spec.rules[] |
Max 100 items |
rules[].matches[] |
Max 50 items per rule |
pathPrefixes.values[] |
Max 100 items |
matches[].priority |
Range 1–10000 |
backendRefs[].name |
RFC 1123 label (max 63 chars, no dots) |
backendRefs[].namespace |
RFC 1123 label (max 63 chars, no dots) |
matches[].path |
MaxLength 4096 |
rewrite.path |
MaxLength 4096 |
rewrite.hostname |
MaxLength 253 |
redirect.path |
MaxLength 4096 |
redirect.hostname |
MaxLength 253 |
header.name |
MaxLength 256 |
header.value |
MaxLength 4096 |
action.headerName |
MaxLength 256 |
mirror.percent |
Range 0–100 (unset = 100) |
cors.allowOrigins[] |
Max 64 items; each must be "*" or absolute URI |
cors.allowMethods[] |
Max 16 items |
cors.allowHeaders[] |
Max 64 items |
cors.exposeHeaders[] |
Max 64 items |
cors.maxAge |
Range 0–86400 seconds |
externalProcessorRef.timeout |
Valid duration pattern: ^[0-9]+(s|ms|m|h)$ |
externalProcessorRef.messageTimeout |
Valid duration pattern: ^[0-9]+(s|ms|m|h)$ |
Additionally, route expansion is capped at 500,000 routes per CRD at runtime. CRDs exceeding this limit are skipped with an error log.
In multi-tenant clusters, hostnames are scoped by namespace. When multiple CustomHTTPRoute resources across different namespaces target the same hostname, the namespace that appears first alphabetically owns that hostname. Routes from non-owning namespaces for the same hostname are silently dropped.
The operator includes optional validating admission webhooks that prevent route conflicts at admission time, before resources reach etcd.
Two webhooks are provided:
- CustomHTTPRoute webhook (
failurePolicy: Fail): Blocks creation/update if another CustomHTTPRoute with the same target has overlapping hostname + route match (path + method + headers + query parameters). Different paths, methods, headers, or query parameters on the same hostname are allowed. - HTTPRoute webhook (
failurePolicy: Ignore): Blocks creation/update if a Gateway API HTTPRoute uses a hostname + route match already claimed by a CustomHTTPRoute.
Conflict detection uses the full HTTPRouteMatch surface:
- Path: type + value (with trailing slash normalization —
/api/equals/api) - Method: empty means "matches all methods"; different methods (e.g.
GETvsPOST) don't conflict - Headers: empty means "matches all"; different values for the same header name don't conflict
- Query parameters: same logic as headers
Enable in Helm:
operator:
webhook:
enabled: trueBy default, TLS certificates are auto-generated at startup and shared across replicas via a Secret. A CABundleReconciler periodically ensures the CA bundle survives Helm upgrades. For environments with cert-manager:
operator:
webhook:
enabled: true
certManager:
enabled: true
issuerName: my-cluster-issuer
issuerKind: ClusterIssuerSee chart/values.yaml for all webhook options including timeoutSeconds, namespaceSelector, failurePolicy, and caBundle.
The allowOverlap field on a rule lets it overlap with rules in other CustomHTTPRoutes. When true, the webhook emits a warning instead of rejecting the resource. This enables zero-downtime migrations between CustomHTTPRoutes.
Note: Conflicts with Gateway API HTTPRoute resources are always rejected regardless of this setting.
Given an existing route in legacy-routes:
apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
name: legacy-routes
spec:
targetRef:
name: default
hostnames:
- www.example.com
rules:
- matches:
- path: /api/users
backendRefs:
- name: users-service
namespace: backend
port: 8080Create the new route with allowOverlap: true to avoid rejection:
apiVersion: customrouter.freepik.com/v1alpha1
kind: CustomHTTPRoute
metadata:
name: new-routes
spec:
targetRef:
name: default
hostnames:
- www.example.com
rules:
- matches:
- path: /api/users
backendRefs:
- name: users-service-v2
namespace: backend
port: 8080
allowOverlap: trueThe webhook accepts the resource with a warning:
Warning: route conflict on hostnames [www.example.com]: PathPrefix /api/users
already defined in CustomHTTPRoute legacy-routes (allowed via allowOverlap)
Once verified, remove the rule from legacy-routes and drop allowOverlap from new-routes.
allowOverlap is set per rule, so only the rules that need it are affected:
rules:
- matches:
- path: /api/users
backendRefs:
- name: users-service-v2
namespace: backend
port: 8080
allowOverlap: true # Warning on conflict
- matches:
- path: /api/orders
backendRefs:
- name: orders-service
namespace: backend
port: 8080
# allowOverlap defaults to false — conflicts are rejectedThe external processor exposes Prometheus metrics via an HTTP endpoint on a separate port from the gRPC server.
| Flag | Default | Description |
|---|---|---|
--metrics-addr |
:9090 |
Address for Prometheus metrics endpoint (empty to disable) |
Available metrics:
| Metric | Type | Labels | Description |
|---|---|---|---|
customrouter_requests_total |
Counter | route_found |
Total requests processed |
customrouter_request_duration_seconds |
Histogram | route_found |
Request processing latency |
customrouter_route_matches_total |
Counter | match_type |
Route matches by type (prefix, exact, regex) |
customrouter_route_not_found_total |
Counter | — | Requests with no matching route |
customrouter_processing_errors_total |
Counter | — | Errors during request processing |
Enable the metrics port and Prometheus Operator ServiceMonitor in values.yaml:
externalProcessors:
default:
args:
- --metrics-addr=:9090
metrics:
enabled: true
port: 9090
serviceMonitor:
enabled: true
interval: 30s
labels: {} # Additional labels for ServiceMonitor
metricRelabelings: [] # Metric relabeling configs
relabelings: [] # Relabeling configsWhen metrics.enabled is true, the chart exposes a metrics port on both the Deployment and Service. When serviceMonitor.enabled is true, a ServiceMonitor resource is created for auto-discovery by Prometheus Operator.
The chart supports custom labels and annotations on both the Deployment metadata and pod template. This is useful for integrations like Admitik that watch Deployment resources (generation policies) and mutate Pods (sidecar injection):
externalProcessors:
default:
# Labels/annotations on the Deployment metadata
deploymentLabels: {}
deploymentAnnotations: {}
# Labels/annotations on the Pod template
podLabels: {}
podAnnotations: {}
operator:
deploymentLabels: {}
deploymentAnnotations: {}
podLabels: {}
podAnnotations: {}If your cluster uses Admitik with FluentBit sidecar injection, add the logging labels and annotations to ship logs to OpenSearch via Vector:
externalProcessors:
default:
# Deployment metadata: triggers FluentBit ConfigMap generation
deploymentLabels:
admitik.policy.v1/logging: "true"
deploymentAnnotations:
admitik.policy.v1/loggingConfig: |
- container: external-processor
index: prod-customrouter
# Pod template: triggers FluentBit sidecar injection
podLabels:
admitik.policy.v1/logging: "true"The external processor already outputs structured JSON logs to stdout (via zap), so no log format changes are needed.
The following PrometheusRule alerts are recommended for production deployments:
| Alert | Severity | Description |
|---|---|---|
CritCustomrouterHighRouteNotFoundRate |
critical | >10% of requests not matching any route for 5m |
WarnCustomrouterRouteNotFoundRate |
warning | >5% of requests not matching any route for 5m |
CritCustomrouterExtprocDown |
critical | 0 available extproc replicas for 2m |
CritCustomrouterCrashLoopBackOff |
critical | Pod in CrashLoopBackOff for 5m |
CritCustomrouterHPAMaxReplicas |
critical | HPA at max replicas for 10m |
These can be deployed as a PrometheusRule resource via extraObjects in the Helm chart values. See the chart/values.yaml for the extraObjects field.
Key panels for a CustomRouter Grafana dashboard:
- Request rate:
sum(rate(customrouter_requests_total[5m])) by (route_found) - Route not found rate:
sum(rate(customrouter_route_not_found_total[5m])) / sum(rate(customrouter_requests_total[5m])) - Latency p50/p95/p99:
histogram_quantile(0.95, sum(rate(customrouter_request_duration_seconds_bucket[5m])) by (le)) - Match type distribution:
sum(rate(customrouter_route_matches_total[5m])) by (match_type) - Processing errors:
sum(rate(customrouter_processing_errors_total[5m]))
Copyright 2024-2026 Freepik Company S.L.
Licensed under the Apache License, Version 2.0. See LICENSE for details.