Skip to content

Config schema

Barbacana is configured with a single YAML file. You never write Caddy config — the YAML is compiled internally.

Top-level structure

version: v1alpha1              # schema version, required
host: "api.example.com"        # Mode 1: single host, auto-TLS
port: 8080                     # Mode 3: behind LB (mutually exclusive with host)
data_dir: "/data/barbacana"    # optional, default "/data/barbacana"
metrics_port: 9090             # optional, default 0 (disabled)
health_port: 8081              # optional, default 0 (disabled)

audit_log:                     # optional; controls the wire schema of stdout audit entries
  format: ocsf                 # "ocsf" (default) or "ecs"

tracing:                       # optional; off entirely when block absent or enabled: false
  enabled: false

global:
  # defaults applied to every route unless the route overrides

routes:
  - # one block per route

Required vs optional

Field Required Default Validation
version yes must equal v1alpha1
host no valid hostname; mutually exclusive with port and with any route-level match.hosts
port no 8080 (only when no host and no route has match.hosts) integer 1–65535; mutually exclusive with host and with any route-level match.hosts
data_dir no /data/barbacana directory must be writable; stores TLS certificates and ACME state — mount as a persistent volume in containers
metrics_port no 0 (disabled) integer 0–65535; 0 disables the listener; when non-zero, must differ from port and health_port
health_port no 0 (disabled) integer 0–65535; 0 disables the listener; when non-zero, must differ from port and metrics_port
audit_log no format: ocsf see "Audit log" section below
tracing no disabled see "Tracing" section below
global no see below
routes yes at least one route

Opt-in observability ports

metrics_port and health_port default to 0, which means the corresponding listener is never started: no port is opened, no endpoint is served. Audit logs go to stdout regardless and are always on.

This opt-in is deliberate. An open port is attack surface — /metrics exposes route IDs and protection names; /healthz advertises that a WAF is running. A hobbyist who forwards :443 on their home router should not unknowingly expose two additional operational-data ports. Production deployments set both ports explicitly.

When a port is 0, the server emits an info log at startup so operators know why the endpoint is missing:

health endpoint disabled — set health_port to enable /healthz and /readyz
metrics endpoint disabled — set metrics_port to enable /metrics

Deployment modes

Exactly one of three mutually exclusive modes is selected by the combination of host, port, and route-level match.hosts. See Hostnames & HTTPS for the full picture.

Mode 1 — Single host, auto-TLS. Set top-level host. Barbacana serves HTTPS on :443, redirects HTTP on :80, and provisions a Let's Encrypt certificate automatically. Routes must not set match.hosts, and port must not be set.

version: v1alpha1
host: api.example.com
routes:
  - upstream: http://api:8000

Mode 2 — Multi-host, auto-TLS. Omit host. Every route supplies match.hosts. Barbacana provisions one certificate per hostname. If any route has match.hosts, every route must have match.hosts. port must not be set.

version: v1alpha1
routes:
  - match:
      hosts: [api.example.com]
    upstream: http://api:8000
  - match:
      hosts: [admin.example.com]
    upstream: http://admin:8000

Mode 3 — Behind a load balancer, plain HTTP. Set port (or leave both host and port unset to default port to 8080). Barbacana serves plain HTTP on the configured port; there is no TLS and no certificate provisioning.

version: v1alpha1
port: 8080
routes:
  - upstream: http://api:8000

Validation errors (modes)

Every mode constraint is a hard error, not a warning. Messages name the specific conflicting fields and, where applicable, the offending route:

waf.yaml:2: "host" and "port" are mutually exclusive — use "host" for auto-TLS or "port" for plain HTTP behind a load balancer

waf.yaml:3: "host" and "match.hosts" on route "api" are mutually exclusive — use top-level "host" for a single hostname or "match.hosts" per route for multiple hostnames

waf.yaml:5: "port" and "match.hosts" on route "api" are mutually exclusive — "match.hosts" requires auto-TLS; remove "port" or remove "match.hosts"

waf.yaml:14: route "uploads" has no match.hosts but route "api" does — add match.hosts to route "uploads", repeating the host for multiple routes is fine, or add "host" at the top level if all routes share the same host

Global section

Global defines defaults applied to every route unless the route overrides.

