Serve a static site
This page explains how to serve a directory with root and file_server,
configure compression and cache headers, and support range requests and
single-page applications.
📌 This page describes v0.2.2.
🧾 Before you start
Section titled “🧾 Before you start”- Pingclair installed and running (Install), service stopped
while you experiment:
sudo pc service stop. - A directory to serve. The examples use
/srv/site.
📁 Serve a directory
Section titled “📁 Serve a directory”http://:8080 { root * /srv/site file_server}sudo cp Pingclairfile /etc/Pingclair/Pingclairfilesudo pingclair validate /etc/Pingclair/Pingclairfilesudo systemctl restart pingclaircurl -i http://localhost:8080/HTTP/1.1 200 OKContent-Type: text/html; charset=utf-8Last-Modified: Tue, 22 Sep 2026 04:37:54 GMTETag: "5e-6ab20622"Accept-Ranges: bytesroot * sets the site root for every request, and file_server serves files
from it. A path that does not exist answers 404.
🗜️ Compression
Section titled “🗜️ Compression”http://:8080 { root * /srv/site encode zstd gzip file_server}encode lists formats in preference order. The same 36 KB text file, requested
with three different Accept-Encoding headers:
zstd 200 65 bytes content-encoding: zstdgzip 200 301 bytes content-encoding: gzipidentity 200 36000 bytes (no content-encoding)Brotli is not implemented for proxied responses. Configuring it produces a compilation error:
Error: ❌ Configuration Error: Compile error: Unsupported feature: `encode br`: Brotli is not implemented for proxied responses; use `encode zstd gzip`The error identifies the supported alternatives. Unsupported options prevent the configuration from loading.
A site compresses responses only when encode is configured. Gzip defaults to level 5; a block can select levels 1–9 and a minimum_length (512 bytes by default). Static responses always carry Vary: Accept-Encoding. Each coding has its own ETag, and gzip tags include the level. Precompressed sidecars use their own size and modification time for validators and take precedence over cached live compression.
⏳ Caching headers
Section titled “⏳ Caching headers”file_server evaluates If-Match, If-Unmodified-Since, If-None-Match, and If-Modified-Since in that order, returning 304 or 412. A failed If-Range returns the whole file with 200. A configured ETag header is the representation’s validator: preconditions compare against the tag the site’s own header policy puts on the response, as RFC 9110 §13.1.2 asks. Ranges stream in bounded chunks with identity encoding. Canonical redirects preserve the query string and clean the path.
Set Cache-Control for the paths to which each cache lifetime applies:
http://:8080 { root * /srv/site encode zstd gzip header Cache-Control "public, max-age=60"
@assets path /assets/* header @assets Cache-Control "public, max-age=31536000, immutable"
file_server}Measured: Cache-Control: public, max-age=60 on the page, and
public, max-age=31536000, immutable on /assets/*. immutable is safe only
when a file’s name changes whenever its content does, which is why build tools
add a content hash to asset names.
Range requests require no additional configuration. For example, request the first ten bytes of a file:
HTTP/1.1 206 Partial ContentContent-Length: 10Content-Range: bytes 0-9/36000🧭 Single-page applications
Section titled “🧭 Single-page applications”An application that routes in the browser needs every unknown path to return its entry document, while real files are still served as themselves:
http://:8080 { root * /srv/site try_files {path} /index.html file_server}Measured: /assets/big.txt still answers 200 with its own content, and
/some/spa/route answers 200 with index.html. Without the try_files line,
the second request is a 404.
🗂️ Directory listings
Section titled “🗂️ Directory listings”file_server browse shows a listing for a directory that has no index file:
http://:8080 { root * /srv/site file_server browse}The listing names the entries: /assets/ shows big.txt under an Index of
heading. Enable browse only for directories intended to have public listings.
🔒 Hiding files
Section titled “🔒 Hiding files”⚠️ Dotfiles are served like any other file: .hidden answered 200 in the
configuration above. This can expose .git, .env, and editor backups. Deny requests for
these paths before the file-server handler runs:
http://:8080 { root * /srv/site
@hidden path /.* respond @hidden "Not found" 404
file_server}Measured: /.hidden answers 404, while / and /assets/big.txt still
answer 200. The status is intentionally 404 rather than 403: a 403
confirms that the file exists. /.* matches only dotfiles at the top of the
site; the file_server { hide … } option hides paths wherever they are.
⚠️ Troubleshooting
Section titled “⚠️ Troubleshooting”Unsupported feature: 'encode br'. Brotli is refused by name; useencode zstd gzip.Unknown directive 'file_server: …'. The option does not exist, andvalidatenames the refused spelling instead of ignoring it.- A directory listing instead of the page. The directory has no
index.html. Add an index file if a listing is not intended. 404for a route the application handles. The single-page fallback is missing:try_files {path} /index.html.- A change does not appear after a reload. Files are read per request, so a new file appears at once without a reload. A new or moved listener needs a restart (Run it as a service).
🧭 Next steps
Section titled “🧭 Next steps”- Reverse proxy an application: serve requests through an application upstream.
file_server: the directive reference.- Pingclairfile: matchers and route order.
