A fast, configurable Caddy HTTP middleware for granular host and path migrations.
Designed for large redirect sets after domain or app restructures, with a clean Caddyfile syntax and production-friendly behavior.
Redirect rules can be configured flexibly, either through the Caddyfile or via external config files.
Report Bug
·
Request Feature
Table of Contents
-
Host-aware rules: define redirects grouped by source host (including wildcards like
*.example.comor catch-all*). -
Three match modes per host:
exact(path = path)prefix(longest prefix wins)regex(Go RE2;$1,$2, … captures)
-
Multiple config formats: rules can be defined not only in the Caddyfile, but also in external YAML, JSON, or TOML files.
-
Configurable status code: global default and host-level override (301, 307 or 308).
-
Absolute vs relative targets:
- Absolute targets (
https://…) are used verbatim. - Relative targets (
/new/path) are attached to the configuredto_hostor stay on the same host if none is set.
- Absolute targets (
-
Performance-minded:
- Exact lookups via map.
- Prefix rules pre-sorted by length to pick the most specific match quickly.
- Regexes compiled once at provision time.
-
Clear precedence:
exact>prefix>regex.
Current default: query strings are not preserved automatically (a per-rule option can be added later).
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# From your (empty) workspace or any folder:
xcaddy build \
--with github.com/Bl4cky99/caddy-redirector@latestThis produces a caddy binary that includes the redirector module.
If you are developing locally inside this repository, you can build with:
xcaddy build --with github.com/Bl4cky99/caddy-redirector=.
The Caddy team recommends building your own Caddy binary with your chosen modules.
If you still want an image, you can use a multi-stage build to bake this module:
# Example only. Prefer building your own Caddy binary with xcaddy.
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/Bl4cky99/caddy-redirector@latest
FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
# Provide your Caddyfile via bind mount or COPYPrebuilt, self-contained Caddy binaries that already include this module are attached to each GitHub Release.
Download & verify (Linux, amd64/arm64):
# Pick a released version tag
VERSION=v1.0.0
OS=linux
ARCH=amd64 # or arm64
# Download the tarball and checksum
curl -sSL -o caddy-${OS}-${ARCH}.tar.gz \
https://github.com/Bl4cky99/caddy-redirector/releases/download/${VERSION}/caddy-${OS}-${ARCH}.tar.gz
curl -sSL -o caddy-${OS}-${ARCH}.tar.gz.sha256 \
https://github.com/Bl4cky99/caddy-redirector/releases/download/${VERSION}/caddy-${OS}-${ARCH}.tar.gz.sha256
# Verify checksum (must print: OK)
sha256sum -c caddy-${OS}-${ARCH}.tar.gz.sha256
# Install
tar -xzf caddy-${OS}-${ARCH}.tar.gz
sudo install -m 0755 caddy-${OS}-${ARCH} /usr/local/bin/caddy
caddy versionNotes
- Binaries are built via
xcaddyand embed this module; you do not need to rebuild Caddy to use it.- Ensure you comply with the licenses of Caddy and all included modules in downstream distributions.
Official-style image published to GHCR, containing a Caddy binary prebuilt with this module.
Pull & run:
IMAGE=ghcr.io/bl4cky99/caddy-redirector
TAG=v1.0.0 # or 'latest'
docker pull ${IMAGE}:${TAG}
# Run with your Caddyfile from the current directory
docker run --rm -it \
-p 8080:8080 \
-v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" \
${IMAGE}:${TAG} run --config /etc/caddy/Caddyfile --adapter caddyfiledocker-compose example:
services:
caddy:
image: ghcr.io/bl4cky99/caddy-redirector:v1.0.0
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:Notes
- Image tags follow release tags:
vX.Y.Z, plus floating tagsX,X.Y, andlatest.- Mount your own
Caddyfile; the image only provides the prebuilt Caddy binary with this module.- For production, persist
/dataand/configvolumes as shown above.
Top-level directive:
redirector {
# Optional: global default status code (301 or 308). Default: 308
status 308
# One or more host blocks:
host <pattern> {
# Optional per-host override:
status 301
# Optional target host. If set, relative targets become absolute URLs on this host.
to_host new.example
# Rules (any order):
exact /old /new
prefix /blog/ /news/
regex ^/u/([0-9]+)$ /users/$1
}
}host example.com– exact host match.host *.example.com– matches any subdomain ofexample.com(does not match the apex).host *– catch-all (least specific; used only if more specific hosts didn’t match).
-
exact
<from> <to>
When the request path equals<from>, redirect to<to>. -
prefix
<from> <to>
When the request path starts with<from>, redirect to<to>plus the remaining suffix.
The longest matching prefix wins. -
regex
<pattern> <to>
When the regex matches the path, produce the target byregexp.ReplaceAllString(path, <to>).
Use$1,$2, … for capture groups. The first matching regex (in declaration order) wins.
- Absolute target (
http://…orhttps://…)
Used verbatim.to_hostis ignored for that rule. - Relative target (
/something) withto_host
Redirect to{scheme}://{to_host}{target}.
Scheme is inferred:httpsby default, orhttpifX-Forwarded-Proto: httpand no TLS. - Relative target without
to_host
Redirect to the same host with the new path.
- Use host-level
statusif set. - Else use global
status(from theredirectorblock). - Else default to 308 (Permanent Redirect).
exactrulesprefixrules (longestfromwins)regexrules (first match wins)- No match → pass to the next handler
In addition to the Caddyfile configuration, rules can also be defined in structured formats:
- YAML (example/rules.yaml)
- JSON (example/rules.json)
- TOML (example/rules.toml)
- Exact migration
:8080
route {
redirector {
status 308
host old.example {
to_host new.example
exact /start /getting-started
exact /about /company
exact /docs /documentation
}
}
}curl -i -H 'Host: old.example' http://localhost:8080/docs
→ 308 Location: https://new.example/documentation
- Prefix with longest-prefix
:8080
route {
redirector {
host app.example {
to_host portal.example
prefix /blog/ /news/
prefix /blog/2024/ /archive/2024/
}
}
}/blog/2024/… goes to /archive/2024/… because the longer prefix wins.
- Regex with captures
:8080
route {
redirector {
host accounts.example {
# Send to a completely different site (absolute URL)
regex ^/u/([0-9]+)$ https://profiles.example/users/$1
}
}
}- Wildcard host and catch-all
:8080
route {
redirector {
# Subdomains only (not apex)
host *.old.example {
to_host new.example
prefix /a/ /alpha/
}
# Fallback for any other host
host * {
exact /ping /health
}
}
}Start Caddy:
./caddy run --config Caddyfile --adapter caddyfileHit endpoints:
# Exact
curl -i -H 'Host: old.example' http://localhost:8080/docs
# Prefix
curl -i -H 'Host: app.example' http://localhost:8080/blog/2024/post
# Regex
curl -i -H 'Host: accounts.example' http://localhost:8080/u/42-
“unrecognized directive: redirector”
Ensure you built Caddy with this module:xcaddy build --with github.com/Bl4cky99/caddy-redirector@latest -
Config adapts but redirects don’t happen
Confirm yourhostblock actually matches the requestHostheader (including ports in dev).
For wildcard*.example.com, remember it does not matchexample.comitself. -
Scheme is wrong (http vs https)
The module infers the scheme ashttpsby default, orhttpif the request is not TLS-terminated and the proxy setsX-Forwarded-Proto: http.
Make sure your proxy sendsX-Forwarded-Protoexactly. -
Regex rule not firing
Regexes use Go’s RE2 syntax. Start with^and end with$when matching the entire path.
The first matching rule wins; check your rule order. -
Caddyfile parse errors
Unknown subdirectives inside ahostblock will be rejected explicitly. Verify spelling and arguments.
- Go: 1.25+
- Caddy: v2 (build with
xcaddy) - Regex engine: Go RE2 (no backtracking)
- Be careful with wide regexes that can redirect a large portion of your site; keep exact/prefix rules for common paths.
- Avoid user-controlled rule inputs; store redirects in your config or vetted data files.
- Absolute targets (
http://…) will downgrade scheme on purpose—use only if you intend that.
- Rule-level status and query preservation flags.
- Optional query passthrough and path templating beyond
$1.
Q: Does *.example.com match example.com?
A: No. Add a separate host example.com block for the apex.
Q: Do query strings get forwarded?
A: Not by default in the current version. This can be added per rule/host later.
Q: How do I choose 301, 307 and 308?
A: Use status 301, status 307 or status 308 at the global level or inside a host block.
308 keeps the HTTP method and is often the safer default for permanent moves.
Q: Can I use absolute URLs in rules?
A: Yes. If the target starts with http:// or https://, it is used verbatim and to_host is ignored for that rule.
Project layout:
.
├─ model.go # data types: HostBlock, PrefixRule, RegexRule, compiledHostBlock, etc.
├─ parse_caddyfile.go # Caddyfile parsing (UnmarshalCaddyfile), directive registration
├─ parse_config.go # Config parsing for external redirect rule files (json, yaml, toml)
├─ redirector.go # module wiring, Provision/Validate/ServeHTTP, core logic
├─ tests
├ ├─ configs # config files for tests
├ ├─ bench_test.go # benchmark test
├ ├─ factory.go # factory for reuseable test environment
├ ├─ integration_test.go # smoke integration test
├ ├─ unit_test.go # unit tests for default (and some edge) cases
├ └─ util.go # some utility for tests
├
└─ go.mod
Lifecycle:
- Caddyfile → structs
UnmarshalCaddyfileparsesredirector { host … }blocks intoRedirector.Hosts. - Provision
- Compute compiled form per host: normalize patterns, compile regex once, sort prefix rules by descending
Fromlength, resolve per-hoststatus(fallback to global).
- Compute compiled form per host: normalize patterns, compile regex once, sort prefix rules by descending
- ServeHTTP (hot path)
- Pick host block: exact host > wildcard suffix (
*.example.com) >*. - Try
exact, thenprefix(longest wins), thenregex(first wins). - Build the target (absolute vs relative + optional
to_host). http.Redirect(w, req, code).
- Pick host block: exact host > wildcard suffix (
Performance & complexity:
- Exact: O(1) map lookup per host block.
- Prefix: O(P) with small constant factor after sorting. For very large P, consider a radix tree.
- Regex: O(R) scan with precompiled regex; keep R modest, put fast rules earlier (exact/prefix cover most cases).
Reload behavior:
- Caddy will call
Provisionon each reload; regexes are recompiled, prefix lists resorted, old state is discarded.
This repository ships a self-contained test suite under tests/ (package e2e).
It exercises the handler directly (no external Caddy process) and uses dedicated rule files under tests/configs/.
Layout
tests/unit_test.go– core unit tests (exact/prefix/regex, host precedence, merging, scheme inference).tests/integration_test.go- integration test using build caddy binary (must be present in repo)tests/configs/– rule files (JSON/YAML/TOML) used by the suite.
Note: The module currently keeps a package-global compiled state. Do not use
t.Parallel(); tests run serially by design.
# Unit tests
go test -v -race -cover -tags=unit ./tests
# Integration test
go test -v -tags=integration ./testsTest assets
All rule files consumed by the suite live in tests/configs/:
rules_exact.json,rules_prefix.yaml,rules_regex.toml,rules_wildcards.yamlmerge_a.json,merge_b.json(merge order/last-wins)scheme.yaml(scheme inference)bad_regex.yaml,unknown.data,noext,regex_no_slash.json,absolute_exact.yaml,case.json(edge/error cases)
This section documents the micro-benchmarks used to characterize the matching cost of the module’s three rule types (exact, prefix, regex) and to validate the expected time complexity.
What is measured?
- The benchmarks run entirely in-process (no network) using
ServeHTTPagainst synthetic requests. - Rule sets are generated in-memory:
Exact_Hit/Miss: 1,000 exact rulesPrefix_Longest_Hit/Miss: 1,000 prefix rules (pre-sorted longest-first)Regex_Hit/Miss: 100 compiled regex rules
- Each test uses a minimal
http.ResponseWriterand a no-opnexthandler to reduce measurement noise.
How to run locally
File:
tests/bench_test.go
#### Run all benchmarks with memory stats
go test -run '^$' -bench . -benchmem ./tests
#### Get more stable numbers (5 repetitions)
go test -run '^$' -bench . -benchmem -count=5 ./tests > bench.txt
#### (Optional) Pin to a single OS thread for reproducibility
GOMAXPROCS=1 go test -run '^$' -bench . -benchmem ./tests
Interpreting the output
Each line shows:
ns/op: average nanoseconds per request processedB/op: bytes allocated per operationallocs/op: number of allocations per operation
Benchmark Results (Plattform: Linux, amd64, i7-9700K):
goos: linux
goarch: amd64
pkg: github.com/Bl4cky99/caddy-redirector
cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
BenchmarkExact_Hit_1e3-8 ~1500 ns/op 1292 B/op 14 allocs/op
BenchmarkExact_Miss_1e3-8 ~145 ns/op 160 B/op 3 allocs/op
BenchmarkPrefix_Longest_Hit_1e3-8 ~8,000 ns/op 5638 B/op 15 allocs/op
BenchmarkPrefix_Miss_1e3-8 ~145 ns/op 160 B/op 3 allocs/op
BenchmarkRegex_Hit_1e2-8 ~2,400 ns/op 1474 B/op 20 allocs/op
BenchmarkRegex_Miss_1e2-8 ~3,700 ns/op 162 B/op 3 allocs/op
What these numbers mean:
-
Exact (map lookup)
- Hit: ~0.48 µs with 3 small allocations (headers/redirect path composition). Time is effectively O(1) with respect to the number of rules.
- Miss: ~30 ns, zero allocations — very fast early exit, also O(1).
-
Prefix (linear scan, longest-first)
- Longest hit: ~25 µs, a few allocations for response header/Location. This scales roughly O(P) with the number of prefix rules because we scan until we find the first match (you pre-sort for best specificity, not for speed).
- Miss: ~0.92 µs, zero allocations — still O(P), but exits after checking all rules without building a redirect.
-
Regex (linear scan over compiled RE2)
- Hit: ~1.14 µs, ~336 B / 9 allocs — includes
ReplaceAllStringwith captures and building the Location header. Complexity O(R) for the number of regex rules. - Miss: ~3.1 µs, zero allocations — time reflects checking each compiled regex and failing to match (O(R)).
- Hit: ~1.14 µs, ~336 B / 9 allocs — includes
- Query handling:
preserve_query on|offat rule or host level; orappend_query key=value. - Metrics/logging: counters per rule, structured logs with rule IDs.
- Radix tree for prefix rules when you have very large sets.
- Dual-stack host matching: treat
Host: example.com:PORTgracefully in dev (normalize port).
# 1) Build a local Caddy with your working copy
xcaddy build --with github.com/Bl4cky99/caddy-redirector=.
# 2) Run with your Caddyfile
./caddy run --config Caddyfile --adapter caddyfile
# 3) Test
curl -i -H 'Host: old.example' http://localhost:8080/old
This project is licensed under the MIT License.
- Copyright © 2025 Jason Giese (Bl4cky99)
- See the full text in LICENSE.
Notes for users and integrators
- Commercial use, modification, distribution, and private forks are permitted.
- Keep the copyright and permission notice from the MIT license in all copies/substantial portions.
- (Optional) Add an SPDX header to source files for tooling:
// SPDX-License-Identifier: MIT.
Third-party software This repository contains only the module’s source. When building Caddy with this module, you must also comply with the licenses of Caddy and any other included modules/dependencies in your final binary or container image.
Happy redirecting!