global:
  mode: blocking                     # "blocking" (default) or "detect_only"
  disable: []                        # canonical protection names disabled everywhere
  enable: []                         # opt-in protections to turn on everywhere

  # ── What the route accepts ────────────────────────────────
  accept:
    methods: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
    content_types: []                # empty = all; values are MIME types; gates which parsers run
    max_body_size: 10MB
    max_url_length: 8192
    max_header_size: 16KB
    max_header_count: 100
    require_host_header: true

  # ── How the WAF inspects ──────────────────────────────────
  inspection:
    evaluation_timeout: 50ms         # context deadline for rule evaluation
    max_inspect_size: 128KB          # bytes of non-file body evaluated by rules
    max_memory_buffer: 128KB         # spool to disk above this
    decompression_ratio_limit: 100   # reject if uncompressed/compressed > ratio
    json_depth: 20                   # max nesting depth for JSON bodies
    json_keys: 1000                  # max key count in JSON objects
    xml_depth: 20                    # max nesting depth for XML bodies (only if XML accepted)
    xml_entities: 100                # max entity expansions (only if XML accepted)

  # ── File uploads ──────────────────────────────────────────
  # Only active if content_types includes multipart/form-data
  multipart:
    file_limit: 10
    file_size: 10MB
    allowed_types: []                # empty = all; values are MIME types
    double_extension: true

  # ── Wire-level behavior ──────────────────────────────────
  protocol:
    slow_request_header_timeout: 10s
    slow_request_min_rate_bps: 1024
    http2_max_concurrent_streams: 100
    http2_max_continuation_frames: 32
    http2_max_decoded_header_bytes: 65536

  # ── What the response carries ─────────────────────────────
  response_headers:
    inject: {}                       # overrides per header (see protections catalog)
    strip_extra: []                  # additional response headers to strip

  # ── API contract ──────────────────────────────────────────
  openapi:
    shadow_api_logging: true         # log undeclared paths even when openapi-path-not-in-spec is disabled

  # ── Rate limiting ─────────────────────────────────────────
  # Off unless this block is present. Replaced wholesale by a route-level block.
  rate_limit:
    requests: 100                    # required
    window: 1s                       # required; parsed duration, e.g. "1s", "30s", "5m"
    source:
      type: ip                       # "ip" or "header"
      key: ""                        # required when type == "header"
    backend:
      type: memory                   # only "memory" is supported today
      max_keys: 100000               # LRU cap on tracked keys
      ttl: 10m                       # idle-key eviction window

Global field reference

Path Type Default Validation
global.mode enum blocking one of blocking, detect_only
global.disable []string [] every entry must resolve to a registered canonical name (L1 family, L2 bucket, or leaf)
global.enable []string [] every entry must resolve to a registered canonical name; takes precedence over disable for more-specific names
global.accept.methods []string standard 7 each must be a valid HTTP method
global.accept.content_types []string [] (all) each must be valid MIME type syntax
global.accept.max_body_size byte size 10MB > 0, <= 1GB
global.accept.max_url_length int 8192 >= 512, <= 65536
global.accept.max_header_size byte size 16KB >= 4KB, <= 1MB
global.accept.max_header_count int 100 >= 10, <= 1000
global.accept.require_host_header bool true
global.inspection.evaluation_timeout duration 50ms >= 10ms
global.inspection.max_inspect_size byte size 128KB > 0, <= 10MB
global.inspection.max_memory_buffer byte size 128KB > 0, <= 10MB
global.inspection.decompression_ratio_limit int 100 >= 1
global.inspection.json_depth int 20 >= 1, <= 1000
global.inspection.json_keys int 1000 >= 1, <= 100000
global.inspection.xml_depth int 20 >= 1, <= 1000
global.inspection.xml_entities int 100 >= 0, <= 10000
global.multipart.file_limit int 10 >= 1
global.multipart.file_size byte size 10MB > 0
global.multipart.allowed_types []string [] (all) MIME type syntax
global.multipart.double_extension bool true
global.protocol.slow_request_header_timeout duration 10s >= 1s
global.protocol.slow_request_min_rate_bps int 1024 >= 0
global.protocol.http2_max_concurrent_streams int 100 >= 1
global.protocol.http2_max_continuation_frames int 32 >= 1
global.protocol.http2_max_decoded_header_bytes int 65536 >= 4096
global.response_headers.inject map[string]string {} keys must be canonical response-headers-add-* names from the protection catalog
global.response_headers.strip_extra []string [] valid HTTP header names
global.openapi.shadow_api_logging bool true
global.rate_limit object none (off) when present, requests, window, and source.type are required — see Rate limiting
global.rate_limit.requests int >= 1
global.rate_limit.window duration >= 1s; e.g. 1s, 30s, 5m
global.rate_limit.source.type enum one of ip, header
global.rate_limit.source.key string required when source.type is header
global.rate_limit.backend.type enum memory currently must be memory
global.rate_limit.backend.max_keys int 100000 >= 1
global.rate_limit.backend.ttl duration 10m >= 1s

