A route path is one of the smallest things in a Kong configuration, and one of the easiest to get wrong. A missing slash, an empty string, a regex without its ~ prefix. In my experience these slip through code review because they look harmless, and the first thing that notices is the gateway at deploy time, or worse, your users after it.

This post shows a small deck file lint ruleset that catches those mistakes in CI, before any gateway is involved. Every case was run against a real Kong.

Versions used: Kong Gateway 3.16.0.1 · decK 1.62.1 · source references from Kong 3.9.4 (open source)

Why this matters

Most teams that manage Kong declaratively already run deck file validate in their pipeline. It’s fast, it needs no gateway, and it feels like a safety net. For route paths, it mostly isn’t one.

I wrote 13 small decK files, one per kind of path mistake, and ran each through three checks: deck file validate (offline), the lint ruleset from this post, and Kong itself (kong config parse inside a Kong 3.16 container, which runs Kong’s own schema validation). The result:

Casedeck file validateLint rulesetKong 3.16
paths: ["/api/v1/orders"]passcleanaccepted
paths: ["~/orders/\d+$"] (regex)passcleanaccepted
No paths, only hostspasserror (policy)accepted
No paths, hosts, methods or headerspasserrorrejected
paths: []passerrorrejected
paths: [""]passerrorrejected
paths: [" "]passerrorrejected
paths: "/api/v1/orders" (string, not array)failerrorrejected
paths: ["api/v1/orders"] (no leading slash)passerrorrejected
paths: ["/api/v1 orders"] (space)passerror (policy)accepted
paths: ["/api//orders"] (empty segment)passerrorrejected
paths: ["/"] (catch-all)passwarning (policy)accepted
paths: ["/orders/\d+$"] (regex without ~)passwarning (policy)accepted

Two things stand out. Offline validation caught one case out of thirteen, the wrong data type. Six configs that Kong rejects passed it, so the pipeline goes green and the deploy fails. And four configs that Kong happily accepts are still worth stopping, because they don’t do what the author meant.

deck file validate isn’t broken; its documentation says it validates against static schemas and “may catch fewer errors” than online validation (decK docs). Path rules live in Kong’s own schema, which the offline check doesn’t fully apply.

What Kong actually requires of a route path

Before writing rules, it helps to know which ones mirror Kong and which ones are your choice.

