Configuration
Learn about the JSON configuration file format, rules, conditions, and actions.
Doorman uses a JSON configuration file to define your firewall rules. The configuration is validated using JSON Schema and provides full TypeScript support.
Schema URL
Add the schema reference to your config file for editor autocompletion and validation:
{
"$schema": "https://doorman.griffen.codes/schema.json"
}Two Rule Formats
Doorman recognizes two on-disk config shapes, auto-detected by whether the file has a top-level provider or providers field:
- Unified format — any config with
providerand/orprovidersset. Required for Cloudflare and Fastly. Rules useconditions/enabled/flataction: { type }. This is the format the rest of this page documents. - Legacy format — a Vercel-only config with no
provider/providersfield at all. Rules useconditionGroup/active/action: { mitigate }. Kept for backward compatibility with configs written before multi-provider support existed. See Legacy (Vercel-only) Rule Format below.
These are genuinely different shapes, not a relabeling of the same fields — mixing them in one rule (e.g. conditionGroup inside a provider-tagged config) fails validation. If you're setting up Cloudflare or Fastly, use the unified format from the start.
Basic Structure
Vercel Configuration (legacy format)
{
"$schema": "https://doorman.griffen.codes/schema.json",
"projectId": "prj_abc123",
"teamId": "team_xyz789",
"rules": [],
"ips": []
}Cloudflare Configuration (unified format)
{
"$schema": "https://doorman.griffen.codes/schema.json",
"provider": "cloudflare",
"providers": {
"cloudflare": {
"zoneId": "zone_abc123",
"accountId": "acc_xyz789"
}
},
"rules": [],
"ips": []
}Fastly Configuration (unified format)
{
"$schema": "https://doorman.griffen.codes/schema.json",
"provider": "fastly",
"providers": {
"fastly": {
"workspaceId": "workspace_abc123"
}
},
"rules": [],
"ips": []
}Multi-Provider Configuration (unified format)
{
"$schema": "https://doorman.griffen.codes/schema.json",
"provider": "cloudflare",
"providers": {
"vercel": {
"projectId": "prj_abc123",
"teamId": "team_xyz789"
},
"cloudflare": {
"zoneId": "zone_abc123",
"accountId": "acc_xyz789"
},
"fastly": {
"workspaceId": "workspace_abc123"
}
},
"rules": [],
"ips": []
}Root Properties
| Property | Type | Required | Description |
|---|---|---|---|
$schema | string | No | JSON Schema URL for validation |
provider | string | No | Default provider ("vercel", "cloudflare", or "fastly"). Presence of this field (or providers) switches the file into unified format. |
projectId | string | Vercel Only | Vercel project ID (legacy format) |
teamId | string | Vercel Only | Vercel team ID (legacy format, optional) |
providers | object | Multi-Provider | Provider-specific configurations |
rules | array | Yes | Array of firewall rules — shape depends on format, see above |
ips | array | No | Array of IP blocking rules |
managedRules | array | No | Cloudflare only. Vendor-managed rulesets (e.g. Cloudflare Managed Ruleset, OWASP CRS) to deploy — see Managed Rule Groups |
version | number | No | Configuration version |
firewallEnabled | boolean | No | Enable/disable firewall |
Rules (Unified Format)
Use this format for Cloudflare, Fastly, or any config with provider/providers set — including a Vercel config you've explicitly tagged with "provider": "vercel".
Rule Structure
{
"id": "rule_block_bots",
"name": "Block Bad Bots",
"description": "Block malicious bots and crawlers",
"enabled": true,
"conditions": [
{ "field": "user_agent", "operator": "contains", "value": "bot" }
],
"action": { "type": "deny" }
}Rule Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | No | Unique rule identifier |
name | string | Yes | Human-readable rule name |
description | string | No | Rule description |
enabled | boolean | Yes | Whether rule is enabled |
conditions | array | Yes | Array of conditions (at least one required) — see below |
conditionLogic | string | No | "AND" (default) or "OR", applied across all conditions when none of them set group — see Grouping Conditions |
action | object | Yes | Action to take when conditions match |
priority | number | No | Evaluation order — lower runs first. Fully honoured on Cloudflare; best-effort on Vercel, which can't reposition rules that already exist remotely. |
Conditions
Each condition has a field, operator, and (usually) a value:
{ "field": "path", "operator": "starts_with", "value": "/admin" }exists/not_exists operators carry no value — everything else requires one. header/query/cookie conditions take an additional key to scope to a specific name:
{ "field": "header", "key": "X-Custom-Header", "operator": "eq", "value": "expected" }Cloudflare caveat:
keyis only honoured forheaderandcookietoday — a keyedquerycondition currently matches against the whole query string instead of the named parameter. Tracked in #263.
Grouping Conditions
For a simple rule, omit group entirely — all conditions implicitly share group 0 and are AND'd together (or OR'd, if you set conditionLogic: "OR").
For "OR of AND-groups" logic (e.g. (path starts with "/admin" AND method is POST) OR (IP is 192.168.1.1)), tag each condition with a group number — conditions sharing a group are AND'd, and distinct group values are OR'd against each other. group takes priority over conditionLogic the moment any condition sets it:
{
"conditions": [
{ "field": "path", "operator": "starts_with", "value": "/admin", "group": 0 },
{ "field": "method", "operator": "eq", "value": "POST", "group": 0 },
{ "field": "ip", "operator": "eq", "value": "192.168.1.1", "group": 1 }
]
}Field Support
| Field | Vercel | Cloudflare | Fastly |
|---|---|---|---|
ip | ✅ | ✅ | ✅ |
country | ✅ | ✅ | ✅ |
region | ✅ | ✅ | ❌ dropped, warned |
city | ✅ | ✅ | ❌ dropped, warned |
asn | ✅ | ✅ | ❌ dropped, warned |
path | ✅ | ✅ | ✅ |
host | ✅ | ✅ | ✅ |
method | ✅ | ✅ | ✅ |
header | ✅ | ✅ requires key | ⚠️ requires key, else dropped/warned |
query | ✅ | ✅ key optional — scopes to one parameter when set | ⚠️ requires key, else dropped/warned |
cookie | ✅ | ✅ requires key | ⚠️ requires key, else dropped/warned |
user_agent | ✅ | ✅ | ✅ |
referer | ❌ dropped, warned | ✅ | ❌ dropped, warned |
scheme | ✅ | ✅ | ✅ |
port | ❌ dropped, warned | ✅ | ❌ dropped, warned |
exists/not_exists on Fastly only work on the keyed fields (header/query/cookie) — unsupported (dropped, warned) on every other Fastly field.
Operator Support
| Operator | Vercel | Cloudflare | Fastly |
|---|---|---|---|
eq | ✅ | ✅ | ✅ |
ne | ✅ maps to eq + neg: true | ✅ | ✅ |
contains | ✅ | ✅ | ✅ |
not_contains | ✅ maps to sub + neg: true | ✅ | ✅ |
starts_with | ✅ | ✅ | ⚠️ wildcard approximation, not a true prefix match |
ends_with | ✅ | ✅ | ⚠️ wildcard approximation, not a true suffix match (identical mechanism to starts_with) |
matches (regex) | ✅ | ✅ | ⚠️ supported, but regex syntax may need adjustment — always warned |
in | ✅ | ✅ | ✅ |
not_in | ✅ maps to inc + neg: true | ✅ | ✅ |
gt | ⚠️ no numeric-comparison operator exists — dropped, warned | ✅ | ⚠️ boundary-inclusive approximation (greater_equal) |
ge | ⚠️ no numeric-comparison operator exists — dropped, warned | ✅ | ✅ |
lt | ⚠️ no numeric-comparison operator exists — dropped, warned | ✅ | ⚠️ boundary-inclusive approximation (lesser_equal) |
le | ⚠️ no numeric-comparison operator exists — dropped, warned | ✅ | ✅ |
exists | ✅ | ✅ | ⚠️ only on keyed fields (header/query/cookie) |
not_exists | ✅ | ✅ | ⚠️ only on keyed fields (header/query/cookie) |
ne/not_contains/not_in are fully supported on Vercel via its positive-operator-plus-neg-flag model (the same mechanism not_exists's nex already uses) — #261 fixed these three, which previously mis-mapped silently to plain eq. gt/ge/lt/le remain a genuine platform gap, not a bug: Vercel's operator vocabulary has no numeric-comparison concept at all, so a condition using one of these four is dropped with a warning rather than synced incorrectly.
Action Support
| Action | Vercel | Cloudflare | Fastly |
|---|---|---|---|
log | ✅ | ✅ | ⚠️ no dedicated action, maps to allow (request logging is separately always-on) |
deny | ✅ | ✅ | ✅ |
challenge | ✅ | ✅ | ✅ |
bypass | ✅ | ✅ | ⚠️ no equivalent, maps to allow |
rate_limit | ⚠️ silently becomes a plain rule with no rate-limit effect if rateLimit is omitted | ⚠️ silently becomes a plain block rule with no rate-limit effect if rateLimit is omitted | ✅ throws if rateLimit is missing; requires a pre-existing Fastly Signal named doorman-rate-limit-<ruleId> that doorman does not create for you |
redirect | ⚠️ silently becomes a rule with no redirect target if redirect is omitted; statusCode/preserveQueryString are silently dropped even when present | ⚠️ silently becomes a rule with no redirect target if redirect is omitted | ✅ falls back to allow (warned) if redirect is missing, otherwise fully supported |
allow | ✅ mapped to bypass (Vercel has no native allow) | ✅ | ✅ |
block | ✅ mapped to deny (Vercel has no native block) | ✅ (same as deny) | ✅ (same as deny) |
#262 fixed allow/block reaching Vercel's API as an invalid native action value — both are now automatically mapped to their nearest Vercel equivalent, so either spelling works in a Vercel-targeted config.
Rate Limiting
{
"action": {
"type": "rate_limit",
"rateLimit": {
"requests": 100,
"window": "60s",
"characteristics": ["ip.src"]
}
}
}window accepts a number followed by s/m/h/d.
Redirect
{
"action": {
"type": "redirect",
"redirect": {
"location": "https://example.com/blocked",
"statusCode": 302,
"permanent": false,
"preserveQueryString": false
}
}
}location accepts an absolute URL or a path starting with /. On Vercel, only location/permanent are actually used — statusCode/preserveQueryString are silently dropped, so omit them if you need this rule to round-trip cleanly to Vercel.
Legacy (Vercel-only) Rule Format
Only applies to a config with no provider/providers field — i.e. the original Vercel-only shape shown in Basic Structure above.
Rule Structure
{
"id": "rule_block_bots",
"name": "Block Bad Bots",
"description": "Block malicious bots and crawlers",
"active": true,
"conditionGroup": [
{
"conditions": [
{ "type": "user_agent", "op": "sub", "value": "bot", "neg": false }
]
}
],
"action": {
"mitigate": { "action": "deny" }
}
}Rule Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | No | Unique rule identifier |
name | string | Yes | Human-readable rule name |
description | string | No | Rule description |
active | boolean | Yes | Whether rule is enabled |
conditionGroup | array | Yes | Array of condition groups — OR logic between groups, AND logic within a group |
action | object | Yes | { "mitigate": { "action": ... } } — action to take when conditions match |
{
"conditionGroup": [
{
"conditions": [
{ "type": "path", "op": "pre", "value": "/admin" },
{ "type": "method", "op": "eq", "value": "POST" }
]
},
{
"conditions": [
{ "type": "ip_address", "op": "eq", "value": "192.168.1.1" }
]
}
]
}This translates to: (path starts with "/admin" AND method equals "POST") OR (IP equals "192.168.1.1").
Legacy Condition Types
host, path, method, header, query, cookie, target_path, ip_address, region, protocol, scheme, environment, user_agent, geo_continent, geo_country, geo_country_region, geo_city, geo_as_number, ja4_digest, ja3_digest, rate_limit_api_id
header/cookie require a key. For ip_address/method/environment/protocol, op must be eq or inc — no other operator is valid for these four types.
Legacy Operators
| Operator | Meaning |
|---|---|
eq | Equals |
pre | Starts with |
suf | Ends with |
sub | Contains |
inc | Is any of (array) — requires value to be an array |
re | Regex match |
ex | Exists |
nex | Does not exist |
neg: true negates a condition, but can't be combined with ex/nex — use nex directly instead of neg: true + ex.
Legacy Actions
log, deny, challenge, bypass, rate_limit, redirect — this is a closed set; allow/block are not valid here (use bypass/deny).
{ "action": { "mitigate": { "action": "rate_limit", "rateLimit": { "requests": 100, "window": "60s" } } } }{ "action": { "mitigate": { "action": "redirect", "redirect": { "location": "/correct-path", "permanent": false } } } }IP Blocking Rules
{
"ips": [
{
"id": "ip_block_suspicious",
"ip": "192.168.1.100/32",
"hostname": "suspicious-host",
"action": "deny",
"notes": "Blocked due to suspicious activity"
}
]
}| Property | Type | Required | Description |
|---|---|---|---|
ip | string | Yes | IP address or CIDR range |
hostname | string | No | Hostname for documentation |
action | string | Yes | "deny" in both formats; "allow" is also valid in the unified format |
notes | string | No | Notes about the block |
Same shape in both the legacy and unified formats.
Managed Rule Groups
Cloudflare only. Deploy a vendor-managed ruleset (Cloudflare Managed Ruleset, OWASP CRS, etc.) alongside your custom rules, with optional overrides — instead of hand-writing rules to replicate what a preconfigured WAF ruleset already covers.
{
"managedRules": [
{
"id": "execute-owasp-crs",
"ruleset": "efb7b8c949ac4650a09736fc376e9aee",
"name": "OWASP Core Ruleset",
"enabled": true,
"action": "log",
"overrides": [
{ "ruleId": "981176", "action": "deny" },
{ "ruleId": "981245", "enabled": false }
]
}
]
}| Property | Type | Required | Description |
|---|---|---|---|
id | string | No | Doorman's diff/sync identifier for this deployment. Omit for a new declaration; Doorman assigns one on first sync. |
ruleset | string | Yes | The vendor ruleset id to deploy (e.g. Cloudflare Managed Ruleset's well-known id shown above) |
name | string | No | Human label |
enabled | boolean | Yes | Whether this deployment is active |
action | string | No | Ruleset-wide override — downgrade every rule in the group to this action. One of log, deny, challenge, allow |
overrides | array | No | Per-rule overrides within the ruleset — { "ruleId": string, "action"?: string, "enabled"?: boolean }, referenced by the vendor's rule id within that ruleset, not a Doorman id |
managedRules needs to live alongside a provider: "cloudflare"/providers.cloudflare block — it's part of the unified format, same as rules/ips.
Adding this to a provider/providers-tagged config that already has custom rules works alongside them — managed rulesets deploy in Cloudflare's separate managed-rules phase, evaluated independently of your custom rules, so there's no ordering interaction to think about between the two.
Environment Variables
Vercel
VERCEL_TOKEN="your_vercel_token"
VERCEL_PROJECT_ID="prj_abc123"
VERCEL_TEAM_ID="team_xyz789"Cloudflare
CLOUDFLARE_API_TOKEN="your_api_token"
CLOUDFLARE_ZONE_ID="zone_abc123"
CLOUDFLARE_ACCOUNT_ID="acc_xyz789"Fastly
FASTLY_API_TOKEN="your_api_token"
FASTLY_WORKSPACE_ID="workspace_abc123"Provider Selection
DOORMAN_PROVIDER="cloudflare" # or "vercel" or "fastly"Best Practices
- Use descriptive names for rules and IPs
- Add descriptions to explain rule purposes
- Group related conditions logically
- Use CIDR notation for IP ranges
- Order rules by frequency (most common first)
- Test rules in staging before production
- Start with log actions before blocking
- Keep backups of working configurations
- Avoid the operators/actions flagged 🚨 above for whichever provider you're targeting, until their tracking issues are fixed
Related Pages
- Getting Started — Quick setup guide
- Commands Overview — CLI command reference
- Examples — Real-world configuration examples
- Cloudflare Setup — Cloudflare-specific setup, including managed rule groups
This content is sourced from the GitHub Wiki.