This is the official reference for kCaddy. It covers:

  • Every object and attribute available in kcaddy.yaml
  • The project file and folder structure, both on GitHub and inside Docker
  • How to install and deploy kCaddy
  • The design decisions behind the project

Overview

kCaddy is a Python builder that reads a single kcaddy.yaml configuration file and generates a complete Caddy web server configuration from it. The pipeline works in four steps:

  1. The builder parses and validates kcaddy.yaml against a strict Pydantic schema (extra="forbid" on all models; unknown keys are rejected at startup).
  2. Jinja2 templates are rendered into .caddy snippet files for headers, filters, matchers, and reverse proxy profiles, organized under helpers/.
  3. A main Caddyfile is generated that imports those helpers and the sites-enabled/ directory.
  4. Caddy reads the generated Caddyfile and serves traffic.

The entire output lives under a single directory (by default /etc/caddy inside Docker), which makes it trivial to mount one volume and manage the full state of a red team redirector in one place.

Note

Document conventions: each reference section follows the layout: kcaddy YAML path → schema table → examples. Where a field accepts either a single value or a list, both shapes are shown explicitly. Code blocks labeled Compact or Flow YAML use inline JSON-style notation; blocks labeled Expanded use full block YAML. Both are valid in kcaddy.yaml.


Project Structure

GitHub Repository Layout

After cloning the repository you will see the following top-level layout:

.
├── builder/        # Python package entrypoint (delegates to src/)
├── config/         # Runtime config: kcaddy.yaml.example, kcaddy.yml
├── data/
│   ├── assets/     # IP/UA lists and default HTML pages
│   └── www-data/   # Optional static content for local builds
├── docker/         # Dockerfile, entrypoint script, docker-compose.yml
├── src/            # Python builder package
│   ├── core/
│   │   ├── config.py           # Config parser
│   │   ├── config_schema.py    # Pydantic schema (source of truth for validation)
│   │   ├── runtime_paths.py    # Path resolution from environment / kcaddy.yaml
│   │   ├── builder.py          # run_build() orchestration
│   │   ├── generators/         # Per-section Caddyfile generators
│   │   └── utils.py
│   ├── templates/              # Jinja2 templates used by generators
│   └── main.py                 # Typer CLI (build, validate)
├── task/           # Taskfile.yml — wrapper tasks for common operations
├── .editorconfig
├── .gitattributes
├── .gitignore
└── README.md

Docker Volume Layout

When kCaddy runs inside Docker, all runtime data is placed under a single root directory (default /etc/caddy). This is an intentional design decision: a single named volume covers the entire state of the deployment, making it trivial to back up, inspect, or wipe between engagements. See Design Decisions for the full rationale.

/etc/caddy/                        ← KCADDY_ROOT (single Docker volume)
├── config/
│   └── kcaddy.yml                 ← your configuration file
├── assets/
│   ├── ip/                        ← IP blocklists for filters
|   |── static-defaults/           ← default error pages
│   └── ua/                        ← User-Agent blocklists for filters
├── www-data/                           ← static content served by file_server
│   └── evilginx-example-group/    ← per-site or group static pages
├── logs/                          ← access and error logs
├── caddy-storage/                 ← Caddy's internal TLS/ACME data
└── Caddyfile                      ← generated main Caddyfile
    helpers/
    ├── headers/
    ├── filters/
    ├── matchers/
    └── reverse_proxy/

The entrypoint sets KCADDY_ROOT=/etc/caddy and derives all sub-paths from it automatically. Subdirectory names can be customized via the paths: key in kcaddy.yaml (see Root-Level Keys).


kcaddy.yaml Reference

The top-level structure of kcaddy.yaml is:

#-------------------------------
# Global keys
#-------------------------------
force_rebuild: false

paths:
  root: /etc/caddy
  www: www
  log: logs
  assets: assets

#-------------------------------
# Helpers (profile maps: name → profile body)
#-------------------------------
helpers:
  headers:
    profile_name:
      set: ["Header-Name: value"]
      strip: ["string"]
      defer: false
  filters:
    profile_name:
      type: blacklist | whitelist
      kind: ip | ua
      list: ["string"]
      list_file: "path/under/assets"
  matchers:
    profile_name:
      - <clause>: ...
  reverse_proxy:
    snippet_key:
      uri: "https://upstream:443"
      transport: { ... }
      header_up: { set: [...], strip: [...] }
      header_down: { set: [...], strip: [...] }
      matchers_templates: [...]
      handle_response: { matcher: "@name", error: { status: 404 } }

#-------------------------------
# Caddyfile (global wildcard :port block)
#-------------------------------
Caddyfile:
  import_folders: ["sites-enabled"]
  http: { http: true, https: true, http_port: 80, https_port: 443 }
  headers_template: nginx_default
  filters_templates: [ip_blacklist]
  matchers_templates: []
  encode: "zstd gzip"
  tls: internal
  auto_https: disable_redirects
  respond: { body: "OK", code: 200 }
  rewrites: []
  handle: []
  error: []
  log: { ... }
  custom_config: ""

#-------------------------------
# Sites (list of virtual host objects)
#-------------------------------
sites:
  - sitename: example
    enable: true
    domains: ["example.com", "*.example.com"]
    http: { ... }
    headers_template: nginx_default
    filters_templates: []
    matchers_templates: []
    encode: "zstd gzip"
    tls: internal
    root: { matcher: "*", path: /srv/www, file_server: true }
    reverse_proxy_template: ""
    handle: []
    log: { ... }
    error: []
    custom_config: ""

Root-Level Keys

Schema