Kong enforces these. In Kong’s schema, a route path must start with / for a plain prefix or ~/ for a regex, and it must not contain empty segments (//). The error messages are the ones you’ll see at deploy time: should start with: / (fixed path) or ~/ (regex path) and must not have empty segments (typedefs.lua).

Paths are optional. A route doesn’t need paths at all. For HTTP it needs at least one of methods, hosts, headers or paths, and for HTTPS snis also counts (routes_subschemas.lua). That’s why a route with an empty paths array and nothing else fails with must set one of 'methods', 'hosts', 'headers', 'paths', 'snis'.

Regex needs a ~. Since Kong 3.0 there is no guessing: a path is a regex only if it starts with ~, and everything else is plain text (breaking changes, 3.0). So /orders/\d+$ is accepted, but Kong treats it as a plain-text prefix, so it only matches requests that literally start with those characters.

Spaces are allowed. I expected Kong to reject /api/v1 orders. It didn’t. That’s why the whitespace rule below is marked as policy.

The ruleset

deck file lint runs rules written in the vacuum format: a given JSONPath selects values, a function checks them, and a severity decides whether the run fails (decK docs). Save this as route-paths.ruleset.yaml:

# route-paths.ruleset.yaml
# The first two rules mirror checks Kong itself enforces, so CI fails before Kong is involved.
# The rules marked "Policy" are choices: Kong accepts these, we do not.
rules:
  route-path-must-start-with-slash:
    description: "A route path must start with / (prefix) or ~/ (regex)"
    given: $.services[*].routes[*].paths[*]
    severity: error
    then:
      function: pattern
      functionOptions:
        match: "^~?/"

  route-path-no-empty-segments:
    description: "A route path must not contain empty segments (//)"
    given: $.services[*].routes[*].paths[*]
    severity: error
    then:
      function: pattern
      functionOptions:
        notMatch: "//"

  route-path-no-whitespace:
    description: "Policy: no spaces or other whitespace in a route path"
    given: $.services[*].routes[*].paths[*]
    severity: error
    then:
      function: pattern
      functionOptions:
        notMatch: "\\s"

  route-must-have-paths:
    description: "Policy: every route declares paths (Kong also allows hosts, methods or headers alone)"
    given: $.services[*].routes[*]
    severity: error
    then:
      field: paths
      function: truthy

  route-paths-must-be-non-empty-array:
    description: "Policy: every route declares paths as a non-empty array"
    given: $.services[*].routes[*]
    severity: error
    then:
      field: paths
      function: schema
      functionOptions:
        schema:
          type: array
          minItems: 1
          items:
            type: string
            minLength: 1

  route-no-catch-all:
    description: "Policy: no catch-all / route (it swallows unmatched traffic)"
    given: $.services[*].routes[*].paths[*]
    severity: warn
    then:
      function: pattern
      functionOptions:
        notMatch: "^/$"

  route-regex-needs-tilde:
    description: "Policy: a path that looks like a regex must start with ~ (Kong 3.x treats it as plain text otherwise)"
    given: $.services[*].routes[*].paths[*]
    severity: warn
    then:
      function: pattern
      functionOptions:
        notMatch: "^/.*(\\\\[dws]|\\[.+\\]|\\.\\*|\\$$)"

A few choices worth explaining:

  • Two rules for “has paths”. A schema check on a field doesn’t fire when the field is missing entirely, so route-must-have-paths uses truthy to catch the missing case and the schema rule catches the empty and wrong-type cases. I found this out the hard way: with only the schema rule, routes without a paths key passed.
  • Warnings for the catch-all and the regex heuristic. A / route is sometimes intentional (a default backend), and the regex check is a heuristic. Warnings show up in the output without failing the build.
  • Drop the policy rules you don’t want. If your team uses host-only routes, delete route-must-have-paths and route-paths-must-be-non-empty-array. The first two rules are the ones that mirror Kong.

Run it

Lint a decK file with the ruleset:

deck file lint -s kong.yaml route-paths.ruleset.yaml

For a file with a path missing its leading slash, the output is:

Linting Violations: 1
Failures: 1

[error][7:17] A route path must start with / (prefix) or ~/ (regex): `api/v1/orders` does not match the expression `^~?/`

The [7:17] is the line and column in your file, which makes the finding easy to fix in a pull request. The command exits non-zero when there are errors, so it fails a CI step on its own. Add --format json if you want to post the results somewhere.

Wire it into CI

In GitHub Actions, the Kong/setup-deck action installs a pinned decK version. Run lint next to your existing validate step so you keep both checks:

# .github/workflows/kong-config.yaml
name: kong-config
on:
  pull_request:
    paths: ["kong/**"]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Kong/setup-deck@v1
        with:
          deck-version: "1.62.1"
      - name: Validate (offline)
        run: deck file validate kong/kong.yaml
      - name: Lint route paths
        run: deck file lint -s kong/kong.yaml kong/route-paths.ruleset.yaml

Keep the ruleset in the same repository as the configuration, so a rule change goes through the same review as a route change.

In production

  • Lint is not a substitute for online validation. Lint knows only the rules you wrote. Before you apply to a real environment, deck gateway validate checks the file against a live Admin API without changing anything, and catches what neither offline check knows about (decK docs). A good pipeline runs lint on every pull request and online validation against a non-production gateway before the merge.
  • Cover every place routes live. The given paths above only look at routes nested under services. If your files also declare top-level routes:, duplicate the path rules with $.routes[*].paths[*].
  • Version the ruleset like code. When you upgrade decK or Kong, re-run your known-bad examples against the new versions. That’s how I found the space case: my original rule was based on an assumption, not a test.
  • The expressions router is different. If your routes use Kong’s expressions router, matching lives in an expression field, not paths, and these rules won’t see it.

Trade-offs and when not to

  • Rules drift from Kong. The two “Kong enforces this” rules copy Kong’s schema. If Kong changes, your rules won’t follow on their own, which is why the known-bad examples are worth keeping.
  • Policy rules need an owner. A team that legitimately routes on hosts only will find route-must-have-paths annoying. Agree the policy first, then encode it; don’t let the linter become the place where design decisions are made by accident.
  • For small, rarely changed configs, online validation against a test gateway may be all you need. Linting pays off when many people change routes, or when routes are generated.

Takeaways

  • deck file validate caught 1 of 13 broken or risky route paths in the examples above. Don’t treat it as your only check.
  • Kong requires paths to start with / or ~/ and to have no empty segments. Everything else in this ruleset is policy, so label it that way.
  • A route without paths is valid Kong. Whether it’s valid for your team is a decision, not a fact.
  • Keep known-bad example files next to the ruleset, and run them again on every decK or Kong upgrade.

References