-
Notifications
You must be signed in to change notification settings - Fork 1
Configuration
Doorman uses a JSON configuration file to define your firewall rules. The configuration is validated using JSON Schema and provides full TypeScript support.
Add the schema reference to your config file for editor autocompletion and validation:
{
"$schema": "https://doorman.griffen.codes/schema.json"
}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.
{
"$schema": "https://doorman.griffen.codes/schema.json",
"projectId": "prj_abc123",
"teamId": "team_xyz789",
"rules": [],
"ips": []
}{
"$schema": "https://doorman.griffen.codes/schema.json",
"provider": "cloudflare",
"providers": {
"cloudflare": {
"zoneId": "zone_abc123",
"accountId": "acc_xyz789"
}
},
"rules": [],
"ips": []
}{
"$schema": "https://doorman.griffen.codes/schema.json",
"provider": "fastly",
"providers": {
"fastly": {
"workspaceId": "workspace_abc123"
}
},
"rules": [],
"ips": []
}{
"$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": []
}| 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 |
Use this format for Cloudflare, Fastly, or any config with provider/providers set — including a Vercel config you've explicitly tagged with "provider": "vercel".
{
"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" }
}| 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. |
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.
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 | Vercel | Cloudflare | Fastly |
|---|---|---|---|
ip |
✅ | ✅ | ✅ |
country |
✅ | ✅ | ✅ |
region |
✅ | ✅ | ❌ dropped, warned |
city |
✅ | ✅ | ❌ dropped, warned |
asn |
✅ | ✅ | ❌ dropped, warned |
path |
✅ | ✅ | ✅ |
host |
✅ | ✅ | ✅ |
method |
✅ | ✅ | ✅ |
header |
✅ | ✅ requires key
|
key, else dropped/warned |
query |
✅ | key currently ignored — #263
|
key, else dropped/warned |
cookie |
✅ | ✅ requires key
|
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 | Vercel | Cloudflare | Fastly |
|---|---|---|---|
eq |
✅ | ✅ | ✅ |
ne |
🚨 silently becomes eq — #261
|
✅ | ✅ |
contains |
✅ | ✅ | ✅ |
not_contains |
🚨 silently becomes eq — #261
|
✅ | ✅ |
starts_with |
✅ | ✅ | |
ends_with |
✅ | ✅ | starts_with) |
matches (regex) |
✅ | ✅ | |
in |
✅ | ✅ | ✅ |
not_in |
🚨 silently becomes eq — #261
|
✅ | ✅ |
gt |
🚨 silently becomes eq — #261
|
✅ | greater_equal) |
ge |
🚨 silently becomes eq — #261
|
✅ | ✅ |
lt |
🚨 silently becomes eq — #261
|
✅ | lesser_equal) |
le |
🚨 silently becomes eq — #261
|
✅ | ✅ |
exists |
✅ | ✅ | header/query/cookie) |
not_exists |
✅ | ✅ | header/query/cookie) |
⚠️ Known bug, not a documentation gap: the 🚨 rows above are not "unsupported" in the safe sense of being rejected or dropped with a warning — Vercel's translator currently mis-maps all seven of these operators to plaineqsilently, which can invert a rule's intent (e.g.ne— "is not" — becomes "is"). Tracked in #261. Until that's fixed, avoid these seven operators in any config targeting Vercel; they all work correctly on Cloudflare.
| Action | Vercel | Cloudflare | Fastly |
|---|---|---|---|
log |
✅ | ✅ | allow (request logging is separately always-on) |
deny |
✅ | ✅ | ✅ |
challenge |
✅ | ✅ | ✅ |
bypass |
✅ | ✅ | allow
|
rate_limit |
rateLimit is omitted |
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 |
redirect is omitted; statusCode/preserveQueryString are silently dropped even when present |
redirect is omitted |
✅ falls back to allow (warned) if redirect is missing, otherwise fully supported |
allow |
🚨 not a valid native Vercel action — #262 | ✅ | ✅ |
block |
🚨 not a valid native Vercel action — #262 | ✅ (same as deny) |
✅ (same as deny) |
Use bypass (not allow) and deny (not block) in Vercel-targeted configs until #262 is fixed.
{
"action": {
"type": "rate_limit",
"rateLimit": {
"requests": 100,
"window": "60s",
"characteristics": ["ip.src"]
}
}
}window accepts a number followed by s/m/h/d.
{
"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.
Only applies to a config with no provider/providers field — i.e. the original Vercel-only shape shown in Basic Structure above.
{
"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" }
}
}| 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").
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.
| 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.
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 } } } }{
"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.
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.
VERCEL_TOKEN="your_vercel_token"
VERCEL_PROJECT_ID="prj_abc123"
VERCEL_TEAM_ID="team_xyz789"CLOUDFLARE_API_TOKEN="your_api_token"
CLOUDFLARE_ZONE_ID="zone_abc123"
CLOUDFLARE_ACCOUNT_ID="acc_xyz789"FASTLY_API_TOKEN="your_api_token"
FASTLY_WORKSPACE_ID="workspace_abc123"DOORMAN_PROVIDER="cloudflare" # or "vercel" or "fastly"- 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
- Getting Started — Quick setup guide
- Commands Overview — CLI command reference
- Examples — Real-world configuration examples
- Cloudflare Setup — Cloudflare-specific setup, including managed rule groups
Getting Started
Configuration
Commands
Guides