recipe_nginx.ts

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).

view source

Declarations
#

20 declarations

nginx_catch_all_core
#

recipe_nginx.ts view source

(default_path: string, catch_all_path: string, options?: NginxCatchAllOptions | undefined): NginxCatchAll import {nginx_catch_all_core} from '@fuzdev/zap/recipe_nginx.js';

The neutral core of the *_nginx_catch_all recipes: the stock-default removal at default_path and the catch-all server at catch_all_path.

The site recipes declare no default_server, so without a catch-all nginx answers a request for any other name (or none) with the first server on the port, the site, and the proxied locations forward that name to the backend as $host. The catch-all closes those requests instead. Order its file after the removal (the stock default site is itself a default_server, and two fail nginx -t, the file's validate) and after the SSL parameters it includes. A site meant to answer any host name declares only the removal.

default_path

type string

catch_all_path

type string

options?

optional

returns

NginxCatchAll

the removal script payload and the catch-all file payload.

nginx_catch_all_server
#

recipe_nginx.ts view source

(options: NginxCatchAllOptions & { ssl_snippet_path: string; }): string import {nginx_catch_all_server} from '@fuzdev/zap/recipe_nginx.js';

Build the catch-all server text: the default_server on every socket the site recipe listens on (port 80 and 443, IPv4 and IPv6), answering any request for a name no other server has with nginx's 444 (the connection closed, no response), and refusing the TLS handshake (ssl_reject_handshake), so it needs no certificate and shows none. It carries the site's SSL parameters (see NginxCatchAllOptions.ssl), ssl_snippet_path being where the 'snippet' ones are, as the site recipe takes it.

options

type NginxCatchAllOptions & { ssl_snippet_path: string; }

returns

string

the server block text, terminated by a trailing newline.

nginx_config_verify
#

recipe_nginx.ts view source

(): VerifyResource import {nginx_config_verify} from '@fuzdev/zap/recipe_nginx.js';

The verify payload that re-checks the on-disk nginx config with NGINX_VALIDATE on every plan (detection reads every resource live).

The files' validate gates each write, but a config the running nginx can't reload onto can still reach disk — a hand edit, an apply killed mid-rollback, a site that only becomes active when its sites-enabled link lands — and its bytes match the declared ones, so the files alone read as converged. A failing check shows as a pending change and blocks zap deploy's preflight. Spread it into a zap.verify that depends on the nginx service.

returns

VerifyResource

the verify assertions.

nginx_remove_default_core
#

recipe_nginx.ts view source

(default_path: string): NginxRemoveDefaultScript import {nginx_remove_default_core} from '@fuzdev/zap/recipe_nginx.js';

The neutral core of the stock-default removal: remove the default-site file at default_path (so it can't shadow the real site, or claim default_server beside the catch-all) and validate the config with nginx -t. The check reads converged once nginx is installed (its nginx.conf is on disk) and nothing is at the path, not even a dangling link; before nginx is installed it reads pending, so a fresh host plans the removal and runs it after the package it is ordered behind, instead of reading the not-yet-installed default as already gone. The distro wrappers differ only in that path — Debian's sites-enabled/default symlink vs Alpine's http.d/default.conf.

default_path

type string

returns

NginxRemoveDefaultScript

the script check/run pair.

nginx_site_core
#

recipe_nginx.ts view source

(options: NginxSiteCoreOptions): NginxSiteContent import {nginx_site_core} from '@fuzdev/zap/recipe_nginx.js';

Build the full nginx site config for a single-page app behind nginx — the neutral core the distro wrappers call.

Composes the HTTPS server block — SSL cert + the SSL params (snippet or certbot), HTTP/2 unless http2 is off, server_tokens off, an optional server-level client_max_body_size, the document root, the security-header set with the CSP (repeated in the /_app and location / blocks, which set headers of their own and so inherit none; the proxied locations inherit it), a rate-limited /api reverse proxy (with WebSocket upgrade headers + Authorization forward + timeouts), /health, the /ready deploy-readiness gate (zap's post-deploy poll target), the immutable /_app static block, any extra_locations, an optional error_page 404, and the SPA catch-all location / (no-cache + the try_files fallback) — plus the HTTP→HTTPS redirect server block (ACME challenge served plain, everything else 301'd), and, for any redirect_domains, an HTTPS server that 301s them to {{domain}}.

Neither server is a default_server: the *_nginx_catch_all recipes add one that gives a request for any other host name no answer, so the proxied locations forward only this site's names to the backend as $host.

{{domain}} / {{port}} are placeholders for zap's template engine, their values the options' domain and port; root/csp/etc. are interpolated from the options. Prefer the debian_nginx_spa_site / alpine_nginx_spa_site wrappers, which supply ssl_snippet_path for you.

options

returns

NginxSiteContent

the template content: the complete site config (terminated by a trailing newline), domain and port as its vars with a redirect_domain_<i> for each redirect_domains entry carrying a reference, and their kinds.

throws

  • Error - if `csp` holds a `"`, a `\`, a `$`, or a line break, if an `extra_locations` block sets `add_header` with no non-empty `Content-Security-Policy`, if a `redirect_domains` entry carrying a reference holds a placeholder, or if another input writes a `redirect_domain_<i>` placeholder such an entry fills

NGINX_VALIDATE
#

recipe_nginx.ts view source

"sudo nginx -t" import {NGINX_VALIDATE} from '@fuzdev/zap/recipe_nginx.js';

The validate command for a file holding nginx config — a site, a snippet, or an http-context include. nginx -t checks the whole tree the running config loads, so a write that would make the next reload fail is rolled back instead of left on disk. sudo because the check reads the TLS private keys.

NginxCatchAll
#

recipe_nginx.ts view source

NginxCatchAll import type {NginxCatchAll} from '@fuzdev/zap/recipe_nginx.js';

The two payloads the *_nginx_catch_all recipes return, each spread into its resource.

remove_default

The stock-default removal, for a zap.script.

type NginxRemoveDefaultScript

catch_all

The catch-all server, for a zap.file ordered after the removal.

type Required<Pick<FileResource, "path" | "content" | "validate">>

NginxCatchAllOptions
#

recipe_nginx.ts view source

NginxCatchAllOptions import type {NginxCatchAllOptions} from '@fuzdev/zap/recipe_nginx.js';

Options for the *_nginx_catch_all recipes.

http2?

Whether the site serves HTTP/2: pass the site recipe's http2. The catch-all's listen 443 sockets carry the same http2 parameter as the site's, so the two servers on one address and port agree on it. Defaults to true, the site's default.

type boolean

ssl?

How the site wires its SSL parameters: pass the site recipe's ssl. Defaults to 'snippet', the site's default. The catch-all includes the same parameters, because as the default_server it is the server whose SSL settings nginx starts every handshake with — its protocols, ciphers and session cache apply to the site's handshakes too, and without them a distro's own defaults would (Debian's allow TLS 1.2 ciphers without forward secrecy). That file must be on disk before the catch-all is written, or its nginx -t fails: order the catch-all after the ssl_params file ('snippet') or the certificate issuance ('certbot').

type "snippet" | "certbot"

NginxRedirectDomainVar
#

recipe_nginx.ts view source

`redirect_domain_${number}` import type {NginxRedirectDomainVar} from '@fuzdev/zap/recipe_nginx.js';

The variable a redirect_domains entry carrying a reference becomes, named for the entry's index.

NginxRemoveDefaultScript
#

recipe_nginx.ts view source

NginxRemoveDefaultScript import type {NginxRemoveDefaultScript} from '@fuzdev/zap/recipe_nginx.js';

The check/run pair of the stock-default removal nginx_catch_all_core returns.

check

Check command — exit 0 means the script is already applied (skip).

type string

run

The command the planner emits when the check fails.

type string

NginxSiteContent
#

recipe_nginx.ts view source

NginxSiteContent import type {NginxSiteContent} from '@fuzdev/zap/recipe_nginx.js';

The template content a site recipe returns: the body, its values, their kinds — domain and port, plus one redirect_domain_<i> per redirect_domains entry carrying a reference.

inheritance

vars

type { domain: string; port: string | number; } & Record<NginxRedirectDomainVar, string>

kinds

type { readonly domain: "hostname"; readonly port: "port"; } & Record<NginxRedirectDomainVar, "hostname">

NginxSiteCoreOptions
#

recipe_nginx.ts view source

NginxSiteCoreOptions import type {NginxSiteCoreOptions} from '@fuzdev/zap/recipe_nginx.js';

Options for nginx_site_core — the public-recipe inputs plus the path the wrapper supplies.

inheritance

ssl_snippet_path

The on-disk path the ssl: 'snippet' include points at — the one bit of the site config the distro wrappers supply, SSL_PARAMS_PATH on both. Unused when ssl === 'certbot'.

type string

NginxSpaSiteOptions
#

recipe_nginx.ts view source

NginxSpaSiteOptions import type {NginxSpaSiteOptions} from '@fuzdev/zap/recipe_nginx.js';

Options for the public site recipes (debian_nginx_spa_site / alpine_nginx_spa_site) — the portable, distro-agnostic inputs. The ssl_snippet_path is supplied by the wrapper, not here.

domain

The site's host name, {{domain}}'s value — typically a reference, zap.env('DEPLOY_DOMAIN'). Held to hostname once it resolves.

type string

port

The backend's loopback port, {{port}}'s value. Held to port.

type string | number

root

Document root for the static frontend, e.g. `` ${CURRENT_LINK}/build ``.

type string

csp

The Content-Security-Policy header value (interpolated, not a placeholder). One holding a ", a \, a $, or a line break is refused: inside the quoted header value the first three end it or read as an escape or an nginx variable.

type string

ssl?

How SSL is wired into the HTTPS server.

  • 'snippet' (default) — include <ssl_snippet_path>; (paired with the ssl_params recipe written to that path; zap owns the full config).
  • 'certbot' — include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; (certbot-managed params).

type "snippet" | "certbot"

http2?

Serve HTTP/2 in the HTTPS server. Defaults to true.

On by default because the alternative is a worse baseline for every site: without it a browser is capped at six connections per origin and repeats every request and response header uncompressed, which dominates the wire cost of any page serving many small assets. HPACK plus multiplexing removes both, and ALPN falls back to HTTP/1.1 for clients that can't negotiate h2, so there is no client-compatibility cost.

Emitted as the listen ... http2 parameter rather than the http2 on; directive. The directive is the modern spelling, but it only exists from nginx 1.25.1; on anything older it is an unknown directive that fails nginx -t outright, and Ubuntu 24.04 LTS ships nginx 1.24. The parameter form works on both sides of 1.25.1 (deprecated above it, still honored, one warning logged at start), so it is the spelling that delivers h2 on every supported host.

One thing to know before turning it off — and the reason this is an option at all rather than unconditional:

  • limit_req burst= absorbs arrival pattern, and h2 changes it. The /api burst was sized against requests trickling in six at a time; over one multiplexed connection a page's whole RPC fan-out can arrive at once. The zone rate is unaffected (limit_req counts requests, not connections), but a site with a heavy fan-out on load may need a larger api_burst than it did on HTTP/1.1.

type boolean

client_max_body_size?

Server-level client_max_body_size cap. Emitted as its own line (with the surrounding blank lines) only when set; omit for nginx's default.

type string

client_max_body_size_comment?

App-specific comment line rendered directly above client_max_body_size (without the leading # , which is added here). Ignored unless client_max_body_size is set; omit for no comment.

type string

api_burst?

/api limit_req burst=. Defaults to 50.

type number

spa_try_files?

The location / try_files arguments (the SPA fallback chain). Defaults to '$uri $uri/index.html /200.html'.

type string

error_page_404?

error_page 404 <target>; in the HTTPS server, e.g. '/404.html'. Emitted only when set.

type string

extra_locations?

App-specific location blocks injected into the HTTPS server verbatim (already indented to the server-block level, no leading/trailing blank lines), placed just before the /_app static block. A block with no add_header inherits the server's security headers and CSP; one with its own inherits none, so it renders site_security_header_directives(csp) beside them, or a Content-Security-Policy of its own. A block (a location, or an if in one) that sets add_header with no Content-Security-Policy is refused.

type string

acme_webroot?

ACME HTTP-01 challenge webroot for the redirect server. Defaults to /var/www/certbot.

type string

redirect_domains?

Further host names that 301 to https://{{domain}} — e.g. ['www.{{domain}}'] to send www. to the apex. Each joins the HTTP server's server_name (so the ACME challenge is served for it too) and gets a lean HTTPS server that only redirects, on the site's certificate, so the cert must cover them: pass the same names to the certbot recipes' extra_domains. Defaults to none.

A literal name may use the {{domain}} placeholder, substituted with the rest of the template. A name that carries a reference (zap.env('MIRROR_DOMAIN'), whole or interpolated) becomes its own variable, redirect_domain_<i> for the entry at index i, held to hostname once it resolves; it takes no placeholder, since a variable's value is never read for one.

type readonly string[]

rate_limit_zone
#

recipe_nginx.ts view source

(options?: RateLimitZoneOptions | undefined): string import {rate_limit_zone} from '@fuzdev/zap/recipe_nginx.js';

Build the http-context rate-limit zone line (rate_limit.conf, which the caller writes under conf.d/ on Debian and http.d/ on Alpine).

Emits the single directive the /api and /ready limit_req directives reference, limit_req_zone $binary_remote_addr zone=<zone>:<size> rate=<rate>;, keyed on the client IP; the defaults suit a single-page app's API.

options?

optional

returns

string

the directive text, terminated by a trailing newline.

RATE_LIMIT_ZONE
#

recipe_nginx.ts view source

"global" import {RATE_LIMIT_ZONE} from '@fuzdev/zap/recipe_nginx.js';

The single rate-limit zone name, shared by rate_limit_zone (which defines the zone) and nginx_site_core (whose /api + /ready limit_req directives reference it). One constant so the definition and the references can't drift — a limit_req naming a zone no limit_req_zone defined fails nginx -t. (This is why the zone name isn't an option: it would let the two sides diverge silently.)

A fixed name assumes one zap-managed site per host. Two of them would write the same rate_limit.conf path with each other's rate — fighting on every apply — and co-declaring the zone in one http context fails nginx -t on the duplicate. Co-hosting needs a per-site zone name (and path) before it needs anything else here.

Exported so a config's own limit_req (in an extra_locations block) references the zone by this constant rather than repeating its name.

RateLimitZoneOptions
#

recipe_nginx.ts view source

RateLimitZoneOptions import type {RateLimitZoneOptions} from '@fuzdev/zap/recipe_nginx.js';

Options for rate_limit_zone.

size?

Shared-memory zone size. Defaults to '10m'.

type string

rate?

rate= cap. Defaults to '10r/s'.

type string

SECURITY_HEADER_DIRECTIVES
#

recipe_nginx.ts view source

readonly string[] import {SECURITY_HEADER_DIRECTIVES} from '@fuzdev/zap/recipe_nginx.js';

The site-independent security-header add_header … always; directives: HSTS, X-Frame-Options, nosniff, and Referrer-Policy, in render order, unindented. The Content-Security-Policy is per site, so it is not here: site_security_header_directives appends it.

nginx drops inherited add_header directives in any block that sets its own, so the set can't be declared once at the server level. A block that sets its own headers repeats the whole set, the CSP included, or it serves without them: an extra_locations block renders site_security_header_directives(csp). A block with a policy of its own (a sandboxing CSP for user-uploaded bytes) renders these and its own Content-Security-Policy instead.

site_security_header_directives
#

recipe_nginx.ts view source

(csp: string): string[] import {site_security_header_directives} from '@fuzdev/zap/recipe_nginx.js';

The full security-header set a site's server block declares, as the directives a block that sets its own add_header repeats: SECURITY_HEADER_DIRECTIVES and the site's Content-Security-Policy, in render order, unindented.

csp

the site's Content-Security-Policy value, the csp the site recipe is given

type string

returns

string[]

the directives, one per entry.

ssl_params
#

recipe_nginx.ts view source

(): string import {ssl_params} from '@fuzdev/zap/recipe_nginx.js';

Build the shared SSL-parameters snippet content.

The ssl_protocols/ssl_ciphers/ssl_prefer_server_ciphers + ssl_session_* block both server contexts include to avoid duplication. The value is fixed across distros, and so is its path (SSL_PARAMS_PATH), so this takes no parameters.

The session-cache zone is named fuz_app_SSL, not the conventional SSL, because Alpine's stock nginx.conf already declares ssl_session_cache shared:SSL:2m in the http block — a second SSL zone at a different size makes nginx -t fail with a zone-size conflict. A unique name sidesteps any distro's built-in zone (Debian's nginx.conf declares none, so it's harmless there).

returns

string

the complete snippet text, terminated by a trailing newline.

SSL_PARAMS_PATH
#

recipe_nginx.ts view source

"/etc/nginx/snippets/ssl_params.conf" import {SSL_PARAMS_PATH} from '@fuzdev/zap/recipe_nginx.js';

The on-disk path of the ssl_params snippet, the same on Debian and Alpine: the distro site wrappers pass it as ssl_snippet_path, and a config writes the ssl_params file here. Under snippets/, never Alpine's auto-included http.d/, which would declare the session zone a second time.

Depends on
#

Imported by
#