Routing
Rule-based request matching with Host, PathPrefix, Path, Headers, and Method matchers, plus router priority, entrypoint binding, and Kubernetes Ingress routing
Routing
A3S Gateway uses a Traefik-style rule engine to match incoming requests to a service and an ordered middleware chain. There are two independent routing tables:
- The HTTP router table compiles every
routersblock into a route and matches an incoming request by host, path, method, headers, and the entrypoint it arrived on. - The TCP router table matches raw TLS connections by the SNI hostname from the ClientHello.
Config files use the .acl extension; HCL/TOML/YAML files are rejected at load.
Rule Syntax
A rule is one or more matchers joined with the && AND combinator. Arguments
are backtick-delimited.
rule = "Matcher(`value`) && Matcher(`value`)"The only combinator is && (logical AND — every matcher must match). There is
no || (OR), no negation, no regex, and no ClientIP/Query matchers. An
empty rule (no matchers) is a parse error, and an unknown matcher name fails
config loading.
HTTP Matchers
Prop
Type
PathPrefix() is a raw string starts_with — it does not enforce path
segment boundaries. PathPrefix(/api) matches /api, /api/x, and
/apixyz. Use Path() for exact matches.
A few matcher edge cases worth knowing:
Host()returns false when there is no Host header (it never matches a request with a missing authority).Headers()returns false if the header is absent or its value is not valid UTF-8. The value comparison is exact and case-sensitive; only the key lookup is case-insensitive.
Combining Matchers
Use && to combine multiple matchers (AND logic — all must match):
routers "api-v1" {
rule = "Host(`api.example.com`) && PathPrefix(`/v1`)"
service = "api-v1"
}
routers "webhooks" {
rule = "Method(`POST`) && PathPrefix(`/webhooks`)"
service = "webhook-handler"
}
routers "internal" {
rule = "Headers(`X-Internal`, `true`) && PathPrefix(`/admin`)"
service = "admin-service"
}Host Port-Stripping
The gateway strips a trailing :port from the request authority before
comparing it against a Host() rule, so a request arriving on a non-default
external port still matches a port-less rule:
# Matches a request with "Host: app.example.com" AND "Host: app.example.com:49164"
routers "app" {
rule = "Host(`app.example.com`)"
service = "app-service"
}IPv6 literals keep their [...] brackets intact — only a colon after the
closing ] is treated as the port separator. This behavior exists specifically
so traffic on non-default ports doesn't fall through to a host-less catch-all.
Priority
When multiple routers can match a request, the gateway picks the most specific one using an effective priority computed per route:
- If the configured
priorityis strictly greater than 0, it is used directly as the weight, and higher wins (Traefik-style). - Otherwise (priority of
0or any non-positive value), the weight falls back to the rule string length — the longer, more-specific rule wins.
Routes are sorted by effective priority descending, with ties broken by router name ascending for deterministic ordering. The first matching route in that order is selected; matching is a linear scan that returns the first hit.
Setting a negative priority on an HTTP router does not raise its
precedence — values of 0 or below are ignored and the rule-string length is
used instead. Only a positive priority overrides the length-based default.
Default priority is 0.
# Explicit priority: higher wins
routers "specific" {
rule = "Host(`api.example.com`) && Path(`/health`)"
service = "health-service"
priority = 100
}
routers "general" {
rule = "Host(`api.example.com`)"
service = "api-service"
priority = 10
}Without explicit priorities, the longer rule string wins automatically. This is
what prevents a host-less catch-all like PathPrefix(/) from swallowing more
specific routers:
# No priority set → both fall back to rule-string length.
# "Host(`api.example.com`) && PathPrefix(`/v1`)" is longer, so it outranks
# the bare "PathPrefix(`/`)" catch-all below.
routers "api-v1" {
rule = "Host(`api.example.com`) && PathPrefix(`/v1`)"
service = "api-v1"
}
routers "catch-all" {
rule = "PathPrefix(`/`)"
service = "default-service"
}Length-based ordering compares the literal rule string length, not the
number of matchers or path-segment specificity. A longer rule string outranks a
shorter one even if it is not semantically more specific. When you need precise
control, set an explicit positive priority.
Entrypoint Binding
Routers only receive traffic from the entrypoints they are bound to. An empty
entrypoints list means the router matches on all entrypoints; a non-empty
list restricts it to exactly those listener names.
entrypoints "web" {
address = "0.0.0.0:80"
}
entrypoints "websecure" {
address = "0.0.0.0:443"
tls {
cert_file = "cert.pem"
key_file = "key.pem"
}
}
# HTTPS only
routers "api" {
rule = "Host(`api.example.com`)"
service = "api-service"
entrypoints = ["websecure"]
}
# Both HTTP and HTTPS
routers "web" {
rule = "Host(`www.example.com`)"
service = "web-service"
entrypoints = ["web", "websecure"]
}
# Omitting entrypoints binds to ALL entrypoints
routers "global-health" {
rule = "Path(`/health`)"
service = "health-service"
}Each name listed in entrypoints must reference an entrypoint that exists, or
config validation fails.
Middleware Chain
Middlewares are applied in the order listed on the request, and in reverse order on the response. If any middleware rejects the request, the pipeline short-circuits immediately and the remaining middlewares are skipped.
routers "api" {
rule = "Host(`api.example.com`)"
service = "api-service"
middlewares = ["ip-allow", "rate-limit", "cors"]
# Order: IP check → rate limit → CORS headers
}Each name listed in middlewares must reference a middlewares block that
exists. See the Middlewares page for the available types.
TCP / SNI Routing
The TCP router table matches raw TLS connections using HostSNI(), which is
parsed from the SNI hostname in the TLS ClientHello:
Prop
Type
The TCP table uses the opposite priority convention from the HTTP table:
TCP routes are sorted by priority ascending (lower value = higher
priority).
HostSNI()-based TCP routing is part of the rule engine, but in v1.0.11 the SNI
matcher is not wired into the live TCP data plane. A TCP entrypoint routes
through the HTTP router table using the synthetic request
match_request(host=None, path="/", method="TCP", headers={}, entrypoint). In
practice this means a live TCP route must match path / with method TCP —
e.g. use PathPrefix(/) rather than HostSNI():
entrypoints "tcp-secure" {
address = "0.0.0.0:5432"
protocol = "tcp"
}
# Live TCP routing: matched via the HTTP table on path "/", method TCP.
routers "postgres" {
rule = "PathPrefix(`/`)"
service = "postgres-service"
entrypoints = ["tcp-secure"]
}UDP routing is port-based: each UDP entrypoint resolves a single upstream service at startup, so there is no per-request rule matching for UDP.
Kubernetes Ingress Routing
When the Kubernetes provider is enabled (requires the kube cargo feature,
shipped in the published Linux images), the gateway watches
networking.k8s.io/v1 Ingress resources and converts them into routers and
services.
For each Ingress rule/path it generates:
- A service named
{namespace}-{ingressName}-{backendServiceName}with the backend URLhttp://{svc}.{namespace}.svc.cluster.local:{port}(port defaults to80when unset). - A router with the same name. The rule is built from the host and path:
Host(h)when the host is non-empty, plusPath(p)forpathType: ExactorPathPrefix(p)for any otherpathType. A path of/or empty is dropped from the rule; if nothing remains, the catch-allPathPrefix(/)is used.
a3s-gateway.io/* Annotations
Routing behavior is configured with annotations on the Ingress resource. All annotations below are read per-Ingress:
Prop
Type
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
namespace: prod
annotations:
a3s-gateway.io/entrypoints: "websecure"
a3s-gateway.io/middlewares: "rate-limit, cors"
a3s-gateway.io/strategy: "least-connections"
a3s-gateway.io/priority: "100"
a3s-gateway.io/request-timeout: "600s"
spec:
rules:
- host: api.example.com
http:
paths:
- path: /v1
pathType: Prefix # → PathPrefix(`/v1`)
backend:
service:
name: api-backend
port:
number: 8080a3s-gateway.io/type=ingressroute is not a per-Ingress annotation. It is the
fixed ConfigMap label selector used by the optional IngressRoute watcher
(enabled via the provider's ingress_route_crd = true) to discover ConfigMaps
carrying IngressRoute specs. Do not set it on an Ingress alongside the seven
annotations above.
When using a tcp/udp protocol annotation, supply a3s-gateway.io/listen so a
stream entrypoint is created (named {svc}-tcp / {svc}-udp); UDP entrypoints
get a 30s session timeout:
metadata:
annotations:
a3s-gateway.io/protocol: "tcp"
a3s-gateway.io/listen: "0.0.0.0:5432"Examples
API Versioning
routers "api-v2" {
rule = "Host(`api.example.com`) && PathPrefix(`/v2`)"
service = "api-v2"
priority = 110
}
routers "api-v1" {
rule = "Host(`api.example.com`) && PathPrefix(`/v1`)"
service = "api-v1"
priority = 100
}
# Bare host fallback — keep it lower so the versioned prefixes win.
routers "api-fallback" {
rule = "Host(`api.example.com`)"
service = "api-v2"
priority = 10
}Multi-Tenant
routers "tenant-a" {
rule = "Host(`a.example.com`)"
service = "tenant-a-backend"
}
routers "tenant-b" {
rule = "Host(`b.example.com`)"
service = "tenant-b-backend"
}Header-Based Canary
# More specific rule (host + header + prefix) — give it a positive priority so
# it always beats the bare prefix below.
routers "canary" {
rule = "Headers(`X-Canary`, `true`) && PathPrefix(`/api`)"
service = "canary-backend"
priority = 100
}
routers "stable" {
rule = "PathPrefix(`/api`)"
service = "stable-backend"
}