Byte sizes accept suffixes: B, KB, MB, GB (powers of 1024). Bare integers are bytes. Durations use Go's time.ParseDuration syntax: 500ms, 10s, 2m, etc.

Route section

routes:
  - id: public-api                   # optional, used as metric label; default: generated from match
    match:                           # optional; if omitted, matches all requests
      hosts: [api.example.com]       # optional; default: match any host
      paths: ["/v1/*"]               # optional; default: match any path
    upstream: http://backend:8000    # required
    upstream_timeout: 30s            # optional, default: 30s

    rewrite:                         # optional
      strip_prefix: /v1              # remove prefix before forwarding
      add_prefix: /api               # prepend after stripping
      path: /exact/path              # full replacement (overrides strip/add)

    mode: blocking                   # override global; optional ("blocking" or "detect_only")

    disable: []                      # canonical protection names disabled for this route only
    enable: []                       # opt-in protections to turn on for this route only

    accept:                          # any subset; unspecified fields inherit from global
      content_types: [application/json]
      methods: [GET, POST]
      max_body_size: 50MB

    inspection: {}                   # any subset; unspecified fields inherit from global
    multipart: {}                    # any subset; gated by accept.content_types

    response_headers:
      inject:
        response-headers-add-csp: "default-src 'self'; script-src 'self' https://cdn.example.com"
      strip_extra: []

    openapi:
      spec: /etc/barbacana/specs/public-api.yaml  # path relative to config or absolute
      strict: true                   # if true, enforce; if false, detect_only regardless of route
      disable: []                    # openapi-* leaves to skip

    cors:                            # CORS is opt-in per route
      allow_origins: ["https://app.example.com"]   # required when cors block is present
      allow_methods: [GET, POST]
      allow_headers: [Authorization, Content-Type]
      expose_headers: []
      allow_credentials: false
      max_age: 600

    error_response:                  # optional; custom body for blocked requests
      body: |
        {"error":"blocked","request_id":"{{.RequestID}}","ts":"{{.Timestamp}}"}

    rate_limit:                      # optional; replaces global.rate_limit entirely (no merge)
      requests: 20
      window: 1s
      source:
        type: header
        key: X-Api-Key

Route field reference

Path Type Default Validation
routes[].id string generated from first path ^[a-z0-9][a-z0-9-]*$, unique per config
routes[].match object match all if present, at least one of hosts or paths must be set
routes[].match.hosts []string [] (any) each a valid hostname or wildcard (*.example.com)
routes[].match.paths []string [] (any) each starts with /
routes[].upstream URL string — (required) valid http:// or https:// URL
routes[].upstream_timeout duration 30s >= 1s, <= 600s
routes[].rewrite.strip_prefix string none must start with /
routes[].rewrite.add_prefix string none must start with /
routes[].rewrite.path string none must start with /; if set, strip_prefix and add_prefix are ignored
routes[].mode string inherit from global one of blocking, detect_only
routes[].disable []string [] canonical names (L1 family, L2 bucket, or leaf)
routes[].enable []string [] canonical names; more-specific entries override disable
routes[].accept.* inherit from global see global field reference
routes[].inspection.* inherit from global see global field reference
routes[].multipart.* inherit from global see global field reference
routes[].response_headers.inject map inherit (merged key-wise) keys are canonical response-headers-add-* names
routes[].openapi object none (feature off) when present, spec is required
routes[].openapi.spec filepath required when the openapi: block is present; file must exist and parse as OpenAPI 3.x
routes[].openapi.strict bool true
routes[].openapi.disable []string [] openapi-* leaf names
routes[].cors object none (CORS off) when present, allow_origins is required
routes[].cors.allow_origins []string required when the cors: block is present; origins or * (never * with credentials)
routes[].cors.allow_methods []string [GET] valid HTTP methods
routes[].cors.allow_headers []string [] valid header names
routes[].cors.expose_headers []string [] valid header names
routes[].cors.allow_credentials bool false if true, allow_origins must not contain *
routes[].cors.max_age int (seconds) 600 >= 0, <= 86400
routes[].error_response.body string none (default JSON body) Go text/template; only {{.RequestID}} and {{.Timestamp}} are exposed
routes[].rate_limit object inherit from global.rate_limit (or off) when present, replaces the global block — no field-level merging; see Rate limiting

The disable and enable lists