KeyTypeDefaultDescription
force_rebuildBooleanfalseForce the builder to regenerate all output files even if kcaddy.yaml is unchanged since the last build. Useful after upgrading templates or the Docker image.
pathsObject-Optional override for runtime directory layout. See paths below.
helpersObject-Required. Header, filter, matcher, and reverse-proxy profiles.
CaddyfileObject-Optional. Global Caddyfile wildcard block configuration.
sitesList-Optional. List of virtual host definitions.

force_rebuild

Set to true once after upgrading templates or the Docker image, then set it back to false for normal runs. The builder tracks a hash of kcaddy.yaml and skips regeneration when the file has not changed, unless this flag is set.

force_rebuild: true

Paths

The paths object lets you override the default subdirectory layout under KCADDY_ROOT. All sub-keys (except root) must be single path segments (no / or ..). When root is set, any sub-key that is omitted defaults to <root>/<default_segment>.

Schema
KeyTypeDefault segmentDescription
rootStringn/aAbsolute path override for the Caddy output root (e.g. /etc/caddy). Must start with /.
config_dirStringconfigSubdirectory name for the config file location.
wwwStringwwwSubdirectory name for static web content.
logStringlogsSubdirectory name for log files. This is a directory segment name, not a Caddy log {} block.
storageStringcaddy-storageSubdirectory name for Caddy’s internal TLS/ACME storage (set via XDG_DATA_HOME).
assetsStringassetsSubdirectory name for operator assets (IP lists, UA lists, static defaults).
# Full explicit layout (same as the defaults):
paths:
  root: /etc/caddy
  config_dir: config
  www: www
  log: logs
  storage: caddy-storage
  assets: assets

Helpers

helpers is the required top-level key that holds four optional profile maps: headers, filters, matchers, and reverse_proxy. Each map is keyed by a profile name of your choosing. The builder writes each profile to a .caddy file under helpers/<category>/<profile_name>.caddy. The Caddyfile wildcard block and each sites[] entry reference profiles by name via headers_template, filters_templates, matchers_templates, and reverse_proxy_template.

Schema

