Usage
The kis.ai API gateway (binary gateway.svc) is a
TLS-terminating, multi-tenant edge proxy and static/SPA file server. It embeds
Traefik v3 in process and translates a kis.ai-flavoured YAML config into
Traefik’s dynamic configuration, adding CPET (tenant) resolution, header/path
transforms, and metric/telemetry middleware on top.
This guide is the quickstart and the how-to. For the concepts behind it, see the
design docs. Every example named below is embedded in the binary —
gateway.svc config generate <name> prints it.
Quick start
Section titled “Quick start”Local dev, HTTP-only, no certs, runs without root:
# 1. Bootstrap file (listeners, log level, where routing lives)gateway.svc config generate quickstart > .gateway.yaml
# 2. A routing config, dropped into the config/ directorygateway.svc config generate spa-http -d config/
# 3. Content for the SPA example to servemkdir -p public && echo '<h1>hello gateway</h1>' > public/index.html
# 4. Run itgateway.svc -f .gateway.yaml -L debug
# 5. Hit itcurl http://localhost:8443/See what else ships:
gateway.svc config list # every embedded example + one-linergateway.svc config generate single-app # print one to stdoutgateway.svc config generate common -d config/ # write config/common.yamlgateway.svc config generate all -d config/ # write every exampleFor production, start from bootstrap instead of quickstart (port 443,
zerotrust: true).
How config is loaded
Section titled “How config is loaded”The gateway reads two kinds of configuration, and they are kept separate:
| Kind | What it is | Where it comes from |
|---|---|---|
| Boot / service config | Listeners, log level, TLS posture, datacenter/cluster, and the pointer to the routing config | The -f bootstrap file, plus the chassis config: block (local folder or remote config.svc) |
| Routing config | domainmaps, backends, endpoints, routes, staticpaths — the gateway’s own data model | Inline in the bootstrap, or a local directory (config.router.dir), or served by config.svc (config.url) |
The config: block selects how the service config is sourced (this is the
fleet-wide chassis convention — same keys as every other kis.ai service):
config: url: http://config:5011 # REMOTE: fetch service+cluster config from config.svc apikey: <api-key> # auth for the remote config service (optional) cache: /var/lib/gateway/cfg.db # bbolt warm-start cache (optional) # path: ./service-config # LOCAL service-config folder (alternative to url) router: dir: ./config # LOCAL gateway ROUTING config directory (see below)config.url→ remote mode.config.svcserves the service config and the routing model (the same top-leveldomainmaps/routes/backends/… keys), streamed over SSE and hot-reloaded.config.router.dir→ local mode. A directory of routing YAML files in the gateway’s multi-file format, loaded and hot-reloaded by the gateway itself.- Neither, with routing inline in the bootstrap → “bootstrap-as-config”; the
-ffile is the whole config. (This is what most of the embedded single-file examples do.)
Note —
config.router.dirvsconfig.path.config.pathis the chassis’s service-config folder (lib-config flat-pathformat). The gateway’s routing directory is a different format and uses its own key,config.router.dir. Don’t put routing files underconfig.path— the chassis will try to parse them as service config and fail.
There is no --config_dir flag — the routing directory is the
config.router.dir key in the bootstrap file.
Boot keys (top level of the bootstrap)
Section titled “Boot keys (top level of the bootstrap)”name: API Gateway # service name (optional)log: stdout # stdout | host:port (fluentd)loglevel: info # info | debug | warn | error
host: "" # bind address; "" = all interfacesport: 8443 # public entrypoint (443 in prod)zerotrust: false # true = TLS termination + mTLS on the internal entrypoint
internal: port: 8444 # localhost-only entrypoint (required) trustedips: [127.0.0.1] # trusted proxies (applied when zerotrust: true)
timeouts: # gateway-wide listener timeouts (per-entrypoint overridable) read: 10s write: 10s idle: 180sThe mental model
Section titled “The mental model”A request flows through five named concepts:
Host header ──► domainmap ──► (Customer, Product, Environment, Tenant) │ route ── binds a domainmap to an entrypoint and a list of ──┐ │ │ endpoints ──► backend (a pool of URLs) │ │ │ staticpaths ──► a folder served by a spawned fileserver │ entrypoint (a listener: websecure / websecureinternal / custom)domainmapsmap one or moreHostvalues to a CPET tuple. Thecustomer-domain-mappermiddleware resolves the incoming host and injectsX-Customer,X-Product,X-Env,X-Tenantheaders so backends know which tenant a request belongs to.backendsare named pools of upstream URLs.endpointsmatch a path prefix and forward to a backend, applying an ordered middleware chain.routestie adomainmap+ anentrypointto a set ofendpointsand/orstaticpaths.entrypointsare the listeners. With no explicitentrypoints:block the gateway synthesises two from the boot keys:websecure(:{port}) andwebsecureinternal(:{internal.port}).
How the routing domain is resolved (running behind a proxy)
Section titled “How the routing domain is resolved (running behind a proxy)”The routing domain comes from the request’s own Host/authority — a port-less
Host on the standard ports (:443/:80) resolves correctly. An incoming
X-Forwarded-Host is trusted only from a peer listed in that entrypoint’s
trustedips; from any other source it is ignored (and all X-Forwarded-* are
stripped), so a client can’t choose the routing domain — or force an SNI/route
mismatch — by spoofing the header.
A direct-to-internet gateway needs nothing extra: clients connect with the real
Host, and that’s what routing uses. But if you run the gateway behind a
reverse proxy or load balancer that terminates the client connection and
forwards the real host in X-Forwarded-Host (e.g. kong, which sends
Host: localhost), you must list that proxy’s IP in the entrypoint’s
trustedips — otherwise the gateway routes by the proxy’s Host (e.g.
localhost) and nothing matches:
entrypoints: - name: websecure address: ":443" protocol: http trustedips: [10.0.1.100] # the upstream proxy/LB; its X-Forwarded-Host is honoredBackend routing
Section titled “Backend routing”One app on one domain
Section titled “One app on one domain”# gateway.svc config generate single-appbackends: - backend: name: svc urls: - http://127.0.0.1:3000
endpoints: - name: to-svc path: { prefix: / } backend: svc middlewares: - customer-domain-mapper: { default: true }
routes: - name: main domainmap: main entrypoint: websecure endpoints: [to-svc]
domainmaps: - name: main domains: [myapp.kisai.dev] customer: kisai product: myapp environment: prod tenant: mainEvery request to myapp.kisai.dev is forwarded to http://127.0.0.1:3000, and
the backend receives X-Customer: kisai, X-Product: myapp, X-Env: prod,
X-Tenant: main.
Several services behind one domain (path prefixes)
Section titled “Several services behind one domain (path prefixes)”Route different path prefixes to different backends, stripping the prefix before forwarding:
backends: - backend: { name: config, urls: [https://127.0.0.1:7123] } - backend: { name: janus, urls: [https://127.0.0.1:7101] } - backend: { name: seshat, urls: [https://127.0.0.1:7102] }
endpoints: - name: to-config path: { prefix: /config/ } backend: config middlewares: - replaceRegexPrefix: { regex: "^/config/(.*)", replacement: "/${1}" } - customer-domain-mapper: { default: true } - name: to-janus path: { prefix: /account/ } backend: janus middlewares: - replaceRegexPrefix: { regex: "^/account/(.*)", replacement: "/${1}" } - customer-domain-mapper: { default: true } - name: to-seshat path: { prefix: /data/ } backend: seshat middlewares: - replaceRegexPrefix: { regex: "^/data/(.*)", replacement: "/${1}" } - customer-domain-mapper: { default: true }
routes: - name: main domainmap: main entrypoint: websecure endpoints: [to-config, to-janus, to-seshat]replaceRegexPrefix rewrites /config/foo → /foo before the request reaches
the config backend. (The slice-2 rewrite: block, below, is the newer way to
express the same thing.)
Multi-tenant (one product, many tenants)
Section titled “Multi-tenant (one product, many tenants)”Give each tenant its own subdomain and domainmap; share the backends and
endpoints:
# gateway.svc config generate multi-tenantroutes: - name: tenant-a domainmap: tenant-a entrypoint: websecure endpoints: [to-config, to-janus, to-seshat] - name: tenant-b domainmap: tenant-b entrypoint: websecure endpoints: [to-config, to-janus, to-seshat]
domainmaps: - name: tenant-a domains: [tenant-a.forge.kisai.dev] customer: kisai product: forge environment: prod tenant: tenant-a - name: tenant-b domains: [tenant-b.forge.kisai.dev] customer: kisai product: forge environment: prod tenant: tenant-bThe same backend pool serves both tenants; the customer-domain-mapper stamps
the right X-Tenant per request so the backend can isolate data.
Backend options
Section titled “Backend options”backends: - backend: name: api urls: - http://10.0.0.1:8080 - http://10.0.0.2:8080 # >1 URL ⇒ round-robin by default https: true # upgrade to https when zerotrust:true (default true) profile: sticky-session # opt into a named load-balance profile (see below)Static sites & SPAs
Section titled “Static sites & SPAs”The gateway can serve a folder directly: it spawns its own small file server on
the URL you give and proxies the route to it. Two types:
type | Behaviour |
|---|---|
static | Serves files as-is. Missing file → notfoundpath (with 404) if set, else plain 404. |
spa | Same, but a missing path falls back to notfoundpath (your index.html) so the SPA’s client-side router can take over. This is what makes deep links work. |
SPA (React / Vue / Svelte) over HTTPS
Section titled “SPA (React / Vue / Svelte) over HTTPS”# gateway.svc config generate spastaticpaths: - name: build type: spa folderpath: /var/www/app # your built output (Vite ./dist, CRA ./build, …) notfoundpath: index.html # SPA fallback — serve index.html for unknown paths indexpath: index.html path: { prefix: / } services: - url: http://localhost:7151 # gateway spawns its fileserver on this local port middlewares: - customer-domain-mapper: { default: true }
routes: - name: main domainmap: main entrypoint: websecure staticpaths: [build] # note: staticpaths, not endpointsBuild your SPA with the correct base path (
base: '/'for Vite,"homepage": "."for CRA), or deep links and asset URLs will break under a sub-path.
Self-contained SPA over HTTP (local dev)
Section titled “Self-contained SPA over HTTP (local dev)”spa-http pairs with the quickstart bootstrap — HTTP-only, catch-all on /,
matches localhost/127.0.0.1, no common.yaml needed:
# gateway.svc config generate spa-httpstaticpaths: - name: site type: spa folderpath: ./public notfoundpath: index.html indexpath: index.html path: { prefix: / } services: - url: http://localhost:7150 middlewares: - customer-domain-mapper: { default: true }
routes: - name: local domainmap: local entrypoint: websecure # plain HTTP when zerotrust:false staticpaths: [site]
domainmaps: - name: local domains: [localhost, 127.0.0.1] customer: kisai product: spahttp environment: dev tenant: localPlain static site (Hugo / MkDocs / Docusaurus)
Section titled “Plain static site (Hugo / MkDocs / Docusaurus)”Use type: static and point notfoundpath at your 404.html —
gateway.svc config generate static-site.
The spawned fileserver binds to the
services[].urlport on localhost. The gateway proxies the route to it; the route’s middleware chain (auth, mapper, metrics, …) runs in the gateway, in front of the fileserver.
URL rewrites, redirects & header transforms
Section titled “URL rewrites, redirects & header transforms”These are endpoint-level blocks (rewrite:, redirect:, headers:,
security_headers:) — gateway.svc config generate url-rewrite.
Rewrite (path + host)
Section titled “Rewrite (path + host)”endpoints: # strip a prefix: /api/v1/users → /users - name: strip path: { prefix: /api/v1 } backend: api-svc rewrite: strip_prefix: /api/v1
# strip then add: /api/v1/users → /internal/users - name: combo path: { prefix: /api/v1 } backend: internal-svc rewrite: strip_prefix: /api/v1 add_prefix: /internal
# regex with capture groups: /v1/users/42 → /v2/u/42 - name: regex path: { prefix: /v1/users } backend: users-svc rewrite: path: match: ^/v1/users/(\d+)$ replace: /v2/u/$1
# rewrite the Host header the backend sees - name: host path: { prefix: /vendor } backend: vendor-svc rewrite: strip_prefix: /vendor host: vendor.partner.comrewrite.path (static or {match, replace}) cannot be combined with
strip_prefix/add_prefix on the same endpoint.
Redirect (no backend — terminates the chain)
Section titled “Redirect (no backend — terminates the chain)”endpoints: - name: legacy path: { prefix: /old } redirect: from: ^/old/(.*)$ # default ^.*$ to: /new/$1 permanent: true # 308 (true) vs 302 (false)redirect: is mutually exclusive with backend: and with rewrite:.
Header set/remove
Section titled “Header set/remove”endpoints: - name: headers path: { prefix: /augmented } backend: app-svc headers: request: set: X-Api-Version: "v1" X-Source: gateway remove: [Cookie, Authorization] response: set: Cache-Control: "no-store" remove: [Server, X-Powered-By]Security-header presets
Section titled “Security-header presets”endpoints: - name: secure path: { prefix: /app } backend: app-svc security_headers: strict # strict | relaxed | nonestrict— HSTS (2y, preload),frameDeny, nosniff, XSS filter,Referrer-Policy, CSPdefault-src 'self'.relaxed— HSTS (1y),X-Frame-Options: SAMEORIGIN, nosniff, XSS filter,Referrer-Policy(no CSP).none— emit nothing.
Expression engine
Section titled “Expression engine”Header values accept ${...} template substitution, resolved per request from a
curated namespace — gateway.svc config generate headers-expr. The values are
populated by upstream middlewares (customer-domain-mapper, kisaiauth), so put
the mapper before the header block.
endpoints: - name: tenant-fwd path: { prefix: /api } backend: api-svc middlewares: - customer-domain-mapper: { default: true } headers: request: set: X-Tenant-Id: "${tenant.customer}" X-Tenant-Slug: "${tenant.customer}-${tenant.env}" X-User: "${auth.subject}" remove: [Authorization] response: set: X-Served-By: "gateway.${gateway.cluster}" X-Backend-Time: "${upstream.duration_ms}ms"Reserved namespaces:
| Namespace | Fields |
|---|---|
request.* | method, scheme, host, path, path_segments[N], query.<n>, headers.<n>, cookies.<n>, remote_addr, body_size |
tenant.* | customer, product, env, name |
auth.* | subject, user_id, realm |
response.* | status, headers.<n> (response-side only) |
upstream.* | duration_ms (response-side only) |
gateway.* | datacenter, cluster, version |
A missing variable renders as the empty string; for a header value, empty means “strip the header”. CRLF in rendered values is stripped (request-smuggling defence).
Load balancing
Section titled “Load balancing”Declare named profiles once; backends opt in via profile:. Backends with more
than one URL round-robin by default — gateway.svc config generate load-balancing.
loadbalance: default: # applied to any backend without an explicit profile algo: wrr healthcheck: { path: /health, interval: 30s, timeout: 5s }
profiles: sticky-session: # cookie-based session affinity algo: sticky sticky: { cookie: srv-id, httpOnly: true, secure: true }
weighted-80-20: # static traffic split across backends algo: weighted members: - { backend: prod, weight: 4 } - { backend: canary, weight: 1 }
canary-10pct: # shadow 10% to a canary (fire-and-forget) algo: mirror primary: prod mirrors: [{ backend: canary, percent: 10 }]
region-failover: # primary + hot standby; health-check drives the flip algo: failover primary: us-east fallback: us-west healthcheck: { path: /health/deep, interval: 10s, timeout: 3s }
adaptive-cpu-mem: # gateway polls each member and rewrites weights algo: adaptive members: [{ backend: pool-a }, { backend: pool-b }, { backend: pool-c }] reconcile: { interval: 10s, metrics_path: /health/load, timeout: 3s } formula: cpu_then_memory # cpu_then_memory | inflight_count | available_capacity min_weight: 1 max_weight: 100| Algorithm | Use |
|---|---|
wrr | Weighted round-robin (the default). |
sticky | WRR + cookie session affinity. |
weighted | Static split across multiple backends (canary by share). |
mirror | Primary serves; mirrors get a sampled copy, responses discarded. |
failover | Primary with a health-gated fallback. |
adaptive | Gateway polls each member’s metrics_path every interval and recomputes weights from formula. |
For adaptive, each member must expose a JSON endpoint at metrics_path
(default /health/load) with CPU/memory/inflight fields. reconcile.interval
has a 5s floor. The controller keeps last-known weights after transient probe
failures.
Timeouts & streaming
Section titled “Timeouts & streaming”Two independent timeout layers can cut a request, and both live under timeouts:
in the bootstrap .gateway.yaml. timeouts is a boot-plane key, so it is
stripped from routing-dir files — putting it in common.yaml/backend.yaml does
nothing (you’ll get a warning in the log).
Listener timeouts (per connection)
Section titled “Listener timeouts (per connection)”# .gateway.yaml — these are the defaultstimeouts: read: 10s # max time to read the request (headers + body) write: 0s # max time to write the WHOLE response; 0 = no deadline (default) idle: 180s # max keep-alive idle between requests on a connectionThese become Traefik’s respondingTimeouts and apply to every route on the
entrypoint.
writedefaults to0s(no deadline), so SSE / WebSockets / streaming work out of the box. If you set a positivewritefor slow-write protection, know that it’s a hard deadline on the entire response, not an idle timer — it severs any stream at exactlywriteseconds no matter how much data is flowing (a source emitting an event every 3s is still cut at the 10s mark; heartbeats do not reset it, and Traefik does not exempt streaming). So either leave it0s, or set it and route streaming endpoints through a dedicated entrypoint that keepswrite: 0s(below).readandidlestill bound request reads and idle keep-alives regardless.
To keep the cap on normal routes and lift it only for streams, declare an explicit
entrypoints: block (gateway.svc config generate multi-port) and override
timeouts on the listener the streaming routes use:
entrypoints: - name: stream address: ":8446" protocol: http timeouts: { write: 0s } # only this listener drops the write deadlinePer-service request-context timeouts
Section titled “Per-service request-context timeouts”A second layer sets the request-handling context deadline per backend, with
per-path regex overrides — also under timeouts: in the bootstrap (or delivered
via the remote cluster config from config.svc):
timeouts: services: - name: app.orders # the service (backend) name defaultcontexttimeout: 30s # default context deadline for this service pathconfig: - { expr: "^/orders/export/.*", duration: 120s } # slower path, longer deadline - { expr: "^/orders/stream/.*", duration: 0s } # 0s = no deadline (streaming)pathconfig entries are matched by expr (Go regexp) against the request path,
first match wins. duration: 0s (or enabled: false) removes the deadline for
that path — use it for streaming endpoints.
Reach for the per-service layer only when you need different deadlines per
backend or path. For a plain “my SSE dies after 10s”, the listener write: 0s is
the whole fix.
TLS & ACME
Section titled “TLS & ACME”External TLS and internal mTLS are two independent planes — don’t conflate them:
- External (public) TLS — client ↔ gateway on a public entrypoint. A route
opts in by declaring
certresolver:(a Let’s Encrypt / ACME cert) and/ortlsoptions:/tlspassthrough:. This is independent ofzerotrust— a route can serve public HTTPS with an LE cert whether or not the internal mesh is on. Works on bothendpoints:andstaticpaths:routes. - Internal mTLS —
zerotrust: trueturns on mutual TLS on thewebsecureinternalentrypoint and the gateway→backend leg (viakisaitlsinternal/ thekisai_issuerCA). That’s service-mesh identity, not public serving; you do not need it to get a public HTTPS cert.
Hardened TLS profiles live in tlsoptions (usually in a shared common.yaml —
gateway.svc config generate common):
tlsoptions: - default: minVersion: VersionTLS12 sniStrict: true cipherSuites: [TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, …] - kisaitlsinternal: minVersion: VersionTLS12 clientAuth: caFiles: [/etc/gateway/certs/kisai_issuer.crt] clientAuthType: RequireAndVerifyClientCertdefault and kisaitlsinternal are protected names — you may add new ones but
not redefine these. A route selects one with tlsoptions: <name> (optional; a
route with only certresolver: uses the default options automatically).
A public Let’s Encrypt cert, step by step
Section titled “A public Let’s Encrypt cert, step by step”-
Declare the resolver + the
:80challenge entrypoint once in thetraefik:passthrough (incommon.yaml):traefik:static:certificatesResolvers:letsencrypt:acme:storage: /var/lib/gateway/acme.json # writable + persisted across restartshttpChallenge: { entryPoint: web }entryPoints:web:address: ":80"http:redirections:entryPoint: { to: websecure, scheme: https, permanent: true } -
Point the route at it — no
zerotrustrequired:routes:- name: maindomainmap: mainentrypoint: websecurecertresolver: letsencryptstaticpaths: [build] # or endpoints: [...]domainmaps:- { name: main, domains: [app.kisai.dev], customer: kisai, product: app, environment: prod, tenant: main } -
Issuance requires (all of them, or the challenge fails and the gateway serves a self-signed cert):
- the route’s
domains:publicly resolve to this host; - the challenge port is reachable from the internet —
:80for HTTP-01 (theweb→websecureredirect does not interfere: Traefik serves/.well-known/acme-challenge/before redirecting); - the
storage:(acme.json) path is writable and persisted.
- the route’s
- HTTP-01 (
gateway.svc config generate acme-http01) — simplest; needs public DNS A records and inbound:80. - DNS-01 (
gateway.svc config generate acme-dns01) — required for wildcards or behind a CDN. DNS-API credentials go in env vars, never YAML.
Troubleshooting — the browser shows a self-signed / “not trusted” cert. That means no LE cert was obtained and the gateway is serving its default-store cert. Check, in order:
- the route actually declares
certresolver:and the running binary emits it —grep certResolverin the debug log should showtls.certResolveron your router (if absent, rebuild/redeploy the binary);- an ACME attempt is logged for your domain (
grep -i 'acme\|obtain') — if there’s none, the router carries no resolver;- the challenge port (
:80) is reachable from outside (curl http://<domain>/.well-known/acme-challenge/probefrom another host — connection refused/timeout ⇒ firewall);acme.jsonis writable.
Traefik passthrough
Section titled “Traefik passthrough”For Traefik features the gateway doesn’t surface as first-class fields (ACME,
dashboard, custom middlewares, http3, …), drop raw Traefik config under
traefik.static / traefik.dynamic — by convention, only in common.yaml.
traefik: static: certificatesResolvers: letsencrypt: acme: storage: /var/lib/gateway/acme.json httpChallenge: { entryPoint: web } dynamic: http: middlewares: strict-rate-limit: { rateLimit: { average: 100, burst: 200 } } gzip-all: { compress: {} }Reference them from a route:
routes: - name: main domainmap: main entrypoint: websecure certresolver: letsencrypt # → traefik.static.certificatesResolvers extramiddlewares: [strict-rate-limit, gzip-all] # → traefik.dynamic.http.middlewares endpoints: [to-svc]Protected paths (the gateway owns these; writes are rejected at boot):
traefik.static:entryPoints.websecure,entryPoints.websecureinternal,providers.kisai,global,log,accessLog.filePath,experimental.plugins/localPlugins,serversTransport.insecureSkipVerify/rootCAs,api.insecure.traefik.dynamic:http.routers,tcp.routers,http.services,tcp.services,tls.options.default,tls.options.kisaitlsinternal.
The Traefik dashboard can be exposed behind kisai-auth —
gateway.svc config generate dashboard (never set api.insecure: true).
Metrics & telemetry
Section titled “Metrics & telemetry”kisaimetrics: enabled: true ipaddr: 127.0.0.1 port: 7011 # default 8088Serves Prometheus metrics at /metrics and Go profiling at /debug/pprof/* on
its own listener.
tracked: cookiename: kisaitracker expiry: 2160h # 90 days anonymous: enabled: true publishhostport: localhost:8000 publishuri: /account/user/anonuser excludedpaths: [/health, "/\\.well-known/.*"] accesslogs: enabled: true publishhostport: localhost:8000 publishuri: /telemetry/accessThe tracker middleware assigns an anonymous cookie and publishes new-tracker and
access-log events asynchronously (fire-and-forget); it is disabled on internal
(websecureinternal) traffic. Non-fatal if the publish endpoint is unset.
Multi-file config directory
Section titled “Multi-file config directory”When config.router.dir points at a folder, every *.yaml in it is merged:
- List keys (
routes,domainmaps,backends,endpoints,staticpaths,tlsoptions) concatenate across files. - Everything else is last-wins.
Each file declares a namespace:; names in that file (and bare same-file
references) get prefixed with <namespace>., so two files can both define a
main route without colliding. Qualified references (common.to-config)
cross files and are left as-is. tlsoptions are never namespaced — define them
once in common.yaml and reference by plain name.
namespace: forgeroutes: - name: tenant-a # → forge.tenant-a domainmap: tenant-a # → forge.tenant-a (same-file, auto-prefixed) entrypoint: websecure endpoints: - common.to-config # qualified → left as-is - to-arkabot # bare → forge.to-arkabot tlsoptions: kisaitlsinternalThe loader fails fast at boot on: duplicate names in a list; a list key with
a non-list value; unresolved references (route→domainmap/endpoint/staticpath,
endpoint→backend); an unknown certresolver; or a write to a protected
passthrough path. A misconfigured deploy never silently serves half its routes.
See config.example/ for a complete multi-file layout.
CLI reference
Section titled “CLI reference”gateway.svc [flags] # run the gatewaygateway.svc config list # list embedded config examplesgateway.svc config generate <name> # print an example (or -d <dir> to write it)gateway.svc config generate all -d <dir> # write every examplegateway.svc version # print the build versionFlags (all also settable in the bootstrap file where applicable):
| Flag | Meaning |
|---|---|
-f, --config <file> | Bootstrap file (default .gateway.yaml) |
-l, --log | stdout or host:port |
-L, --loglevel | info|debug|warn|error |
-p, --port | Override the public port |
--datacenter, --cluster | Deployment tags (default default) |
--config_url, --config_apikey | Remote config service (folds into the config: block) |
--servicediscovery_url, --servicediscovery_apikey | Service-discovery registration |
--svccert, --svckey, --rootca | TLS material for zero-trust |
--zerotrust | true|false |
--jwt_public_key | JWT verification key path |
--sid | Service instance id |
Operations
Section titled “Operations”See Operations for bootstrap, hot reload, observability, and failure modes.
Feature status
Section titled “Feature status”Stable: backend routing, static & SPA serving, path rewrites / redirects /
header transforms, the ${...} expression engine, security-header presets, load
balancing (wrr/sticky/weighted/mirror/failover/adaptive), metrics,
anonymous-tracker & access-log telemetry, ACME (HTTP-01 / DNS-01), Traefik
passthrough, multi-file config + hot reload, HTTP entrypoints.
Partial / experimental: TCP/UDP (L4) entrypoints — L4 traffic bypasses the HTTP middleware chain, so there is no per-request tenant attribution (declare one tenant per L4 port); UDP service wiring is incomplete.
Planned (not yet implemented): scheduled/time-ramp rollouts, SLO-aware
adaptive weights (error-rate / latency formulas), traffic variants
(header/cookie/bucket cohorts), and Tier-2 compute: expressions.