The neutral nginx core shared by the recipe_debian_nginx /
recipe_alpine_nginx wrappers.
These build the nginx config fragments for a single-page app behind nginx — a
prerendered SvelteKit frontend
served as static files, with /api, /health and /ready reverse-proxied to a loopback
backend, SSL terminated by nginx, and an HTTP→HTTPS redirect:
- ssl_params — the shared
ssl_protocols/ssl_ciphers/session snippet
content, identical across distros, written at SSL_PARAMS_PATH on both. - rate_limit_zone — the
limit_req_zone line for the http-context rate
limiter, identical across distros. - nginx_site_core — the full site: the HTTPS
server { listen 443 ssl }
block (SSL, security headers, the /api proxy, /health, /ready, /_app,
the SPA catch-all, plus any app-specific extra_locations) and the HTTP→HTTPS
redirect server { listen 80 } block (ACME challenge + 301), plus an HTTPS
redirect block when redirect_domains names aliases (www.). The wrapper
supplies ssl_snippet_path — where the ssl: 'snippet' include points —
which is SSL_PARAMS_PATH on both distros.
The server-block content and the snippet path are the same on both distros. What
differs is placement: the caller writes the site to sites-available/ (linked into
sites-enabled/) on Debian and to http.d/ on Alpine, and the rate_limit_zone
file to conf.d/ or http.d/; and the stock default site sits in sites-enabled/
or http.d/. The public site recipes are the thin debian_nginx_spa_site /
alpine_nginx_spa_site wrappers (with the matching *_nginx_catch_all)
rather than this core.
Templating boundary
The site config is zap template content: the site recipes return the whole
{type: 'template', template, vars, kinds} object, the caller's domain and port
as its vars, so a caller writes zap.file({..., content: debian_nginx_spa_site({...})})
with domain: zap.env('DEPLOY_DOMAIN'), port: '4040', root, and csp. The body holds
{{domain}} / {{port}} as placeholders, substituted at apply time once their
references resolve, while eval-time values (the document root, the csp header
value, …) are interpolated here.
{{domain}} lands in server_name, return 301 https://… and the certificate
paths, and {{port}} in proxy_pass: a ;, { or } in either would end the
directive or the block. The content declares them a hostname and a port, so zap
refuses a value that is neither. Used whole, the content carries its kinds;
overriding kinds or rebuilding the content drops them. A redirect_domains entry
that carries a reference adds one more variable, redirect_domain_<i> (i its
index in redirect_domains), held to hostname too. Beyond those, a placeholder in
extra_locations or a literal redirect_domains entry uses {{domain}} or
{{port}}, and any other is a structural error when the config is read.
Recipes are imported at config eval time inside zap's sandboxed config eval
(fgro eval), which has no network, no writes, and no environment, and where
the clock and randomness throw. This module is therefore a set of pure functions: each only
assembles a string from its arguments — no I/O, no clock, no randomness, no
imports beyond types and the pure single_quote and letsencrypt_live_path. The
output is deterministic for given inputs (stable ordering, trailing newline), so it
stays a reliable content-addressed plan artifact.
The caller wraps each result in a file resource, e.g. for Debian
zap.file({id: 'nginx-ssl-params', path: SSL_PARAMS_PATH, content: {type: 'inline', content: ssl_params()}})
(Alpine writes the same content at the same path).