Both lists accept canonical names from the protection catalog — at any of the three levels (L1 family, L2 bucket, leaf). Examples:

  • sql-injection — L2; disables every SQLi technique on this route
  • sql-injection-union-select — leaf; disables only that technique
  • response-headers-add-csp — leaf; skips CSP header injection (or, in enable:, opts in)
  • response-headers-remove-server — leaf; keeps the upstream's Server header
  • openapi-body-mismatch — leaf; skips body-schema validation but keeps path/method/param validation

Validation rejects any entry that does not resolve to a registered canonical name. The error message lists the misspelled entry and a suggestion if one is close (Levenshtein ≤ 2).

Route-level disable:/enable: is merged with global lists into a single effective set per route. Resolution rule: more specific wins. A leaf in enable: overrides its L2 or L1 in disable:, and the reverse for disable:. See the Tuning protections page for worked examples.

Content-type gating

accept.content_types controls which parsers run for a route:

  • If empty (default), all parsers are active.
  • If set to [application/json], only the JSON parser runs. XML parsing, multipart parsing, and form-urlencoded parsing are all skipped. XML-related inspection knobs (xml_depth, xml_entities) have no effect.
  • A POST with a Content-Type not in the accept list is rejected with 415 Unsupported Media Type.
  • The multipart section is only active if content_types includes multipart/form-data.

This is both a security control (rejecting unexpected content types) and a performance optimization (skipping unnecessary parsers).

Audit log

Stdout emission of audit events is unconditional — there is no off switch. The audit_log block selects the wire schema only.

audit_log:
  format: ocsf            # default; or "ecs"
Field Default Valid values
audit_log.format ocsf ocsf, ecs

The choice is process-wide and applies to every audit document for the lifetime of the process. Switching formats at runtime requires a config reload; one running process never mixes the two.

ocsf emits OCSF v1.2.0 (HTTP Activity event class, class_uid: 4002). ecs emits ECS 8.x. Both formats also carry a vendor barbacana.* namespace with matched_protections, matched_rules, and cwe — see the audit log reference for full document examples and the field-mapping table.

Tracing

Distributed tracing is opt-in. With the block absent or enabled: false, no exporter is created and no OTLP traffic ever leaves the process.

tracing:
  enabled: false           # default; flip to true to ship traces
  protocol: grpc           # grpc (default) or http (== http/protobuf)
  endpoint: ""             # falls back to OTEL_EXPORTER_OTLP_ENDPOINT
  insecure: true           # default; set false to require TLS to the collector
  headers:                 # optional, e.g. authentication
    authorization: "Api-Token <secret>"
  timeout: ""              # optional, e.g. 5s; >= 100ms when set

  service:
    name: ""               # defaults to "barbacana" when empty
    namespace: ""
    version: ""            # defaults to the build's internal version when empty
Field Default Validation
tracing.enabled false bool
tracing.protocol grpc one of grpc, http, http/protobuf
tracing.endpoint "" (use env) non-empty wins over OTEL_EXPORTER_OTLP_ENDPOINT; empty defers to env
tracing.insecure true bool
tracing.headers none string → string map
tracing.timeout none duration string parseable by Go's time.ParseDuration; >= 100ms when set
tracing.service.name "barbacana" string
tracing.service.namespace none string
tracing.service.version from build string

A subset of the standard OTLP exporter env vars are honoured as fallback when the corresponding YAML field is empty: OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TIMEOUT, and OTEL_RESOURCE_ATTRIBUTES (for any attribute other than service.name and service.version). YAML wins when both are set. OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_TRACES_SAMPLER, and OTEL_TRACES_SAMPLER_ARG are not currently consulted — use the matching YAML fields. The default sampler is ParentBased(AlwaysSample); for high-volume deployments, do tail sampling at your OTLP collector layer.

When tracing is enabled, audit log entries also carry the active trace and span IDs so SIEM events can be pivoted to the corresponding distributed trace. See tracing for the span model and a worked Jaeger example.

Route matching precedence

