- 01 Overview
- 02 Project Structure
- 03 GitHub Repository Layout
- 04 Docker Volume Layout
- 05 kcaddy.yaml Reference
- 06 Root-Level Keys
- 07 Schema
- 08 force_rebuild
- 09 Paths
- 10 Helpers
- 11 Schema
- 12 Headers
- 13 Filters
- 14 Matchers
- 15 Reverse Proxy
- 16 Caddyfile
- 17 Schema
- 18 Sites
- 19 Schema
- 20 Handle
- 21 Shared Fields
- 22 HTTP
- 23 Respond
- 24 Rewrites
- 25 Root
- 26 Error
- 27 Log
- 28 Full Configuration Example
- 29 Installation and Deployment
- 30 Prerequisites
- 31 With Taskfile (Recommended)
- 32 With Docker Compose
- 33 Standalone (No Docker)
- 34 Design Decisions
- 35 Single Docker Volume
- 36 Taskfile
- 37 Jinja2 Templates
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:
- The builder parses and validates
kcaddy.yamlagainst a strict Pydantic schema (extra="forbid"on all models; unknown keys are rejected at startup). - Jinja2 templates are rendered into
.caddysnippet files for headers, filters, matchers, and reverse proxy profiles, organized underhelpers/. - A main
Caddyfileis generated that imports those helpers and thesites-enabled/directory. - Caddy reads the generated
Caddyfileand 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
| Key | Type | Default | Description |
|---|---|---|---|
| force_rebuild | Boolean | false | Force 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. |
| paths | Object | - | Optional override for runtime directory layout. See paths below. |
| helpers | Object | - | Required. Header, filter, matcher, and reverse-proxy profiles. |
| Caddyfile | Object | - | Optional. Global Caddyfile wildcard block configuration. |
| sites | List | - | 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.
- Compact
- Expanded
force_rebuild: trueforce_rebuild: true# Force full regeneration on next start or reload:
force_rebuild: true
# Normal run — skip regeneration if the config hash is unchanged:
# force_rebuild: falsePaths
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
| Key | Type | Default segment | Description |
|---|---|---|---|
| root | String | n/a | Absolute path override for the Caddy output root (e.g. /etc/caddy). Must start with /. |
| config_dir | String | config | Subdirectory name for the config file location. |
| www | String | www | Subdirectory name for static web content. |
| log | String | logs | Subdirectory name for log files. This is a directory segment name, not a Caddy log {} block. |
| storage | String | caddy-storage | Subdirectory name for Caddy’s internal TLS/ACME storage (set via XDG_DATA_HOME). |
| assets | String | assets | Subdirectory name for operator assets (IP lists, UA lists, static defaults). |
- Compact
- Expanded
# Full explicit layout (same as the defaults):
paths:
root: /etc/caddy
config_dir: config
www: www
log: logs
storage: caddy-storage
assets: assets# Full explicit layout (same as the defaults):
paths:
root: /etc/caddy
config_dir: config
www: www
log: logs
storage: caddy-storage
assets: assets# Override only what differs from the defaults:
paths:
root: /etc/caddy
log: access-logs # /etc/caddy/logs → /etc/caddy/access-logsHelpers
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
| Key | Type | Description |
|---|---|---|
| headers | Map (name → Header profile) | Optional. Each key → helpers/headers/<key>.caddy. |
| filters | Map (name → Filter profile) | Optional. Generated only when the profile name appears in filters_templates. |
| matchers | Map (name → Matcher profile) | Optional. Every key → helpers/matchers/<key>.caddy on each build, regardless of whether it is referenced. |
| reverse_proxy | Map (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:
- Compact
- Expanded
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" }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" }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"
# Elsewhere in kcaddy.yaml:
# Caddyfile: headers_template: nginx_default
# filters_templates: [ip_blacklist]
# sites[]: reverse_proxy_template: app
# matchers_templates: [api_only]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
| Attribute | Type | Allowed values | Description |
|---|---|---|---|
| set | List of strings | Each item: "Name: value" | Colon + space (": ") separates name from value. The builder splits on the first occurrence and emits header Name "value" in the Caddyfile. |
| strip | List of strings | Non-empty names or patterns | Remove response headers. Wildcard-style patterns (e.g. X-*, Via*) are supported as Caddy allows. |
| defer | Boolean | true / false | When 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:
- Compact
- Expanded
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: truehelpers:
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: truehelpers:
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: trueExample: strip-only profile (useful when the upstream already sends safe headers):
- Compact
- Expanded
helpers:
headers:
strip_upstream_leaks: { strip: ["X-Powered-By", "Server"], defer: true }helpers:
headers:
strip_upstream_leaks: { strip: ["X-Powered-By", "Server"], defer: true }helpers:
headers:
strip_upstream_leaks:
strip:
- "X-Powered-By"
- "Server"
defer: trueFilters
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
| Attribute | Type | Allowed values | Description |
|---|---|---|---|
| type | String | blacklist, whitelist | blacklist → deny if matched. whitelist → allow only if matched. |
| kind | String | ip, ua | ip: CIDR or IP strings. ua: User-Agent substrings compiled into a regex pattern by the builder. |
| list | List of strings | Non-empty entries | Inline list. Merged with list_file contents when both are present. |
| list_file | String | Path relative to assets root | Entries loaded from a file (one per line; lines starting with # are ignored). Merged with list. |
| source | String or list of strings | IP 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.
- Compact
- Expanded
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"] }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"] }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):
- Compact
- Expanded
helpers:
filters:
ua_bad_only_file: { type: blacklist, kind: ua, list_file: ua/global_bad_ua.txt }helpers:
filters:
ua_bad_only_file: { type: blacklist, kind: ua, list_file: ua/global_bad_ua.txt }helpers:
filters:
ua_bad_only_file:
type: blacklist
kind: ua
list_file: ua/global_bad_ua.txtMatchers
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)
| Form | Type | Description |
|---|---|---|
| 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:
| Clause | YAML value type | Description |
|---|---|---|
| host | Non-empty string | Match 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. |
| path | Non-empty string | Match 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_regexp | Object: regexp name → pattern string | Match by path regular expression. Each entry becomes path_regexp <name> <pattern>. |
| query | Non-empty string | Match by query string. |
| method | Non-empty string | Match by HTTP method. To match multiple methods, space-separate them in a single string (e.g. "GET POST DELETE"). |
| header | Object: field name → string value | Match by request header field and value. |
| header_regexp | Object: name → [field, pattern] (two strings) | Match by header field using a regexp. |
| protocol | http, https, or grpc | Match by request protocol. |
| remote_ip | Non-empty string (CIDR or IP) | Match by remote IP address. |
| client_ip | Non-empty string (CIDR or IP) | Match by client IP (behind a proxy). |
| status | Non-empty string | Match by HTTP response status. To match multiple codes or ranges, space-separate them in a single string (e.g. "4xx 5xx" or "404 500"). |
| file | Object with try_files: non-empty list of strings | Match based on file existence. Only try_files is allowed inside this object. |
| not | List of clause objects | Negate one or more clauses. Each list element must itself be a single-key clause object. |
| expression | Non-empty string (CEL expression) | Match using a Caddy CEL expression. |
| vars | Non-empty object: placeholder name → string | Match by Caddy variable value. |
| vars_regexp | Non-empty object: var name → pattern string | Match 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.
- Compact
- Expanded
helpers:
matchers:
api_secure:
- { host: api.example.com }
- { path: [/secure/api/*] }
- { method: [POST, PUT] }
- not: [{ path: /secure/api/public/* }]
all_4xx_errors:
- { status: "4xx" }helpers:
matchers:
api_secure:
- { host: api.example.com }
- { path: [/secure/api/*] }
- { method: [POST, PUT] }
- not: [{ path: /secure/api/public/* }]
all_4xx_errors:
- { status: "4xx" }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:
- Compact
- Expanded
helpers:
matchers:
login_area: [{ path: /login/* }]helpers:
matchers:
login_area: [{ path: /login/* }]helpers:
matchers:
login_area:
- path: /login/*Example B: path list plus CEL expression (lure-style matcher):
- Compact
- Expanded
helpers:
matchers:
authPaths:
- { path: [/foo/*, /bar/secret] }
- { expression: "{query.utm_source}.matches('^(token1|token2)$')" }helpers:
matchers:
authPaths:
- { path: [/foo/*, /bar/secret] }
- { expression: "{query.utm_source}.matches('^(token1|token2)$')" }helpers:
matchers:
authPaths:
- path:
- /foo/*
- /bar/secret
- expression: "{query.utm_source}.matches('^(token1|token2)$')"Example C: header field matching (Cookie-based session detection):
- Compact
- Expanded
helpers:
matchers:
evilginx_auth_cookie:
- { header: { Cookie: "*2a8f-9bbd=*" } }helpers:
matchers:
evilginx_auth_cookie:
- { header: { Cookie: "*2a8f-9bbd=*" } }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:
- Compact
- Expanded
helpers:
matchers:
auth_api_call:
- { path: /api/* }
- { header: { Authorization: "Bearer *" } }helpers:
matchers:
auth_api_call:
- { path: /api/* }
- { header: { Authorization: "Bearer *" } }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):
- Compact
- Expanded
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)$')" }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)$')" }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):
- Compact
- Expanded
helpers:
matchers:
evilginx_o365_cdn_content:
- { host: "cdn-1.evilginx-o365.site cdn-2.evilginx-o365.site sso.evilginx-o365.site" }helpers:
matchers:
evilginx_o365_cdn_content:
- { host: "cdn-1.evilginx-o365.site cdn-2.evilginx-o365.site sso.evilginx-o365.site" }helpers:
matchers:
evilginx_o365_cdn_content:
- host: cdn-1.evilginx-o365.site cdn-2.evilginx-o365.site sso.evilginx-o365.siteA 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
| Attribute | Type | Required | Description |
|---|---|---|---|
| uri | String | Yes | Upstream URL (http://... or https://...). |
| matcher | String | No | Request matcher for the reverse_proxy directive. When omitted, the template inserts * as the default. |
| transport | Object | No | TLS client options for HTTPS upstreams. See Transport below. |
| header_up | Object | No | Outgoing request headers: set / strip lists. Same "Name: value" rules as helpers.headers. Emission order is always strip first, then set. |
| header_down | Object | No | Response headers from upstream: set / strip. Same rules as header_up. |
| matchers_templates | List or string | No | Matcher profile keys or glob patterns. Expanded at build time to import lines at the top of the generated reverse_proxy block. |
| handle_response | Object or list | No | Upstream response handling. Single object or list of objects. See Handle Response below. |
| custom_config | String | No | Raw 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.
| Attribute | Type | Description |
|---|---|---|
| mode | String | Only http is supported. |
| tls_insecure_skip_verify | Boolean | Skip TLS certificate verification (useful for lab or internal upstreams). |
| tls_server_name | String | SNI hostname sent to the upstream. |
| custom_config | String | Extra raw lines inside transport http { }. |
- Compact
- Expanded
transport: { mode: http, tls_insecure_skip_verify: true, tls_server_name: "internal.service.local" }transport: { mode: http, tls_insecure_skip_verify: true, tls_server_name: "internal.service.local" }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.
| Attribute | Type | Required | Description |
|---|---|---|---|
| matcher | String | Yes | Caddy matcher for the upstream response (e.g. @all_errors). |
| error | Object | Yes | status (integer 100–599), optional message (string). |
You may supply a single object or a list of objects.
- Compact
- Expanded
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" } }
]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" } }
]# Single upstream-response rule:
handle_response:
matcher: "@all_errors"
error:
status: 502
message: "bad gateway"
# Multiple rules:
handle_response:
- matcher: "@all_4xx_errors"
error: { status: 404 }
- matcher: "@all_errors"
error:
status: 502
message: "upstream failed"Full reverse proxy profile example:
- Compact
- Expanded
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" }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" }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
| Attribute | Type | Required | Description |
|---|---|---|---|
| import_folders | List of strings | Yes* | Site import paths relative to the Caddyfile directory (e.g. sites-enabled). *Unless custom_caddyfile or custom_caddyfile_asset_path is used. |
| http | Object | No | See Shared HTTP. |
| headers_template | String | No | Key under helpers.headers. |
| filters_templates | List of strings | No | Keys under helpers.filters. Evaluated in the order listed. |
| matchers_templates | List or string | No | Keys or glob patterns under helpers.matchers; expanded at build time to per-file imports. |
| encode | String | No | Compression directive value (e.g. zstd gzip). |
| tls | String or bool | No | internal or false. |
| auto_https | String | No | e.g. disable_redirects, off. |
| response_matcher | String | No | Default response route matcher string for templates. |
| rewrites | List | No | See Shared Rewrites. |
| respond | Object | No | See Shared Respond. Mutually exclusive with default_page and reverse_proxy_template. |
| default_page | String | No | Basename of a file in the static defaults directory. Mutually exclusive with respond and reverse_proxy_template. |
| reverse_proxy_template | String | No | Key under helpers.reverse_proxy → import snippet in the wildcard block. Mutually exclusive with respond and default_page. |
| handle | List | No | Ordered handle / handle_path / handle_errors entries after the default branch. Same fields as Sites Handle. |
| error | Object or list | No | Standalone Caddy error directives. See Shared Error. |
| log | Object | No | Global log { } block. See Shared Log. |
| custom_config | String | No | Raw lines appended to the generated Caddyfile. |
| custom_caddyfile | String | No | Path to a file that replaces the entire generated Caddyfile (absolute, or relative to the config directory). |
| custom_caddyfile_asset_path | String | No | Path under the assets directory that replaces the entire generated Caddyfile. |
Example: typical Caddyfile with imports, filters, headers, default respond, and access log:
- Compact
- Expanded
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 }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 }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.logExample: matchers_templates as a glob string vs an explicit list (two valid shapes for the same key):
- Compact
- Expanded
# 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]# 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]# A) Glob:
matchers_templates: "*error*"
# B) List:
matchers_templates:
- all_4xx_errors
- api_secureExample: Caddyfile with a reverse_proxy_template as default and a path handle for /health:
- Compact
- Expanded
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 } }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 } }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
| Attribute | Type | Required | Description |
|---|---|---|---|
| sitename | String | Yes | Unique identifier. Drives the output filename and default www/ folder when group is not set. |
| domains | String or list | Yes | Host patterns for this site (example.com, *.example.com, …). |
| enable | Boolean | No | If false, the site is skipped entirely. Omitting this field is treated as enabled. |
| group | String | No | Merge multiple sites into one .caddy file and a shared www/ directory. |
| http | Object | No | See Shared HTTP. |
| respond | Object | No | See Shared Respond. |
| encode | String | No | Compression / encoding directive. |
| tls | String or bool | No | internal or false. |
| response_matcher | String | No | Template default matcher string. |
| headers_template | String | No | Key under helpers.headers. |
| filters_templates | List | No | Keys under helpers.filters. Evaluated in the order listed. |
| matchers_templates | List or string | No | Keys or glob patterns under helpers.matchers; expanded at build time. |
| rewrites | List | No | See Shared Rewrites. |
| root | Object | No | Static root with optional file_server. See Shared Root. |
| reverse_proxy_template | String | No | Key under helpers.reverse_proxy → import <key> at site scope. |
| handle | List | No | Ordered subroute entries. See Sites Handle. |
| log | Object | No | See Shared Log. |
| error | Object or list | No | See Shared Error. |
| custom_config | String | No | Raw Caddyfile lines appended inside the site block. |
Example: site with TLS, static root, access log, and a proxy handle for /api:
- Compact
- Expanded
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 }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 }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_httpExample: domains as a single string vs a list (both are valid):
- String
- Compact
- List
domains: "single.example.com"domains: "single.example.com"["single.example.com", "*.single.example.com"]domains:
- single.example.com
- "*.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
| Attribute | Type | Required | Description |
|---|---|---|---|
| mode | String | No | none (default → handle), path (→ handle_path), or errors (→ handle_errors). |
| matcher | String or int | No | Path matcher, named matcher (e.g. @authPaths), or HTTP status code for errors mode. |
| matchers_templates | List or string | No | Matcher profile imports scoped to this handle block only. |
| headers_template | String | No | Header profile applied inside this handle (useful for handle_errors). |
| rewrites | List | No | See Shared Rewrites. |
| root | Object | No | See Shared Root. |
| reverse_proxy_template | String | No | import <key> inside this handle. |
| respond | Object | No | See Shared Respond. |
| error | Object or list | No | See Shared Error. |
| custom_config | String | No | Raw 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:
- Compact
- Expanded
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 } }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 } }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: trueExample: per-handle matchers_templates as a glob string vs an explicit list:
- Compact
- Expanded
handle:
- { matchers_templates: "health*", matcher: "*", respond: { body: "ok", code: 200 } }
- { matchers_templates: [api_secure, all_4xx_errors], matcher: /v2/*, reverse_proxy_template: app_http }handle:
- { matchers_templates: "health*", matcher: "*", respond: { body: "ok", code: 200 } }
- { matchers_templates: [api_secure, all_4xx_errors], matcher: /v2/*, reverse_proxy_template: app_http }handle:
- matchers_templates: "health*"
matcher: "*"
respond:
body: "ok"
code: 200
- matchers_templates:
- api_secure
- all_4xx_errors
matcher: /v2/*
reverse_proxy_template: app_httpShared 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).
| Attribute | Type | Description |
|---|---|---|
| http | Boolean | Listen on the HTTP port. |
| https | Boolean | Listen on the HTTPS port. |
| http_port | Integer | Port number, 1–65535. |
| https_port | Integer | Port number, 1–65535. |
- Compact
- Expanded
{ "http": true, "https": true, "http_port": 80, "https_port": 443 }{ "http": true, "https": true, "http_port": 80, "https_port": 443 }http:
http: true
https: true
http_port: 80
https_port: 443Example: HTTPS only on a non-standard port:
- Compact
- Expanded
{ "http": false, "https": true, "https_port": 8443 }{ "http": false, "https": true, "https_port": 8443 }http:
http: false
https: true
https_port: 8443Respond
kcaddy YAML: respond object on Caddyfile, a sites[] item, or inside a handle.
Caddy output: respond directive with body and status code.
| Attribute | Type | Description |
|---|---|---|
| body | String | Response body text. |
| code | Integer | HTTP status code, 100–599. |
- Compact
- Expanded
{ "body": "We'll be back soon.", "code": 503 }{ "body": "We'll be back soon.", "code": 503 }respond:
body: "We'll be back soon."
code: 503Example: JSON body for an API-style 404:
- Compact
- Expanded
{ "body": "{\"error\":\"not_found\"}", "code": 404 }{ "body": "{\"error\":\"not_found\"}", "code": 404 }respond:
body: '{"error":"not_found"}'
code: 404Rewrites
kcaddy YAML: rewrites (list of objects with matcher and rewrite_to).
Caddy output: rewrite directives in the matching scope.
| Attribute | Type | Required | Description |
|---|---|---|---|
| matcher | String | No | Matcher for this rewrite rule. Defaults to *. |
| rewrite_to | String | Yes | Non-empty rewrite target. Caddy URI placeholder syntax is supported (e.g. {path}). |
- Compact
- Expanded
rewrites:
- { matcher: "*", rewrite_to: /index.html }
- { matcher: /old-blog/*, rewrite_to: /news{path} }rewrites:
- { matcher: "*", rewrite_to: /index.html }
- { matcher: /old-blog/*, rewrite_to: /news{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.
| Attribute | Type | Required | Description |
|---|---|---|---|
| matcher | String | No | Matcher for the root directive. |
| path | String | Yes | Filesystem root path. |
| file_server | Boolean | No | Enable file_server in the root block. |
| rewrites | List | No | Rewrites scoped inside the root block. Same shape as Shared Rewrites. |
| custom_config | String | No | Extra raw lines appended inside the root block. |
| error | Object or list | No | error directives inside the root block. Same shape as Shared Error. |
- Compact
- Expanded
{ "matcher": "*", "path": "/etc/caddy/www/mysite", "file_server": true }{ "matcher": "*", "path": "/etc/caddy/www/mysite", "file_server": true }root:
matcher: "*"
path: /etc/caddy/www/mysite
file_server: trueError
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>].
| Attribute | Type | Required | Description |
|---|---|---|---|
| status | Integer | Yes | HTTP status code, 100–599. |
| message | String | No | Optional message string. |
| matcher | String | No | Named or path matcher. Omit for the default *. |
- Compact
- Expanded
Single object:
{ "status": 404, "message": "Not found" }List of objects:
[
{ "matcher": "@bad_request", "status": 400, "message": "Invalid input" },
{ "status": 500, "message": "Server error" }
]Single object:
{ "status": 404, "message": "Not found" }List of objects:
[
{ "matcher": "@bad_request", "status": 400, "message": "Invalid input" },
{ "status": 500, "message": "Server error" }
]# Single directive:
error:
status: 404
message: "Not found"
# Multiple directives:
error:
- 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.
| Attribute | Type | Description |
|---|---|---|
| logger_name | String | Named logger. When omitted, produces an anonymous log { } block. |
| output | String | Log destination: a file path, stdout, stderr, or discard. |
| auto | Boolean | kCaddy-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. |
| hostnames | List | Filter log entries by hostname (per-site logs). |
| no_hostname | Boolean | Suppress hostname-based log filtering. |
| format | String | Log encoder format (e.g. json, console). |
| level | String | Log level (e.g. INFO, DEBUG). |
| include | List | Logger names to include. |
| exclude | List | Logger names to exclude. |
| custom_config | String | Raw lines inside the log { } block. |
Example A: global Caddyfile log to a fixed file:
- Compact
- Expanded
{ "output": "/etc/caddy/logs/access.log", "format": "console", "level": "INFO" }{ "output": "/etc/caddy/logs/access.log", "format": "console", "level": "INFO" }log:
output: /etc/caddy/logs/access.log
format: console
level: INFOExample B: per-site log with auto (the builder derives the log filename from sitename or group):
- Compact
- Expanded
{ "auto": true, "format": "json" }{ "auto": true, "format": "json" }log:
auto: true
format: jsonFull 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: trueInstallation and Deployment
Prerequisites
- Docker + Docker Compose (
docker composeplugin 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/anddata/www-data/ - Compose stack:
docker/docker-compose.yml - Task commands:
task/Taskfile.yml
Note
With Taskfile (Recommended)
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
- Init and run
- Reload
- Logs
- Restart / stop / clean
Build the Docker image and copy the configuration into the named volume:
task build
task copyconfigAlternatively, build and start the container in one step, then copy config:
task up -- --build
task copyconfigAttention
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.Build the Docker image and copy the configuration into the named volume:
task build
task copyconfigAlternatively, build and start the container in one step, then copy config:
task up -- --build
task copyconfigAttention
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.The reload command is the safest way to push a new configuration while the redirector is live. It validates kcaddy.yaml first, then rebuilds only if the config hash changed, formats the generated .caddy files, and sends a hot reload to Caddy, all without restarting the container. If validation or the Caddy reload fails, the previous configuration keeps running.
task reloadUnder the hood it runs: python3 -m builder --validate-only, then python3 -m builder, then caddy fmt, then caddy reload.
Follow container logs:
task logs -- -ftask restart # restarts the container (no config validation — use reload instead when possible)
task down # stops and removes containers and networks (volume is kept)
task delete # removes containers, networks, images, and the named volumeAttention
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/).
- Init
- Copy config / assets / www
- Reload
- Useful operations
Create the named volume the stack mounts at /etc/caddy:
docker volume create kcaddy_runtimeBuild the image and start the stack:
docker compose -f docker/docker-compose.yml up -d --buildCreate the named volume the stack mounts at /etc/caddy:
docker volume create kcaddy_runtimeBuild the image and start the stack:
docker compose -f docker/docker-compose.yml up -d --buildCopy the host config/ directory into the volume:
docker run --rm -v kcaddy_runtime:/target -v "${PWD}/config:/source:ro" alpine:3.19 \
sh -c "mkdir -p /target/config && cp -rf /source/. /target/config/"Copy operator assets into the volume’s assets/:
docker run --rm -v kcaddy_runtime:/target -v "${PWD}/data/assets:/source:ro" alpine:3.19 \
sh -c "mkdir -p /target/assets && cp -rf /source/. /target/assets/"Copy static www content into the volume’s www/:
docker run --rm -v kcaddy_runtime:/target -v "${PWD}/data/www-data:/source:ro" alpine:3.19 \
sh -c "mkdir -p /target/www && cp -rf /source/. /target/www/"After editing config/kcaddy.yml on the host and re-copying it into the volume, reload Caddy inside the running container:
docker compose -f docker/docker-compose.yml exec caddy sh /opt/kcaddy/docker/reload.shFollow container logs:
docker compose -f docker/docker-compose.yml logs -fRestart the Caddy service:
docker compose -f docker/docker-compose.yml restart caddyStop the stack (containers and networks; the named volume is kept):
docker compose -f docker/docker-compose.yml downRemove the stack and delete the volume for a clean reset:
docker compose -f docker/docker-compose.yml down
docker volume rm kcaddy_runtimeStandalone (No Docker)
Use the repository root as your working directory. Install Python dependencies once:
python -m pip install -r src/requirements.txt
- builder module
- Typer CLI
Validate only: loads and checks kcaddy.yaml without writing any files:
python -m builder --validate-onlyFull build: writes generated Caddy config under the configured output directories:
python -m builderValidate only: loads and checks kcaddy.yaml without writing any files:
python -m builder --validate-onlyFull build: writes generated Caddy config under the configured output directories:
python -m builderValidate a specific config file:
python -m src.main validate --config-file ./config/kcaddy.ymlBuild with a single runtime root and project root for templates:
KCADDY_ROOT=./my-runtime OPT_KCADDY=. \
python -m src.main buildBuild with explicit paths for each directory:
CONFIG_FILE=./config/kcaddy.yml \
CADDYFILE_DIR=./data/generated \
WWW_DIR=./data/www-data \
CADDY_LOG_DIR=./data/logs \
OPT_KCADDY=. \
python -m src.main buildNote
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.