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-Language header starts with zh (covers zh-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:

  1. Check Chinese condition: user language is Chinese ^zh and the Chinese version’s path exists (probed via file directive)
  2. If Chinese version exists → 301 redirect to /zh-cn{uri}
  3. Then check English condition: does the English version’s path exist?
  4. If English version exists → 301 redirect to /en{uri}
  5. 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.

References