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)
helm 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 |
--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[].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 header (overwrite if exists) |
header-add |
Add a header (append if exists) |
header-remove |
Remove a header |
rules:
- matches:
- path: /old-page
type: Exact
actions:
- type: redirect
redirect:
path: /new-page
statusCode: 301
# No backendRefs needed for redirectsFor 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.
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.
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
backendRefs:
- name: api-service
namespace: backend
port: 8080Variables 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 |
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 rejectedCopyright 2024-2026 Freepik Company S.L.
Licensed under the Apache License, Version 2.0. See LICENSE for details.