Static Files #

Serving static files is one of the most fundamental and crucial functions of a web server. Whether you’re building a simple portfolio site, distributing image assets, or deploying a complex modern Single Page Application (SPA), how efficiently the web server reads files from disk and sends them over the network largely determines performance and user experience. Caddy does this job very well with extremely concise configuration, combining high-efficiency file handling with built-in HTTPS automation.

This article covers how to serve static files professionally with Caddy. We’ll dive into basic configuration, the internal IO mechanisms Caddy uses at the operating system level, directory permission management, MIME type customization techniques, aggressive yet safe caching strategies, protecting sensitive files from external access, and modern SPA deployment patterns like React, Vue, and Angular in production.


The Simplest Configuration #

To serve a public static site with automatic HTTPS and HTTP-to-HTTPS redirect, your Caddyfile only needs these three lines:

# Standard static file serving configuration
example.com {
    root * /var/www/html
    file_server
}

Behind the simplicity of these three lines, Caddy automatically does the following for you in the background:

  • ACME TLS Management: Contacts Let’s Encrypt or ZeroSSL to obtain and manage TLS certificates.
  • MIME Mapping: Detects the requested file’s extension and sends the correct Content-Type header to the browser.
  • HTTP/2 & HTTP/3: Enables modern protocol negotiation to speed up parallel asset loading.
  • Automatic Redirect: Redirects all port 80 HTTP traffic to port 443 HTTPS.

Kernel-Level IO Performance Optimization (Sendfile) #

Caddy is written in Go, which has a very efficient standard library for networking and filesystems. When serving large static files through the file_server directive, Caddy leverages a kernel-level optimization called sendfile (on Linux and macOS).

flowchart TD
    subgraph NoSendfile ["Without Sendfile (Repeated Memory Copying)"]
        direction LR
        Disk1["Server Disk"] --> BufKer1["Kernel Buffer"]
        BufKer1 --> BufApp1["Application Buffer (Go)"]
        BufApp1 --> SockKer1["Kernel Socket"]
        SockKer1 --> Net1["Network"]
    end

    subgraph WithSendfile ["With Sendfile (Zero-Copy Transfer)"]
        direction LR
        Disk2["Server Disk"] --> BufKer2["Kernel Buffer"]
        BufKer2 -->|"Zero-Copy"| SockKer2["Kernel Socket"]
        SockKer2 --> Net2["Network"]
    end

With this zero-copy technique, Caddy minimizes CPU context switches and memory usage. This lets Caddy serve thousands of files simultaneously with very small RAM usage, making it an ideal choice for servers with limited hardware specs.


The root Directive — Understanding the Webroot #

The root directive sets the base directory where your static files are stored on the server’s storage system. Its first argument is a matcher that determines which request paths the base directory applies to.

The root syntax:

# root [matcher] [directory_path]

In production practice, you can set different base directories for different request types:

example.com {
    # The '*' matcher means it applies to all requests as the default fallback
    root * /var/www/html
    
    # Set a special root for image assets
    root /images/* /storage/media/images
    
    # Set a special root for documentation
    root /docs/* /var/www/documentation/dist
    
    file_server
}

Path Resolution Implications #

Caddy resolves file paths by joining the configured base directory with the incoming request URI. You must be careful designing your directory structure to match the following lookup flow:

Scenario A:
  Configuration: root * /var/www/html
  Incoming request: GET /images/logo.png
  Path Caddy looks for: /var/www/html/images/logo.png

Scenario B:
  Configuration: root /images/* /storage/media/images
  Incoming request: GET /images/logo.png
  Path Caddy looks for: /storage/media/images/images/logo.png
  (Note that the "/images" prefix is preserved and appended to the end of the root)

If you want to strip the path prefix when looking up files in the base directory, you must use the uri strip_prefix directive before calling file_server (this topic is covered in detail in the File Server article).


Managing File Permissions on Linux #

One of the most frequent causes of HTTP 403 Forbidden errors when serving static files is wrong file permission configuration on Linux.

The user account running Caddy (usually named caddy in official Debian/Ubuntu package installations) must have read access to all static files, plus execute access on the directories containing those files so Caddy can traverse the folder contents.

# 1. Check which user is running the Caddy process
ps aux | grep caddy

# 2. Set the webroot directory ownership to the caddy user recursively
sudo chown -R caddy:caddy /var/www/html

# 3. Set proper access permissions
# Give read-write access to the owner (caddy) and read access to the public
find /var/www/html -type d -exec chmod 755 {} + # 755 gives execute permission on folders
find /var/www/html -type f -exec chmod 644 {} + # 644 gives read permission on files

# 4. Test whether the caddy user can read files manually
sudo -u caddy cat /var/www/html/index.html

MIME Type Customization #

By default, Caddy detects a file’s MIME type (Multipurpose Internet Mail Extensions) based on its name extension using Go’s internal database and OS configuration files (like /etc/mime.types on Linux). The correct Content-Type header is crucial; if a browser receives the wrong MIME type for a JavaScript file, it will block the file’s execution for security (MIME-sniffing protection).

If you use modern file formats or custom extensions, you can force the correct Content-Type header using a matcher and the header directive in the Caddyfile:

example.com {
    root * /var/www/html
    
    # Ensure WebAssembly files are served with the correct MIME type
    @wasm path *.wasm
    header @wasm Content-Type "application/wasm"
    
    # Ensure the modern AVIF image format is served correctly
    @avif path *.avif
    header @avif Content-Type "image/avif"
    
    # Serve progressive web app (PWA) manifest files
    @webmanifest path *.webmanifest
    header @webmanifest Content-Type "application/manifest+json"
    
    file_server
}

Caching Management Strategies for Maximum Performance #

Client-side browser caching is the key to dramatically improving repeat page load speed and reducing data transfer (bandwidth) load on your server. Bad caching strategies can cause users to receive stale old web pages, or conversely, force the server to resend the same static images that never actually changed.

Here’s a recommended modern caching strategy configuration for static sites using cache-busting schemes (like React/Vite builds that add a unique hash to asset filenames, e.g., main.a1b2c3d4.js):

example.com {
    root * /var/www/html
    
    # 1. Hashed Asset Strategy (Cache Forever - 1 Year)
    # Because the filename changes when content changes, these files are safe to cache forever
    @hashedAssets {
        path_regexp \.[a-f0-9]{8,}\.(js|css|woff2?|ttf|eot)$
    }
    header @hashedAssets Cache-Control "public, max-age=31536000, immutable"
    
    # 2. Common Image Asset Strategy (Medium Cache - 30 Days)
    @images path *.jpg *.jpeg *.png *.gif *.webp *.avif *.svg *.ico
    header @images Cache-Control "public, max-age=2592000"
    
    # 3. Main HTML File Strategy (No Local Cache - Must Validate)
    # Browsers must always ask the server whether the HTML file changed (using ETag)
    @html path *.html /
    header @html Cache-Control "no-cache"
    
    # 4. Sensitive & Dynamic Configuration Files (Don't Store Cache at All)
    @dynamic path *.json *.xml
    header @dynamic Cache-Control "no-store"
    
    encode gzip zstd
    file_server
}

Understanding the Different Cache-Control Directives: #

  • max-age=31536000: Instructs the browser to store the file for 1 year (31,536,000 seconds) without contacting the server again.
  • immutable: Tells the browser the file’s name will never change. The browser doesn’t need to send conditional GET validation queries during the max-age period, even if the user presses the browser refresh button.
  • no-cache: The browser stores the file but must validate with the server (using an HTTP If-None-Match query with ETag) before serving it to the user. If the server responds with 304 Not Modified, the local cache is used.
  • no-store: The browser must not store this file in its local disk cache at all. The file must always be downloaded in full from the server every time it’s accessed.

Protecting Sensitive and Hidden Files #

When you deploy a web project, sometimes sensitive files get uploaded into the webroot directory, such as environment configuration (.env), Git repositories (.git), or project dependencies (node_modules). Leaving these files publicly accessible is a very dangerous security hole.

You can configure Caddy to block access to these files and return an HTTP 404 Not Found response so automated scanners don’t learn of the sensitive files’ existence:

example.com {
    root * /var/www/html
    
    # Define the sensitive file group using a path matcher
    @sensitive {
        path /.env
        path /.git/*
        path /.gitignore
        path /wp-config.php
        path /composer.json
        path /package.json
        path /package-lock.json
        path /*.key
        path /*.pem
        path /*.sql
        path /*.db
    }
    
    # Return 404 for all matches above
    respond @sensitive 404
    
    # Block all hidden files starting with a dot (dotfiles)
    @dotfiles {
        path_regexp ^/\.
    }
    respond @dotfiles 404
    
    file_server
}

SPA Deployment (React, Vue, Angular) #

Single Page Applications (SPAs) use client-side routing. When a user visits https://app.com/about, the browser requests the /about file from the Caddy server. Because the /about file doesn’t exist on the server’s storage system (it’s just a virtual route in React Router or Vue Router), the Caddy server returns a 404 Not Found error by default.

For client-side routing to work correctly, Caddy must be configured to serve the main index.html file as a fallback when the requested file isn’t found on disk:

# Caddyfile configuration for an SPA application
app.example.com {
    root * /var/www/app/dist
    
    encode gzip zstd
    
    # Security headers for web applications
    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    
    # Aggressive caching for bundler build assets (Vite/Webpack)
    @hashedAssets {
        path_regexp assets/.*\.[a-f0-9]{8}\.(js|css)$
    }
    header @hashedAssets Cache-Control "public, max-age=31536000, immutable"
    
    # Set no-cache for the main HTML so it always refreshes on new deploys
    @html path *.html /
    header @html Cache-Control "no-cache"
    
    # KEY FOR SPA:
    # If the requested file DOES NOT exist on the filesystem, and the path is NOT
    # an API call (/api/*), rewrite the path to /index.html
    @notFound {
        not file
        not path /api/*
    }
    rewrite @notFound /index.html
    
    file_server
}

A Typical Vite/React Build Output Structure: #

Here’s a visualization of the directory structure produced after running npm run build on your React/Vite project. Caddy serves these files based on the configuration above:

/var/www/app/dist/
  ├── index.html                 # Served as the fallback for client-side routing
  ├── favicon.ico
  ├── assets/
  │   ├── index-a1b2c3d4.js      # Contains a hash - cached 1 year (immutable)
  │   ├── index-e5f6g7h8.css     # Contains a hash - cached 1 year (immutable)
  │   └── vendor-i9j0k1l2.js
  └── images/
      └── logo.png               # Cached 30 days (common image caching)

Bandwidth Optimization Using Dynamic and Static Compression #

Compressing text responses (like HTML, CSS, JavaScript, and JSON) before sending them over the network is important for saving data quotas and speeding up page load times, especially for users on slow mobile connections.

Caddy supports two compression methods:

1. Dynamic Compression (On-The-Fly) #

Caddy automatically compresses data before sending it to the client using the Gzip or Zstandard (zstd) algorithm based on the Accept-Encoding header content sent by the browser:

example.com {
    root * /var/www/html
    
    # Enable dynamic compression
    encode {
        # Zstandard offers better compression ratios and is faster than Gzip
        zstd
        gzip 6
        
        # Only compress files with a minimum size of 1 KB (1024 bytes)
        minimum_length 1024
    }
    
    file_server
}

2. Serving Precompressed Assets #

Doing dynamic compression on every incoming request consumes server CPU power. In high-traffic production environments, this can burden the CPU. The best solution is to pre-compress static files during the build process on your CI/CD machine, then tell Caddy to directly serve those compressed files:

example.com {
    root * /var/www/html
    
    file_server {
        # If the client supports Brotli ("br") and the "style.css.br" file exists on disk,
        # Caddy sends it directly without doing repeated compression.
        precompressed br gzip
    }
}

Automation Script for Creating Precompressed Files (CI/CD): #

You can include this simple bash script in your continuous integration workflow to generate .gz and .br files automatically before uploading to the server:

# Navigate to the application build output directory
cd /var/www/html

# Create Gzip compressed versions (.gz) with maximum compression level (-9)
find . -type f -name "*.js" -o -name "*.css" -o -name "*.html" -o -name "*.svg" | while read -r f; do
    gzip -k -9 "$f"
done

# Create Brotli compressed versions (.br) with maximum quality (-q 11)
# (Requires the 'brotli' tool installed on the system)
find . -type f -name "*.js" -o -name "*.css" -o -name "*.html" -o -name "*.svg" | while read -r f; do
    brotli -k -q 11 "$f"
done

Summary #

  • Minimal Configuration — Combining the root * /path and file_server directives is enough to serve a static site with automatic HTTPS.
  • Sendfile Efficiency — Caddy uses the kernel-level sendfile system call by default to minimize memory load and CPU context switches when serving files.
  • Linux Permissions — The caddy system user must have read permission on static files (644) and execute permission (755) on directories to traverse folders.
  • Cache Management — Use aggressive Cache-Control headers (immutable) for hashed assets, no-cache for main HTML files, and no-store for dynamic data.
  • SPA Routing — Use the not file matching rule and the rewrite directive to /index.html so client-side virtual routing (React/Vue/Angular) doesn’t cause 404 errors.
  • Asset Compression — Enable the encode directive for dynamic compression, or use the precompressed option to directly serve pre-compressed .gz/.br files to save server CPU power.

← Previous: Web Server   Next: Virtual Host →

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