When a request arrives:

  1. If a route has no match block, it matches everything.
  2. Filter routes whose match.hosts matches the request Host header. Empty hosts matches any host.
  3. Among survivors, select the route whose match.paths has the most specific match.
  4. Specificity: literal path > longer prefix > shorter prefix. /v1/users/profile beats /v1/users/* beats /v1/* beats /*.
  5. Ties are resolved by source order (earlier wins). The compiler warns when ties exist.
  6. If no route matches, the request is rejected with 404 Not Found. There is no default route — explicit routing is required.

Host matching:

  • Exact: api.example.com
  • Suffix wildcard: *.example.com matches foo.example.com but not example.com
  • Case-insensitive

Path matching uses glob syntax: * matches a single segment, ** matches any number of segments. Trailing slashes are normalized.

Example 1: minimal (Mode 3, plain HTTP behind a load balancer)

version: v1alpha1

routes:
  - upstream: http://app:8000

Everything else is defaulted. port defaults to 8080 because no host is set and no route uses match.hosts. metrics_port and health_port stay at 0 (disabled) — audit logs on stdout are the only observability. Every protection is active in blocking mode. The five default-on security headers are injected with their built-in values. All canonical strip headers removed. All content types accepted. All parsers active.

Example 2: multi-route with per-team overrides (Mode 1, single host auto-TLS)

version: v1alpha1
host: example.com                    # single host, auto-TLS on :443 and :80→:443 redirect
data_dir: /var/lib/barbacana         # persistent TLS/ACME state
metrics_port: 9090                   # opt-in: expose Prometheus /metrics
health_port: 8081                    # opt-in: expose /healthz and /readyz

global:
  mode: blocking                     # switch whole instance to blocking mode

routes:
  - id: public-api
    match:
      paths: ["/v1/*"]
    upstream: http://api-backend:8000
    accept:
      content_types: [application/json]
      methods: [GET, POST, PUT, DELETE]
    rewrite:
      strip_prefix: /v1
    openapi:
      spec: /etc/barbacana/specs/public-api.yaml

  - id: admin
    match:
      paths: ["/admin/*"]
    upstream: http://admin-backend:8000
    accept:
      content_types: [application/json]
    enable:
      - response-headers-add-csp
      - response-headers-add-coep
    response_headers:
      inject:
        response-headers-add-csp: "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; upgrade-insecure-requests"
        response-headers-add-coep: "require-corp"
    cors:
      allow_origins: ["https://example.com"]
      allow_credentials: true

  - id: legacy-php
    match:
      paths: ["/legacy/*"]
    upstream: http://legacy:80
    rewrite:
      strip_prefix: /legacy
      add_prefix: /app
    disable:
      - php-injection                # legacy app trips on its own PHP-ish params (L2)
      - http-compliance-null-bytes   # legacy binary protocol uses \x00 markers
    mode: detect_only                # keep logging but don't break the legacy app

Example 3: extensive overrides (Mode 2, multi-host auto-TLS)

version: v1alpha1
data_dir: /var/lib/barbacana         # persistent TLS/ACME state
metrics_port: 9090                   # opt-in: expose Prometheus /metrics
health_port: 8081                    # opt-in: expose /healthz and /readyz

global:
  mode: blocking
  disable:
    - scanner-detection              # noisy across the whole fleet
  accept:
    methods: [GET, POST, PUT, DELETE]
    max_body_size: 50MB
  inspection:
    json_depth: 15
  response_headers:
    inject:
      response-headers-add-csp: "default-src 'self' https://assets.example.com"
      response-headers-add-hsts: "max-age=31536000"
    strip_extra:
      - X-Custom-Backend-Id

routes:
  - id: uploads
    match:
      hosts: [uploads.example.com]
      paths: ["/upload/*"]
    upstream: http://uploads:8000
    accept:
      content_types: [multipart/form-data]
      max_body_size: 500MB
    multipart:
      file_limit: 50
      file_size: 100MB
      allowed_types:
        - image/png
        - image/jpeg
        - application/pdf
      double_extension: true
    inspection:
      max_inspect_size: 256KB        # larger payloads need more inspection buffer

  - id: graphql
    match:
      hosts: [api.example.com]
      paths: ["/graphql"]
    upstream: http://gql:4000
    accept:
      content_types: [application/json]
    inspection:
      json_depth: 40                 # GraphQL queries can be deep
      json_keys: 5000

  - id: webhooks
    match:
      hosts: [hooks.example.com]
    upstream: http://hook-router:8000
    accept:
      content_types: [application/json, application/x-www-form-urlencoded]
    disable:
      - response-headers-add-csp     # webhooks never render HTML
    enable:
      - response-headers-add-cache-control
    response_headers:
      inject:
        response-headers-add-cache-control: "no-store"

Validation behaviour

All validation runs during --validate and on startup. Errors are emitted as a single list with file path, YAML line number, and a specific message. Example:

waf.yaml:17: unknown protection "sql-injetcion" in route "public-api" disable list (did you mean "sql-injection"?)
waf.yaml:23: global.accept.max_body_size must be <= 1GB, got 2GB
waf.yaml:31: route "admin" cors.allow_credentials is true but allow_origins contains "*"
waf.yaml:45: route "uploads" accept.content_types includes "multipart/form-data" but multipart.file_limit is 0

The binary exits 1 with the error list. No config fragments are ever applied when validation fails.