Skip to content

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.

  • Pingclair installed and running (Install), service stopped while you experiment: sudo pc service stop.
  • A directory to serve. The examples use /srv/site.
http://:8080 {
root * /srv/site
file_server
}
Terminal window
sudo cp Pingclairfile /etc/Pingclair/Pingclairfile
sudo pingclair validate /etc/Pingclair/Pingclairfile
sudo systemctl restart pingclair
curl -i http://localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Last-Modified: Tue, 22 Sep 2026 04:37:54 GMT
ETag: "5e-6ab20622"
Accept-Ranges: bytes

root * sets the site root for every request, and file_server serves files from it. A path that does not exist answers 404.

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: zstd
gzip 200 301 bytes content-encoding: gzip
identity 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.

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 Content
Content-Length: 10
Content-Range: bytes 0-9/36000

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.

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.

⚠️ 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.

  • Unsupported feature: 'encode br'. Brotli is refused by name; use encode zstd gzip.
  • Unknown directive 'file_server: …'. The option does not exist, and validate names 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.
  • 404 for 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).