Kai Ole Hartwig
Media from S3 in Kubernetes?

caddy-fs-s3cache — serve files from S3 without asking three times per image.

Many websites no longer keep images and downloads on the server's disk but in an object store such as Amazon S3. The Caddy web server can serve such files straight from there. With the module available so far, every file costs three requests to S3, and the next visitor starts from scratch. caddy-fs-s3cache fetches a file once, keeps a copy on local disk and answers every further request from there. Only at intervals does it ask S3 whether the file has changed.

  • What it is: an extension module for Caddy (caddy.fs.s3cache) that takes the place of the existing S3 module
  • Who it is for: operators running Caddy or FrankenPHP with their media in S3, for example in Kubernetes
  • Technology: Go module · Caddy 2.11+ · Go 1.26+ · MIT licence · source on GitHub

TL;DR — Does this fit my setup?

Use it if

Do not use it if

Effort

One line when building Caddy and one block in the configuration. Credentials for S3 come from the usual AWS chain, in Kubernetes from the pod's identity. No password sits in the configuration file.

Why three requests per file, and how the cache saves them

The problem

Caddy's file server treats S3 like a disk. For every file it first asks whether the file exists and how large it is. Then it opens the file. Finally it jumps back to the beginning to deliver it. A disk does all that in microseconds. S3 cannot jump back, so the file is fetched a second time. Three requests, each 30 to 40 milliseconds, for a tiny image. And the next visitor starts from scratch.

The fix

caddy-fs-s3cache remembers what it has already fetched. The first request downloads the file and puts it on local disk. Existence, size, opening and delivery come from there afterwards. After a configurable interval, one minute by default, the module asks S3 whether the file has changed. Unchanged means a short answer without a download.

SituationRequests to S3
File fetched recentlynone
File older than the interval but unchangedone short check, no download
File new or changedone download
File does not existone check, then none for 30 seconds
Many visitors request a new file at the same timeone download for all of them

If S3 is down, Caddy keeps serving what it already has and writes a warning to the log. Only a file that was never fetched reports a server error instead of a misleading “not found”.

Measured in production

Measured on a TYPO3 website in Kubernetes. Same servers, same files, once with the existing module and once with caddy-fs-s3cache. The times are the web server's own response time, without the path to the browser.

What was requestedbeforewith the cache
Small image (1.8 kB), already cached72 to 100 ms3 to 5 ms
Small image, first request250 ms44 ms
Medium image (92 kB), already cached70 to 180 ms4 to 6 ms
File that does not exist40 ms12 to 27 ms
First request through the cache proxy in front70 to 88 ms1 to 12 ms

For scale: a single S3 access from the pod takes 28 to 42 milliseconds in the same region. Three of them made up the 70 to 100 milliseconds. The cache turns that into the price of a local file.

Serving media from S3 without a FUSE mount? I help with the Caddy build, the Caddyfile and the pod identity in Kubernetes. Get in touch →

Installation and configuration

For operators who build Caddy or FrankenPHP themselves. Two steps.

1. Link the module into the build

 

xcaddy build --with github.com/ohartwig/caddy-fs-s3cache@v1.0.1

 

In FrankenPHP the line goes in as a further --with next to github.com/dunglas/frankenphp.

2. Declare the filesystem in the Caddyfile

The first block names the bucket and the cache. The second serves everything under /media/ from it.

 

{
    filesystem media s3cache {
        bucket my-media-bucket
        region eu-north-1
        cache_dir /var/cache/caddy/media
        cache_max_size 512MiB
        revalidate_after 60s
        negative_ttl 30s
        keepalive 25s
    }
}

example.org {
    handle /media/* {
        route {
            uri strip_prefix /media
            root * .
            fs media
            file_server {
                pass_thru
            }
            respond "Not Found" 404
        }
    }
}

 

The options

OptionDefaultMeaning
bucketrequiredName of the S3 bucket
region, endpoint, use_path_stylefrom the AWS configurationAWS region; custom endpoint and path-style addressing for S3-compatible stores such as MinIO or RustFS
prefixemptyOnly one subfolder of the bucket becomes visible
cache_dirtemporary directoryWhere the copies live; emptied on start
cache_max_size512MiBUpper bound for the cache; the files unused for longest go first
revalidate_after60sHow long a copy is served without asking S3
negative_ttl30sHow long a missing file stays missing without a new check
keepalive25sKeeps the connection to S3 open in the background, so the first request after a quiet spell does not wait for a new connection; off disables it
max_idle_conns16How many connections to S3 are kept open

Permissions

The module needs read access to the files in the bucket (s3:GetObject). With keepalive on, it also needs the right to see the bucket (s3:ListBucket). Credentials do not go into the Caddyfile. As with every AWS tool they come from environment variables, the AWS configuration file or, in Kubernetes, the pod's identity.

Common pitfalls

Next step

Serving media from S3 without a network drive?

If every image on your website costs three requests to S3, or a mounted network drive gets in the way, caddy-fs-s3cache helps. I support the Caddy build, the configuration and the pod identity in Kubernetes.

Discuss the setup →

Or email me directly: mail@ole-hartwig.eu