File Server #

The file_server directive is the core module responsible for all of Caddy’s ability to serve static files from local storage to the internet. Although it looks simple from the outside — often just a one-line instruction in the Caddyfile — file_server actually has a very rich internal architecture, equipped with advanced automation features like compressed file management, content negotiation, hidden file handling, and range request fulfillment for media streaming.

This article takes a deep dive into the file_server directive. We’ll learn how Caddy processes incoming requests in its internal routing pipeline, explore all the advanced configuration options, understand supporting directives like try_files for fallback routing, optimize file delivery with precompressed assets, and troubleshoot common issues found in production.


The file_server Request Processing Pipeline #

When an HTTP request arrives at the Caddy server and is passed to the file_server directive, Caddy doesn’t just read the file from disk directly. Caddy runs a series of structured checks to ensure security and delivery efficiency:

flowchart TD
    Step1["1. Physical Path Resolution:<br/>Join the base directory (root) with the request URI"] --> Step2{"2. Hidden File Check (Hide List):<br/>Is the target file on the 'hide' list?"}
    
    Step2 -- "Yes" --> HideYes["Stop the process & Return HTTP 404 (or 403)"]
    Step2 -- "No" --> Step3{"3. Target Type Check (File vs Directory):<br/>Is the target a directory?"}
    
    Step3 -- "Yes" --> DirYes{"Look for an index file (e.g., index.html). If present, make index.html the target.<br/>If there's no index.html, check whether the 'browse' option is active.<br/>Is 'browse' active?"}
    DirYes -- "No" --> BrowseNo["Return HTTP 404"]
    DirYes -- "Yes" --> BrowseYes["Make the index listing / selected file the target"] --> Step4
    Step3 -- "No" --> Step4{"4. Precompression Check (Precompressed):<br/>Is the 'precompressed' option active and did the browser send an Accept-Encoding header?"}
    
    Step4 -- "Yes" --> PreYes["Check for file.br or file.gz on disk. If present, send the compressed version directly"] --> Step5
    Step4 -- "No" --> Step5{"5. Conditional Request Evaluation (ETag & Cache):<br/>Check the If-None-Match (ETag) or If-Modified-Since headers from the browser.<br/>Has the file changed?"}
    
    Step5 -- "No" --> CacheYes["Return HTTP 304 Not Modified (Without Body)"]
    Step5 -- "Yes" --> CacheNo["Return HTTP 200 OK with the file contents"]

The structured logic above ensures data transfer is as economical as possible and protects system files from unauthorized scanning.


All file_server Configuration Options #

The file_server directive supports an optional configuration block to fine-tune its behavior:

example.com {
    root * /var/www/html
    
    file_server {
        # 1. Set the built-in index file names (priority order)
        index index.html index.htm default.html
        
        # 2. Enable directory listing (default: disabled)
        # browse
        
        # 3. Hide files/directories from public access
        # Supports glob patterns. Paths are relative to the webroot.
        hide .git .env *.key secrets.json node_modules
        
        # 4. Disable automatic trailing slash redirects
        # disable_canonical_uris
        
        # 5. Serve precompressed files if available
        precompressed zstd br gzip
        
        # 6. Override the response status code (rarely used)
        # status 403
    }
}

try_files vs Rewrite: Fallback Routing Logic #

The try_files directive is a helper instruction executed before file_server. Its main job is to check file availability on disk sequentially based on the path list you provide, and rewrite the request path to the first file found.

The try_files syntax:

try_files [test_path_1] [test_path_2] [fallback_path]

Why Does try_files Behave Differently from a Regular Rewrite? #

It’s important to understand the execution model difference between try_files and the standard rewrite directive:

  • rewrite (State Manipulation): The rewrite directive directly changes the request URI in Caddy’s memory without checking whether the target file actually exists on disk. This is pure state manipulation happening at Caddy’s HTTP layer.
  • try_files (Filesystem Querying): The try_files directive performs physical I/O queries to the storage system (filesystem query) for each tested argument. If the first file is found, the rewrite process stops. This is very efficient because it prevents internal routing errors before data is forwarded to file_server.

Use Case: Clean URLs (without .html) #

You want users to access https://site.com/about without typing the .html extension at the end, while the server still serves the /about.html file internally:

example.com {
    root * /var/www/html
    
    # Try in order:
    # 1. Is there an exact file as requested? ({path})
    # 2. Is there a file with .html appended? ({path}.html)
    # 3. If neither, route to the custom 404 error page (/404.html)
    try_files {path} {path}.html /404.html
    
    file_server
}

Use Case: Single Page Application (SPA) Fallback #

In SPA applications, all virtual routes must be routed to the main index file index.html:

app.example.com {
    root * /var/www/app/dist
    
    # Try to find the physical file; if not found, route to /index.html
    try_files {path} /index.html
    
    file_server
}

Serving Precompressed Assets #

Serving large static files like uncompressed JavaScript bundles or CSS wastes network bandwidth. However, doing dynamic (on-the-fly) compression with the encode module on every request burdens the server CPU.

The most optimal solution for production-scale servers is using the Precompressed Assets feature of file_server:

example.com {
    root * /var/www/html
    
    file_server {
        # Caddy detects the Accept-Encoding header from the browser.
        # If the browser supports Brotli ("br") and file.js.br exists on disk,
        # Caddy directly serves file.js.br.
        precompressed zstd br gzip
    }
}

Integration with Modern Bundlers (Vite / Webpack) #

You can configure your modern JavaScript bundler to generate compressed assets automatically during the build process.

Example Vite Configuration (vite.config.js): #

Using the vite-plugin-compression plugin for automatic Brotli and Gzip compression:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import compression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    react(),
    // Standard Gzip compression
    compression({ algorithm: 'gzip', ext: '.gz' }),
    // Brotli compression for modern browsers
    compression({ algorithm: 'brotliCompress', ext: '.br' })
  ]
});