KeyTypeDescription
headersMap (name → Header profile)Optional. Each key → helpers/headers/<key>.caddy.
filtersMap (name → Filter profile)Optional. Generated only when the profile name appears in filters_templates.
matchersMap (name → Matcher profile)Optional. Every key → helpers/matchers/<key>.caddy on each build, regardless of whether it is referenced.
reverse_proxyMap (name → Reverse proxy profile)Optional. Each key → helpers/reverse_proxy/<key>.caddy as a Caddy snippet (key) { reverse_proxy … }. The main Caddyfile imports helpers/reverse_proxy/*.caddy once at the top level.

Example: minimal helpers map showing how sites and Caddyfile reference profiles by name:

helpers:
  headers:
    nginx_default: { set: ["Server: nginx/1.18"], strip: ["Via*"], defer: true }
  filters:
    ip_blacklist: { type: blacklist, kind: ip, list: ["10.0.0.0/8"] }
  matchers:
    api_only: [{ host: api.example.com }]
  reverse_proxy:
    app: { uri: "http://127.0.0.1:8080" }

Headers

kcaddy YAML: helpers.headers.<profile_name> (object).

Caddy output: helpers/headers/<profile_name>.caddy, a header { } block. Directives are always emitted in this order: all strip entries first, then all set entries, then the optional defer keyword. YAML key order does not change this.

Schema
AttributeTypeAllowed valuesDescription
setList of stringsEach item: "Name: value"Colon + space (": ") separates name from value. The builder splits on the first occurrence and emits header Name "value" in the Caddyfile.
stripList of stringsNon-empty names or patternsRemove response headers. Wildcard-style patterns (e.g. X-*, Via*) are supported as Caddy allows.
deferBooleantrue / falseWhen true, adds the Caddy defer keyword inside the header block so it applies late in the handler chain.

At least one of set or strip must be present. An empty profile is skipped by the builder.

For set, always use one string per header in "Name: value" format. Do not use separate YAML keys for name and value.

Example: nginx-style fingerprint with aggressive header stripping:

helpers:
  headers:
    nginx_default:
      set: ["Server: nginx/1.18.0 (Ubuntu)", "X-Frame-Options: SAMEORIGIN", "X-Content-Type-Options: nosniff", "X-XSS-Protection: 1; mode=block", "Referrer-Policy: strict-origin-when-cross-origin", "Connection: keep-alive", "Accept-Ranges: bytes"]
      strip: ["X-*", "Via*", "ETag*"]
      defer: true

Example: strip-only profile (useful when the upstream already sends safe headers):

helpers:
  headers:
    strip_upstream_leaks: { strip: ["X-Powered-By", "Server"], defer: true }

Filters

kcaddy YAML: helpers.filters.<profile_name> (object).

Caddy output: helpers/filters/<profile_name>.caddy, generated only when the profile name appears in filters_templates on the Caddyfile block or on a site. Filter profiles are evaluated in the order they appear in filters_templates. If you need a whitelist evaluated before a blacklist, list the whitelist profile first.

Schema
AttributeTypeAllowed valuesDescription
typeStringblacklist, whitelistblacklist → deny if matched. whitelist → allow only if matched.
kindStringip, uaip: CIDR or IP strings. ua: User-Agent substrings compiled into a regex pattern by the builder.
listList of stringsNon-empty entriesInline list. Merged with list_file contents when both are present.
list_fileStringPath relative to assets rootEntries loaded from a file (one per line; lines starting with # are ignored). Merged with list.
sourceString or list of stringsIP filters only. remote_ip and/or client_ip. Default when omitted: remote_ip. Several values apply the same CIDR/IP list to each matcher and combine with OR. Not valid when kind is ua.

At least one of list (non-empty) or list_file (non-empty path) is required.

For kind: ip, use client_ip (alone or together with remote_ip) when Caddy sits behind a reverse proxy or CDN and you have configured Caddy trusted_proxies so forwarded client addresses are trusted.

helpers:
  filters:
    ip_blacklist: { type: blacklist, kind: ip, list: ["10.0.0.0/8", "192.168.1.1"], list_file: ip/global_blacklist.txt }
    ua_blacklist: { type: blacklist, kind: ua, list_file: ua/global_bad_ua.txt }
    ip_whitelist: { type: whitelist, kind: ip, list: ["192.168.1.0/24"] }

Example: list_file only (typical for large blocklists):

helpers:
  filters:
    ua_bad_only_file: { type: blacklist, kind: ua, list_file: ua/global_bad_ua.txt }

Matchers

kcaddy YAML: helpers.matchers.<profile_key>: each value is a list of clause objects, or a legacy object with clauses.

Caddy output: helpers/matchers/<profile_key>.caddy defines a named matcher @<name>. The @ name defaults to the YAML key unless an explicit name field is provided in the legacy form. Both the profile key and any explicit name must match ^[A-Za-z_][A-Za-z0-9_]*$. Reference a matcher inside a site block with @profile_key after importing via matchers_templates.

Imports are controlled by matchers_templates on Caddyfile, on a site, or on a handle.

Schema (profile root)
FormTypeDescription
List of clause objects[{ clause_type: value }, ...]Preferred form.
Legacy object{ name: optional_at_name, clauses: [...] }Accepted for backward compatibility.

Each clause item must be a YAML object with exactly one key. The supported clause types and their value rules are:

ClauseYAML value typeDescription
hostNon-empty stringMatch by hostname. Caddy’s native host matcher accepts one or more space-separated hostnames in a single string; the value is passed verbatim. A request matches if its hostname equals any of the listed values.
pathNon-empty stringMatch by path. Caddy’s native path matcher accepts one or more space-separated path patterns in a single string; the value is passed verbatim. A request matches if its path equals any of the listed patterns (e.g. "/login /admin /dashboard").
path_regexpObject: regexp name → pattern stringMatch by path regular expression. Each entry becomes path_regexp <name> <pattern>.
queryNon-empty stringMatch by query string.
methodNon-empty stringMatch by HTTP method. To match multiple methods, space-separate them in a single string (e.g. "GET POST DELETE").
headerObject: field name → string valueMatch by request header field and value.
header_regexpObject: name → [field, pattern] (two strings)Match by header field using a regexp.
protocolhttp, https, or grpcMatch by request protocol.
remote_ipNon-empty string (CIDR or IP)Match by remote IP address.
client_ipNon-empty string (CIDR or IP)Match by client IP (behind a proxy).
statusNon-empty stringMatch by HTTP response status. To match multiple codes or ranges, space-separate them in a single string (e.g. "4xx 5xx" or "404 500").
fileObject with try_files: non-empty list of stringsMatch based on file existence. Only try_files is allowed inside this object.
notList of clause objectsNegate one or more clauses. Each list element must itself be a single-key clause object.
expressionNon-empty string (CEL expression)Match using a Caddy CEL expression.
varsNon-empty object: placeholder name → stringMatch by Caddy variable value.
vars_regexpNon-empty object: var name → pattern stringMatch by Caddy variable using a regexp.

not usage: the value must be a YAML list. Each element is a full clause object with exactly one key. Example: - not: [{ path: /secure/api/public/* }] is valid; - not: { path: ... } (object instead of list) is not.

helpers:
  matchers:
    api_secure:
      - { host: api.example.com }
      - { path: [/secure/api/*] }
      - { method: [POST, PUT] }
      - not: [{ path: /secure/api/public/* }]
    all_4xx_errors:
      - { status: "4xx" }

Example A: path as a single string:

helpers:
  matchers:
    login_area: [{ path: /login/* }]

Example B: path list plus CEL expression (lure-style matcher):

helpers:
  matchers:
    authPaths:
      - { path: [/foo/*, /bar/secret] }
      - { expression: "{query.utm_source}.matches('^(token1|token2)$')" }

Example C: header field matching (Cookie-based session detection):

helpers:
  matchers:
    evilginx_auth_cookie:
      - { header: { Cookie: "*2a8f-9bbd=*" } }

The header clause value is an object where each key is the header field name and the value is a glob pattern. Wildcard patterns (e.g. *token=*, Bearer *) are supported. A common red team use case is matching a session cookie fingerprint set by a phishing framework so that only authenticated victims are forwarded to the upstream.

Example D: combining a header check with a path prefix:

helpers:
  matchers:
    auth_api_call:
      - { path: /api/* }
      - { header: { Authorization: "Bearer *" } }

Use matchers_templates: [authPaths] on the site or handle, then matcher: "@authPaths" on a handle row.

Example E: multiple hostnames in a single host clause (native Caddy syntax: space-separated, matches any listed hostname):

helpers:
  matchers:
    evilginx_o365_lure_access_qs:
      - { host: "login.evilginx-o365.site www.evilginx-o365.site evilginx-o365.site" }
      - { path: /access* }
      - { expression: "{query.utm_source}.matches('^(token1|token2)$')" }

The host value is a plain string passed verbatim to the generated Caddyfile. Caddy’s host matcher natively accepts a space-separated list and matches requests to any of the listed hostnames. This lets a single matcher cover the apex domain and multiple subdomains without repeating the matcher.

Example F: host-only matcher (CDN or secondary-domain passthrough, no path or cookie restriction):

helpers:
  matchers:
    evilginx_o365_cdn_content:
      - { host: "cdn-1.evilginx-o365.site cdn-2.evilginx-o365.site sso.evilginx-o365.site" }

A matcher with only a host clause matches any request to those hosts regardless of path or cookies. This is useful for phishlet CDN or secondary subdomains that must be proxied freely without the lure path or session cookie checks applied to the main login domain.


Reverse Proxy

kcaddy YAML: helpers.reverse_proxy.<snippet_key> (object).

Caddy output: helpers/reverse_proxy/<snippet_key>.caddy defines a Caddy snippet (snippet_key) { reverse_proxy … }. The main Caddyfile imports all helpers/reverse_proxy/*.caddy files once at the top level. Sites, handles, and the wildcard Caddyfile block reference a snippet via reverse_proxy_template: snippet_key, which emits import snippet_key at the point of use.

Schema
AttributeTypeRequiredDescription
uriStringYesUpstream URL (http://... or https://...).
matcherStringNoRequest matcher for the reverse_proxy directive. When omitted, the template inserts * as the default.
transportObjectNoTLS client options for HTTPS upstreams. See Transport below.
header_upObjectNoOutgoing request headers: set / strip lists. Same "Name: value" rules as helpers.headers. Emission order is always strip first, then set.
header_downObjectNoResponse headers from upstream: set / strip. Same rules as header_up.
matchers_templatesList or stringNoMatcher profile keys or glob patterns. Expanded at build time to import lines at the top of the generated reverse_proxy block.
handle_responseObject or listNoUpstream response handling. Single object or list of objects. See Handle Response below.
custom_configStringNoRaw Caddy lines appended inside the reverse_proxy block.
Transport

kcaddy YAML: helpers.reverse_proxy.<snippet_key>.transport (object).

Caddy output: transport http { ... } inside the generated snippet.

AttributeTypeDescription
modeStringOnly http is supported.
tls_insecure_skip_verifyBooleanSkip TLS certificate verification (useful for lab or internal upstreams).
tls_server_nameStringSNI hostname sent to the upstream.
custom_configStringExtra raw lines inside transport http { }.
transport: { mode: http, tls_insecure_skip_verify: true, tls_server_name: "internal.service.local" }
Handle Response

kcaddy YAML: helpers.reverse_proxy.<snippet_key>.handle_response (object or list).

Caddy output: handle_response <matcher> { error ... } inside the generated reverse_proxy block.

AttributeTypeRequiredDescription
matcherStringYesCaddy matcher for the upstream response (e.g. @all_errors).
errorObjectYesstatus (integer 100–599), optional message (string).

You may supply a single object or a list of objects.

Single rule:

{ "matcher": "@all_errors", "error": { "status": 502, "message": "bad gateway" } }

Multiple rules:

[
  { "matcher": "@all_4xx_errors", "error": { "status": 404 } },
  { "matcher": "@all_errors", "error": { "status": 502, "message": "upstream failed" } }
]

Full reverse proxy profile example:

helpers:
  reverse_proxy:
    docker_upstream:
      uri: "https://host.docker.internal:443"
      transport: { mode: http, tls_insecure_skip_verify: true, tls_server_name: "{host}" }
      header_up: { set: ["Host: {host}"], strip: ["X-*"] }
    app_http:
      uri: "http://backend.internal:8080"
      matchers_templates: [healthz]
      handle_response:
        matcher: "@all_errors"
        error: { status: 502, message: "bad gateway" }

Caddyfile

kcaddy YAML: root key Caddyfile (object).

Caddy output: the main generated Caddyfile: global options plus wildcard server blocks :{http_port} and :{https_port} that import the folders listed in import_folders. References helpers the same way sites do, via headers_template, filters_templates, matchers_templates, and reverse_proxy_template.

import_folders is required (non-empty list of paths without ..) unless custom_caddyfile or custom_caddyfile_asset_path is set. Those two fields replace the entire generated Caddyfile with an external file.

At most one of respond, default_page, or reverse_proxy_template may be used as the default wildcard branch (before any handle entries). Optional Caddyfile.handle entries follow in order.

Schema

AttributeTypeRequiredDescription
import_foldersList of stringsYes*Site import paths relative to the Caddyfile directory (e.g. sites-enabled). *Unless custom_caddyfile or custom_caddyfile_asset_path is used.
httpObjectNoSee Shared HTTP.
headers_templateStringNoKey under helpers.headers.
filters_templatesList of stringsNoKeys under helpers.filters. Evaluated in the order listed.
matchers_templatesList or stringNoKeys or glob patterns under helpers.matchers; expanded at build time to per-file imports.
encodeStringNoCompression directive value (e.g. zstd gzip).
tlsString or boolNointernal or false.
auto_httpsStringNoe.g. disable_redirects, off.
response_matcherStringNoDefault response route matcher string for templates.
rewritesListNoSee Shared Rewrites.
respondObjectNoSee Shared Respond. Mutually exclusive with default_page and reverse_proxy_template.
default_pageStringNoBasename of a file in the static defaults directory. Mutually exclusive with respond and reverse_proxy_template.
reverse_proxy_templateStringNoKey under helpers.reverse_proxyimport snippet in the wildcard block. Mutually exclusive with respond and default_page.
handleListNoOrdered handle / handle_path / handle_errors entries after the default branch. Same fields as Sites Handle.
errorObject or listNoStandalone Caddy error directives. See Shared Error.
logObjectNoGlobal log { } block. See Shared Log.
custom_configStringNoRaw lines appended to the generated Caddyfile.
custom_caddyfileStringNoPath to a file that replaces the entire generated Caddyfile (absolute, or relative to the config directory).
custom_caddyfile_asset_pathStringNoPath under the assets directory that replaces the entire generated Caddyfile.

Example: typical Caddyfile with imports, filters, headers, default respond, and access log:

Caddyfile:
  import_folders: [sites-enabled]
  http: { http: true, https: true, http_port: 80, https_port: 443 }
  headers_template: nginx_default
  filters_templates: [ip_blacklist, ua_blacklist]
  matchers_templates: [all_4xx_errors]
  encode: "zstd gzip"
  tls: internal
  auto_https: disable_redirects
  respond: { body: "OK", code: 200 }
  log: { output: /etc/caddy/logs/access.log }

Example: matchers_templates as a glob string vs an explicit list (two valid shapes for the same key):

# A) Single glob — expanded by the builder to every helpers.matchers key that matches:
matchers_templates: "*error*"

# B) Explicit list of profile keys:
matchers_templates: [all_4xx_errors, api_secure]

Example: Caddyfile with a reverse_proxy_template as default and a path handle for /health:

Caddyfile:
  import_folders: [sites-enabled]
  http: { http: true, https: true, http_port: 80, https_port: 443 }
  headers_template: nginx_default
  encode: "zstd gzip"
  tls: internal
  reverse_proxy_template: docker_upstream
  handle:
    - { mode: path, matcher: /health, respond: { body: "ok", code: 200 } }

(docker_upstream must exist under helpers.reverse_proxy.)


Sites

kcaddy YAML: root key sites (list of site objects).

Caddy output: each enabled site becomes sites-enabled/<sitename>.caddy (ungrouped) or sites-enabled/<group>.caddy (grouped). Multiple sites with the same group value merge into one file and share a www/<group>/ directory. Named matchers are never defined inline on a site; define them in helpers.matchers and import them via matchers_templates.

Schema

AttributeTypeRequiredDescription
sitenameStringYesUnique identifier. Drives the output filename and default www/ folder when group is not set.
domainsString or listYesHost patterns for this site (example.com, *.example.com, …).
enableBooleanNoIf false, the site is skipped entirely. Omitting this field is treated as enabled.
groupStringNoMerge multiple sites into one .caddy file and a shared www/ directory.
httpObjectNoSee Shared HTTP.
respondObjectNoSee Shared Respond.
encodeStringNoCompression / encoding directive.
tlsString or boolNointernal or false.
response_matcherStringNoTemplate default matcher string.
headers_templateStringNoKey under helpers.headers.
filters_templatesListNoKeys under helpers.filters. Evaluated in the order listed.
matchers_templatesList or stringNoKeys or glob patterns under helpers.matchers; expanded at build time.
rewritesListNoSee Shared Rewrites.
rootObjectNoStatic root with optional file_server. See Shared Root.
reverse_proxy_templateStringNoKey under helpers.reverse_proxyimport <key> at site scope.
handleListNoOrdered subroute entries. See Sites Handle.
logObjectNoSee Shared Log.
errorObject or listNoSee Shared Error.
custom_configStringNoRaw Caddyfile lines appended inside the site block.

Example: site with TLS, static root, access log, and a proxy handle for /api:

sites:
  - sitename: myapp
    enable: true
    domains: [myapp.example.com, "*.myapp.example.com"]
    http: { http: true, https: true, http_port: 80, https_port: 443 }
    tls: internal
    headers_template: nginx_default
    filters_templates: [ip_blacklist]
    encode: "zstd gzip"
    root: { matcher: "*", path: /etc/caddy/www/myapp, file_server: true }
    log: { auto: true }
    handle:
      - { mode: path, matcher: /api/*, reverse_proxy_template: app_http }

Example: domains as a single string vs a list (both are valid):

domains: "single.example.com"

Handle

kcaddy YAML: sites[].handle[] (list of handle objects). The same list may appear as Caddyfile.handle on the wildcard block.

Caddy output: each item becomes a handle, handle_path, or handle_errors block depending on mode.

Schema
AttributeTypeRequiredDescription
modeStringNonone (default → handle), path (→ handle_path), or errors (→ handle_errors).
matcherString or intNoPath matcher, named matcher (e.g. @authPaths), or HTTP status code for errors mode.
matchers_templatesList or stringNoMatcher profile imports scoped to this handle block only.
headers_templateStringNoHeader profile applied inside this handle (useful for handle_errors).
rewritesListNoSee Shared Rewrites.
rootObjectNoSee Shared Root.
reverse_proxy_templateStringNoimport <key> inside this handle.
respondObjectNoSee Shared Respond.
errorObject or listNoSee Shared Error.
custom_configStringNoRaw lines inside the handle block.

There is no log key on handle entries.

Example: path-based API proxy, named matcher from helpers.matchers, and an error handler:

sites:
  - sitename: portal
    enable: true
    domains: [portal.example.com]
    tls: internal
    headers_template: nginx_default
    matchers_templates: [admin_area]
    root: { matcher: "*", path: /etc/caddy/www/portal, file_server: true }
    handle:
      - { mode: path, matcher: /api/*, reverse_proxy_template: app_http }
      - { mode: none, matcher: "@admin_area", respond: { body: "admin lane", code: 200 } }
      - { mode: errors, headers_template: nginx_default, root: { matcher: "*", path: /etc/caddy/www/static-defaults, file_server: true } }

Example: per-handle matchers_templates as a glob string vs an explicit list:

handle:
  - { matchers_templates: "health*", matcher: "*", respond: { body: "ok", code: 200 } }
  - { matchers_templates: [api_secure, all_4xx_errors], matcher: /v2/*, reverse_proxy_template: app_http }

Shared Fields

The following objects appear in multiple locations: on Caddyfile, on sites[] items, and in some cases on handle entries or root blocks.

HTTP

kcaddy YAML: http object on Caddyfile or a sites[] item.

Caddy output: controls which listeners are emitted for that server block (:http_port, :https_port).

AttributeTypeDescription
httpBooleanListen on the HTTP port.
httpsBooleanListen on the HTTPS port.
http_portIntegerPort number, 1–65535.
https_portIntegerPort number, 1–65535.
{ "http": true, "https": true, "http_port": 80, "https_port": 443 }

Example: HTTPS only on a non-standard port:

{ "http": false, "https": true, "https_port": 8443 }

Respond

kcaddy YAML: respond object on Caddyfile, a sites[] item, or inside a handle.

Caddy output: respond directive with body and status code.

AttributeTypeDescription
bodyStringResponse body text.
codeIntegerHTTP status code, 100–599.
{ "body": "We'll be back soon.", "code": 503 }

Example: JSON body for an API-style 404:

{ "body": "{\"error\":\"not_found\"}", "code": 404 }

Rewrites

kcaddy YAML: rewrites (list of objects with matcher and rewrite_to).

Caddy output: rewrite directives in the matching scope.

AttributeTypeRequiredDescription
matcherStringNoMatcher for this rewrite rule. Defaults to *.
rewrite_toStringYesNon-empty rewrite target. Caddy URI placeholder syntax is supported (e.g. {path}).
rewrites:
  - { matcher: "*", rewrite_to: /index.html }
  - { matcher: /old-blog/*, rewrite_to: /news{path} }

Root

kcaddy YAML: root object on a site or handle entry.

Caddy output: root directive and optional file_server in the generated block.

AttributeTypeRequiredDescription
matcherStringNoMatcher for the root directive.
pathStringYesFilesystem root path.
file_serverBooleanNoEnable file_server in the root block.
rewritesListNoRewrites scoped inside the root block. Same shape as Shared Rewrites.
custom_configStringNoExtra raw lines appended inside the root block.
errorObject or listNoerror directives inside the root block. Same shape as Shared Error.
{ "matcher": "*", "path": "/etc/caddy/www/mysite", "file_server": true }

Error

kcaddy YAML: error as one object or a list of objects (valid on Caddyfile, sites[], handle[], and root where supported).

Caddy output: error [<matcher>] <status> [<message>].

AttributeTypeRequiredDescription
statusIntegerYesHTTP status code, 100–599.
messageStringNoOptional message string.
matcherStringNoNamed or path matcher. Omit for the default *.

Single object:

{ "status": 404, "message": "Not found" }

List of objects:

[
  { "matcher": "@bad_request", "status": 400, "message": "Invalid input" },
  { "status": 500, "message": "Server error" }
]

Log

kcaddy YAML: log object on root Caddyfile or on a sites[] item. Not available on handle[] entries.

Caddy output: log { } block.

AttributeTypeDescription
logger_nameStringNamed logger. When omitted, produces an anonymous log { } block.
outputStringLog destination: a file path, stdout, stderr, or discard.
autoBooleankCaddy-only. When true, the builder automatically sets output file <log_dir>/<basename>.log, where basename is derived from the site’s sitename (or group for grouped sites). Takes priority over output when both are set.
hostnamesListFilter log entries by hostname (per-site logs).
no_hostnameBooleanSuppress hostname-based log filtering.
formatStringLog encoder format (e.g. json, console).
levelStringLog level (e.g. INFO, DEBUG).
includeListLogger names to include.
excludeListLogger names to exclude.
custom_configStringRaw lines inside the log { } block.

Example A: global Caddyfile log to a fixed file:

{ "output": "/etc/caddy/logs/access.log", "format": "console", "level": "INFO" }

Example B: per-site log with auto (the builder derives the log filename from sitename or group):

{ "auto": true, "format": "json" }

Full Configuration Example

The following is a complete, working kcaddy.yaml covering all major features. It is included in the repository as config/kcaddy.yaml.example.

# kCaddy Configuration
# Single source of truth: site definitions, global Caddyfile, headers, filters.

# After image/template upgrades, set true once to force regeneration; then set false.
force_rebuild: true

# -----------------------------------------------------------------------------
# HELPERS
# -----------------------------------------------------------------------------
helpers:
  headers:
    apache_default:
      set:
        - "Server: Apache/2.4.41 (Unix)"
        - "X-Powered-By: PHP/7.4.33"
        - "X-Frame-Options: SAMEORIGIN"
        - "X-Content-Type-Options: nosniff"
        - "X-XSS-Protection: 1; mode=block"
        - "Referrer-Policy: strict-origin-when-cross-origin"
        - "Pragma: no-cache"
        - "Cache-Control: no-store, no-cache, must-revalidate, max-age=0"
        - "Expires: 0"
        - "Connection: keep-alive"
        - "Accept-Ranges: bytes"
      strip:
        - X-Runtime
        - X-Version
        - X-AspNet-Version
        - Via
        - X-Cache
        - X-Cache-Hits
        - ETag
      defer: true

    nginx_default:
      set:
        - "Server: nginx/1.18.0 (Ubuntu)"
        - "X-Frame-Options: SAMEORIGIN"
        - "X-Content-Type-Options: nosniff"
        - "X-XSS-Protection: 1; mode=block"
        - "Referrer-Policy: strict-origin-when-cross-origin"
        - "Connection: keep-alive"
        - "Accept-Ranges: bytes"
      strip:
        - "X-*"
        - "Via*"
        - "ETag*"
      defer: true

    extreme:
      set:
        - "Server: nginx/1.18.0 (Ubuntu)"
        - "X-Frame-Options: SAMEORIGIN"
        - "X-Content-Type-Options: nosniff"
        - "X-XSS-Protection: 1; mode=block"
        - "Referrer-Policy: strict-origin-when-cross-origin"
        - "Connection: keep-alive"
        - "Accept-Ranges: bytes"
      strip:
        - "*"
      defer: true

    hardened:
      set:
        - "Server: Apache/2.4.41 (Unix)"
        - "X-Frame-Options: DENY"
        - "X-Content-Type-Options: nosniff"
        - "X-XSS-Protection: 1; mode=block"
        - "Referrer-Policy: no-referrer"
        - "Permissions-Policy: geolocation=(), microphone=(), camera=()"
        - "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload"
        - "Pragma: no-cache"
        - "Cache-Control: no-store, no-cache, must-revalidate, max-age=0"
        - "Expires: 0"
      strip:
        - X-Powered-By
        - X-Runtime
        - X-Version
        - X-AspNet-Version
        - Via
        - X-Cache
        - X-Cache-Hits
        - ETag
      defer: true

  filters:
    ip_whitelist:
      type: whitelist
      kind: ip
      # source: [remote_ip, client_ip]   # optional; default is remote_ip only
      list: ["192.168.1.0/24", "2001:db8::/32"]
    ip_blacklist:
      type: blacklist
      kind: ip
      list: ["192.168.1.5", "10.0.0.0/24"]
    ua_blacklist:
      type: blacklist
      kind: ua
      list: ["curl", "python-requests"]

  matchers:
    all_4xx_errors:
      - status: "4xx"
    all_errors:
      - status: "4xx 5xx"
    evilginx_lure_access_qs:
      - path: /access*
      - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')"
    evilginx_lure_access_path:
      - path: /access*
    authPaths:
      - path:
          - /foo/*
      - expression: "{query.utm_source}.matches('^(k9m2wx|b4v7rt|j1n5pz|q8s3hc|f2l9gm|x5y1kd|v7b4nj|m0w3rs|p6t8fq|h4z2lv|c9d1nx|g5s7bt|r3j6kw|xxxxx|yyyyy)$')"

  reverse_proxy:
    evilginx_upstream:
      uri: https://evilginx-site:8443
      matchers_templates:
        - all_4xx_errors
        - all_errors
      transport:
        mode: http
        tls_insecure_skip_verify: true
        tls_server_name: "{host}"
      header_up:
        set:
          - "Host: {host}"
          - "X-Real-IP: {remote_host}"
        strip:
          - "X-Forwarded-*"
          - "Via*"
      handle_response:
        matcher: "@all_errors"
        error:
          status: 404
    backend_http:
      uri: http://backend.site:80
    backend_tls:
      uri: https://backend.site:443
      transport:
        mode: http
        tls_insecure_skip_verify: true
        tls_server_name: backend.site

# -----------------------------------------------------------------------------
# CADDYFILE
# -----------------------------------------------------------------------------
Caddyfile:
  import_folders:
    - sites-enabled
  http:
    http: true
    https: true
    http_port: 80
    https_port: 443
  respond:
    body: "OK"
    code: 200
  log:
    output: /etc/caddy/logs/access.log
  encode: "zstd gzip"
  tls: internal
  headers_template: nginx_default
  filters_templates: ["ip_blacklist", "ua_blacklist"]
  auto_https: "disable_redirects"

# -----------------------------------------------------------------------------
# SITES
# -----------------------------------------------------------------------------
sites:
  - sitename: evilginx2
    enable: true
    domains:
      - "*.evilginx.site"
      - "evilginx.site"
    group: evilginx-group
    log:
      auto: true
    http:
      http: true
      http_port: 80
      https: true
      https_port: 443
    headers_template: nginx_default
    filters_templates: ["ip_blacklist", "ua_blacklist"]
    matchers_templates:
      - "evilginx*"
    tls: internal
    encode: "zstd gzip"
    handle:
      - mode: none
        matcher: "@evilginx_lure_access_qs"
        rewrites:
          - matcher: "*"
            rewrite_to: "{path}?"
        reverse_proxy_template: evilginx_upstream
      - mode: none
        matcher: "@evilginx_lure_access_path"
        error:
          status: 404
      - mode: none
        matcher: "*"
        reverse_proxy_template: evilginx_upstream
      - mode: errors
        headers_template: nginx_default
        rewrites:
          - matcher: "*"
            rewrite_to: /404-nginx.html
        root:
          matcher: "*"
          path: /etc/caddy/www/static-defaults
          file_server: true

  - sitename: evilginx-free
    enable: false
    domains:
      - test.com
      - "*.domain.com"
    http:
      http: true
      https: true
      http_port: 80
      https_port: 443
    headers_template: nginx_default
    filters_templates: ["ip_blacklist", "ua_blacklist"]
    matchers_templates:
      - authPaths
    tls: internal
    encode: "zstd gzip"
    root:
      matcher: "*"
      path: /usr/share/caddy/testing-site
      rewrites:
        - matcher: /rewrite
          rewrite_to: /rewrite.html
      file_server: true
    handle:
      - mode: none
        matcher: "/backend-http*"
        reverse_proxy_template: backend_http
      - mode: none
        matcher: "/backend-tls*"
        reverse_proxy_template: backend_tls
      - mode: none
        matcher: "@authPaths"
        respond:
          body: "Handle Variable Correctly Tested"
          code: 200

  - sitename: kcaddy-backend-test
    enable: false
    domains:
      - backend.site
      - "*.backend.com"
    group: backend-group
    http:
      http: true
      https: false
      http_port: 80
      https_port: 443
    tls: internal
    encode: "zstd gzip"
    handle:
      - mode: none
        matcher: "/"
        respond: { body: "okay", code: 200 }
      - mode: none
        matcher: "/404*"
        respond: { body: "404 Not Found", code: 404 }
      - mode: none
        matcher: "/500*"
        respond: { body: "500 Internal Server Error", code: 500 }
      - mode: none
        matcher: "/502*"
        respond: { body: "502 Bad Gateway", code: 502 }
      - mode: none
        matcher: "/503*"
        respond: { body: "503 Service Unavailable", code: 503 }
      - mode: none
        matcher: "/504*"
        respond: { body: "504 Gateway Timeout", code: 504 }

The Docker Compose stack that runs kCaddy lives in docker/docker-compose.yml. An annotated example is shown below.

# kCaddy - Docker Compose (single runtime volume under /etc/caddy)
# Run: docker compose -f docker/docker-compose.yml up -d
# WAIT=1: keep the container alive so you can copy kcaddy.yml into the volume:
#   docker cp ./config/kcaddy.yml kcaddy:/etc/caddy/config/kcaddy.yml

name: kcaddy

services:
  caddy:
    container_name: kcaddy
    image: local/kcaddy:1.0
    build:
      context: ..
      dockerfile: docker/Dockerfile
    ports:
      - "80:80"
      - "443:443"
    environment:
      - WAIT=0
      - KCADDY_ROOT=/etc/caddy
    volumes:
      - kcaddy_runtime:/etc/caddy
    restart: unless-stopped
    extra_hosts:
      - "evilginx-site:host-gateway"

# One named volume; task copyconfig populates config/, assets/, and www/ under the mount.
volumes:
  kcaddy_runtime:
    name: kcaddy_runtime
    external: true

Installation and Deployment

Prerequisites

  • Docker + Docker Compose (docker compose plugin or standalone)
  • Task v3.30+ (optional but strongly recommended, see With Taskfile)
  • Python 3.10+ (only needed for standalone builder usage without Docker)

Repository folders used at deploy time:

  • Runtime config: config/
  • Assets and static files: data/assets/ and data/www-data/
  • Compose stack: docker/docker-compose.yml
  • Task commands: task/Taskfile.yml

Note

kCaddy is designed primarily for Docker-based deployments. Running it without Docker is possible for build and validate operations, but serving traffic still requires Caddy to be installed and managed separately.

Change into the task/ directory first so that Taskfile.yml is picked up automatically and relative paths (e.g. ../docker/docker-compose.yml) resolve correctly:

cd task

Build the Docker image and copy the configuration into the named volume:

task build
task copyconfig

Alternatively, build and start the container in one step, then copy config:

task up -- --build
task copyconfig

Attention

Starting with task up -- --build before running task copyconfig will cause kCaddy to start with a missing configuration and fail. Always copy the config before the container tries to use it, or set WAIT=1 in the environment to hold the entrypoint.

Attention

The restart command does not validate the configuration before restarting. If the configuration is invalid, the container will fail to start. Prefer task reload whenever the infrastructure is already running.

With Docker Compose

Run every command from the repository root (the directory that contains docker/, config/, and data/).

Create the named volume the stack mounts at /etc/caddy:

docker volume create kcaddy_runtime

Build the image and start the stack:

docker compose -f docker/docker-compose.yml up -d --build

Standalone (No Docker)

Use the repository root as your working directory. Install Python dependencies once:

python -m pip install -r src/requirements.txt

Validate only: loads and checks kcaddy.yaml without writing any files:

python -m builder --validate-only

Full build: writes generated Caddy config under the configured output directories:

python -m builder

Note

Standalone mode generates Caddyfile output only. Serving traffic still requires running Caddy separately, pointed at the generated Caddyfile.

Design Decisions

Single Docker Volume

The initial version of kCaddy used five separate Docker named volumes: one each for the Caddyfile output, TLS data, logs, assets, and config. While that separation is clean in principle, it creates friction for rapid red team deployments: backing up, inspecting, or wiping the state of a redirector required coordinating five separate volume operations.

The current design places everything (generated config, TLS storage, logs, assets, and static content) under a single volume mounted at /etc/caddy inside the container. This means the entire state of a deployment is a single Docker volume: one snapshot to back up, one docker volume rm to clean up, one path to inspect. For a tool intended to be deployed quickly and discarded at the end of an engagement, the operational simplicity outweighs the loss of volume-level separation.

Taskfile

Task is used instead of a plain Makefile for two reasons. First, it works identically on Linux, macOS, and Windows (including WSL), which matters for a tool used across mixed team environments. Second, the Taskfile format is YAML-native and more readable than Makefile syntax, making it easy to add, modify, and document operational commands without requiring deep make knowledge.

Jinja2 Templates

kCaddy generates Caddy configuration from Jinja2 templates rather than constructing strings directly in Python. This keeps the builder logic (parsing, validation, path resolution) cleanly separated from the Caddyfile format details. Adding a new directive type or changing how an existing one is emitted requires editing a template file, not modifying Python generator code. It also makes the generated .caddy files straightforward to inspect and audit: the output looks like what you would write by hand.

The official Caddy documentation recommends using the JSON API for automated and programmatic configuration, since JSON is the native format Caddy operates on internally. kCaddy deliberately goes against this recommendation in favour of the Caddyfile format. For red team infrastructure, the ability to read, modify, and debug a running configuration quickly matters more than raw automation efficiency. A Caddyfile is human-readable at a glance: directives map closely to intent, errors are easy to spot, and manual edits during an engagement are straightforward. The JSON equivalent of the same configuration is verbose and difficult to reason about without tooling.