Caddy + Hugo: Language-Aware Redirection Based on Accept-Language
Background
My blog (built with Hugo + FixIt theme) supports Chinese and English. The content structure looks like:
content/
├── posts/
│ ├── podman.zh-CN.md
│ ├── podman.en.md
│ ├── custom-caddy-build.zh-CN.md
│ └── custom-caddy-build.en.md
└── ...After Hugo builds, it generates separate language directories:
/www/blog/
├── zh-cn/
│ ├── posts/
│ │ └── custom-caddy-build/
│ └── 404.html
├── en/
│ ├── posts/
│ │ └── custom-caddy-build/
│ └── 404.html
└── index.html (root redirect)The root path / is typically empty. When a visitor reaches www.example.com, I want Caddy to automatically redirect them to the right language version based on their browser’s language preference. If the target language’s article doesn’t exist, fall back to the other language. If neither exists, show the default 404 page.
Complete Configuration
www.example.com {
root * /www/blog
# Root path language redirect
route / {
@chinese header_regexp Accept-Language ^zh
redir @chinese /zh-cn/ 302
redir * /en/ 302
}
# Subpath language detection
@needs_localization not path / /en/* /zh-cn/*
route @needs_localization {
@zh_target_exists {
header_regexp Accept-Language ^zh
file {root}/zh-cn{uri} {root}/zh-cn{uri}/index.html
}
redir @zh_target_exists /zh-cn{uri} 301
@en_target_exists {
file {root}/en{uri} {root}/en{uri}/index.html
}
redir @en_target_exists /en{uri} 301
}
file_server
handle_errors 404 {
rewrite * {root}/en/404.html
file_server
}
}Step-by-Step Breakdown
Root Path Language Redirect
route / {
@chinese header_regexp Accept-Language ^zh
redir @chinese /zh-cn/ 302
redir * /en/ 302
}When visiting www.example.com/ (root path):
- Check if the
Accept-Languageheader starts withzh(coverszh-CN,zh-TW,zh-HK, etc.) - Chinese users →
/zh-cn/(302 temporary redirect) - Everyone else →
/en/(default fallback)
We use 302 instead of 301 because language preference is temporary - a user might switch their browser language, and 301 would be cached.
Subpath Language Detection
@needs_localization not path / /en/* /zh-cn/*This named matcher defines which paths need language detection. Three cases are excluded:
/: Root path is already handled above/en/*: Already an English path/zh-cn/*: Already a Chinese path
So when a user visits a neutral path like /posts/custom-caddy-build/ (no language prefix), detection is triggered.
route @needs_localization {
@zh_target_exists {
header_regexp Accept-Language ^zh
file {root}/zh-cn{uri} {root}/zh-cn{uri}/index.html
}
redir @zh_target_exists /zh-cn{uri} 301
@en_target_exists {
file {root}/en{uri} {root}/en{uri}/index.html
}
redir @en_target_exists /en{uri} 301
}Detection logic:
- Check Chinese condition: user language is Chinese
^zhand the Chinese version’s path exists (probed viafiledirective) - If Chinese version exists → 301 redirect to
/zh-cn{uri} - Then check English condition: does the English version’s path exist?
- If English version exists → 301 redirect to
/en{uri} - If neither exists → fall through to
file_server(will hit 404)
The file directive tries two patterns: {uri} for directory-style access (Hugo generates directory structures) and {uri}/index.html for file-style access. If either exists, the language version is considered available.
404 Error Handling
handle_errors 404 {
rewrite * {root}/en/404.html
file_server
}When a user visits a path that doesn’t exist (in any language), show the English 404 page. You could make this smarter by checking Accept-Language for locale-specific 404s, but a simple uniform 404 page hardly justifies that complexity.