Canonical Trailing Slash Handling (Canonical URIs) #

To maintain routing consistency and prevent duplicate content issues on search engines (SEO duplicate content penalty), Caddy’s file_server enforces canonical URI writing by default:

Caddy Canonical Redirect Patterns:

1. Target is a DIRECTORY:
   Browser accesses: /about (without trailing slash)
   Caddy logic: Finds that "about" is a folder on disk.
   Caddy response: HTTP 301 Redirect to /about/ (with trailing slash).

2. Target is a FILE:
   Browser accesses: /style.css/ (with trailing slash)
   Caddy logic: Finds that "style.css" is a file on disk.
   Caddy response: HTTP 301 Redirect to /style.css (without trailing slash).

If your web application needs integration with legacy systems that don’t support this automatic trailing slash redirect, you can disable it with the disable_canonical_uris option:

example.com {
    root * /var/www/html
    
    file_server {
        # Turn off automatic trailing slash redirects
        disable_canonical_uris
    }
}

Serving from Multiple Root Directories (CDN Split Storage) #

In complex web application architectures, you often separate static file storage by type onto different storage media or disk mount points. For example, code assets (.js and .css) live on fast SSD storage, while user uploads live on large HDD or NFS (Network File System) storage.

Caddy facilitates this need by allowing multiple isolated root directives under the same virtual host:

# CDN-like Split Storage Configuration in Caddy
assets.example.com {
    # 1. Image paths live on the large HDD mount point
    root /media/images/* /mnt/storage-hdd/images
    
    # 2. Video file paths live on a separate video mount point
    root /media/videos/* /mnt/storage-videos/videos
    
    # 3. Static code paths live on the main SSD (/var/www/static)
    root /* /var/www/static
    
    # Dynamic compression specifically for code assets
    encode {
        zstd
        gzip 6
    }
    
    # Global file server handler
    file_server {
        hide .git .env
    }
}

Range Requests (Media Streaming & Partial Access) #

Caddy fully supports the HTTP Range Requests specification (HTTP 206 Partial Content status code) out of the box. This feature is crucial when the server serves very large binary files:

  • Video/Audio Streaming: Browsers can request a middle chunk of video data directly when a user scrubs the playback timeline without downloading the entire video file from the start.
  • Resume Downloads: Download managers can resume an interrupted download mid-way without restarting from byte 0.

[!IMPORTANT] Never enable the dynamic encode compression module on directives serving large video or audio files. Dynamic compression on natively compressed media files (like MP4, WebM, MP3) won’t shrink the file size — instead, it will break the byte offset calculations for Range Requests, so streaming and resume download features stop working in users’ browsers.


Cryptographic ETags: Strong vs Weak ETag #

Caddy uses ETag markers to facilitate conditional requests from browsers. Technically, the HTTP specification defines two types of ETags:

  1. Strong ETag: Guarantees that every byte of the file on the server is absolutely identical to the file in the browser’s cache. Represented by a direct hash string, e.g., "1a2b3c4d".
  2. Weak ETag (prefixed with W/): Guarantees the content is semantically identical, but small byte-level differences may exist (e.g., due to metadata or compression differences). Caddy generates Weak ETags (e.g., W/"1a2b3c4d") by default because this approach is far more efficient for validating compressed static web assets.

Comparative Analysis: Caddy vs Nginx for Static Files #

For system administrators used to Nginx, here’s a comparison of equivalent configuration lines for serving static files with compression optimization and automatic TLS:

Traditional Nginx Configuration: #

# /etc/nginx/sites-available/default
server {
    listen 80;
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
        gzip on;
        gzip_types text/css application/javascript text/html;
        gzip_min_length 1024;
        add_header X-Frame-Options SAMEORIGIN;
    }
}
# (Still needs an external certbot cronjob for SSL renewal)

Caddyfile Configuration: #

# Full equivalent of the Nginx configuration above
example.com {
    root * /var/www/html
    try_files {path} /index.html
    encode gzip zstd
    header X-Frame-Options SAMEORIGIN
    file_server
}
# (Automatic SSL, automatic renewal, HTTP/3 enabled automatically)

file_server Troubleshooting #

1. HTTP 403 Forbidden #

  • Main Cause: Linux directory or file permissions aren’t readable by the caddy user.
  • Solution: Run sudo chown -R caddy:caddy /var/www/html and make sure directories have 755 permission so they can be traversed.

2. HTTP 404 Not Found (Even Though the Physical File Exists on Disk) #

  • Cause 1: The path Caddy looks for doesn’t match due to a wrong root + URI join (see the Path Resolution chapter).
  • Solution 1: Use caddy adapt to see how Caddy translates the Caddyfile into internal JSON configuration to verify the active root directory.
  • Cause 2: The file is hidden because it was accidentally filtered by the hide option in the Caddyfile.
  • Solution 2: Recheck the hide option block inside your file_server directive.

Summary #

  • Core Engine — The file_server directive is the backbone of static file serving in Caddy, handling MIME Types, ETags, and Range Requests automatically.
  • try_files Logic — Used to create route fallbacks, facilitate Clean URLs, and support SPA deployment by routing virtual routes to index.html.
  • Precompressed Assets — Serve Brotli (.br), Gzip (.gz), or Zstandard (.zst) compressed files directly from disk to save server CPU.
  • Video Streaming — Range Requests (HTTP 206) are automatically active for videos, but make sure not to enable the encode module on video files so byte offsets don’t break.
  • MIME & ETag — Caddy automatically detects file content types and generates dynamic ETag markers for efficient browser data transfer using status code 304.

← Previous: Virtual Host   Next: Directory Browse →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact