Operations, containers, and releases
The web/ directory is the client-only configuration builder (Astro). It is
deployed separately as static assets with Wrangler; the UI downloads
configuration text and does not run it. The container is a hardened NGINX
runtime for user sites and services. It ships a small welcome page, uses port
8080, accepts Host: localhost, and rejects unknown hosts with 444.
Builder deployment
The app, its Wrangler configuration, and its headers are described in
web/README.md. From a checked-out repository:
cd web
npm ci
npm run deploy:dry-run
npm run deploy
The deploy command requires the operator’s Cloudflare authentication; this repository records no deployment result or public URL, and it has no GitHub Actions deployment workflow for the app. A user still validates and mounts the downloaded file into the NGINX runtime.
Local container
Build and start the local image:
docker compose config
docker compose up --build nginx
Confirm the default page and health endpoint with the intended Host header:
docker compose ps nginx
curl -i --header 'Host: localhost' http://localhost:8080/
curl -i --header 'Host: localhost' http://localhost:8080/healthz
The image should report a healthy HTTP response. Stop it with Ctrl-C or:
docker compose down
For a direct image build:
docker build -t nginx-config:local .
docker run --rm -p 8080:8080 nginx-config:local
The image includes the generated default /etc/nginx/nginx.conf and a small
site under /usr/share/nginx/html. For a real workload, mount the complete
reviewed configuration and the site or service content read-only:
NGINX_CONFIG=./sites-example/container.conf \
NGINX_CONTENT=./path/to/public \
NGINX_SERVER_NAME=localhost \
docker compose up --build nginx
The mounted configuration must be made for the Container target, with HTTPS
off and a server name equal to NGINX_SERVER_NAME. sites-example/container.conf
is an SPA for localhost. A config for the Server or VM target will not start
here: it binds port 80 and writes under /var/log. The config listens on 8080
and uses /usr/share/nginx/html for static content. The two mounts replace the image
defaults at /etc/nginx/nginx.conf and /usr/share/nginx/html. Set
NGINX_SERVER_NAME to the mounted configuration’s server_name; localhost
is the local example used by the health check.
Compose also has an opt-in TLS service. Create a Container-target configuration
with HTTPS set to your own certificate files (it listens on 8443 and uses
certificate paths under /etc/nginx/tls), then mount it and its
certificate directory. Set NGINX_TLS_SERVER_NAME to the same name as the
configuration’s server_name; this lets the local health check send the right
TLS SNI. The mounted directory and files must be readable by UID 101:
NGINX_TLS_CONFIG=./sites-example/container-ssl.conf \
NGINX_TLS_CERTS=./ssl \
NGINX_TLS_SERVER_NAME=example.com \
docker compose --profile tls up --build nginx-tls
sites-example/container-ssl.conf expects ./ssl/example.com/fullchain.pem and
./ssl/example.com/privkey.pem. scripts/smoke-compose.mjs runs both Compose
commands of this page (the HTTP and the TLS service) and checks that they become
healthy.
The default HTTP service stays on 8080. Do not mount production private keys unless the host permissions and the TLS configuration have been reviewed.
Use Compose’s read-only root filesystem, a small tmpfs for /tmp, dropped capabilities, and no-new-privileges settings when exposing the image beyond a developer laptop. Keep the published port narrow. Do not mount a user’s production NGINX directory into the runtime container.
The runtime image contains no Node toolchain, dist/ tree, or builder source.
It uses the Docker Official nginx:1.30.5-alpine image, pinned by index digest
sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94
(checked with docker buildx imagetools inspect on 2026-10-01), and its shipped
numeric UID 101:101, with port 8080 and writable temporary paths under /tmp.
That build has OpenSSL 3.5.8, HTTP/2, HTTP/3, gzip_static, realip, and
stub_status, and it ships ngx_http_acme_module.so. It has no Brotli or zstd
module. The canonical renderer generates the default config and checked-in
profiles; validate a downloaded or edited full config before mounting it. Do
not mix these paths or entrypoint assumptions with the separate nginxinc
unprivileged image.
Exported configurations
The browser download is text. Before installing it:
- Replace placeholders for the server name, document root, upstream, and certificate paths.
- Check that the installed NGINX package contains the modules the file uses.
- Run nginx -t and inspect nginx -T with the same user, paths, and mounts as the service.
- Test unknown hosts, static assets, application routes, upstream failures, TLS, and health endpoints.
- Reload gracefully and make a real request before declaring the change live.
The container target’s HTTP listener defaults to port 8080 (HTTPS 8443); the
server target uses 80 and 443. The reverse proxy profile’s loopback upstream
defaults to 127.0.0.1:3000, keeping the application listener separate from
NGINX; the validator rejects loopback upstreams that reuse either enabled NGINX
listener port. A named service such as backend:8080 may use the same numeric
port because it runs in a different network namespace.
Automatic certificates in a container
With HTTPS set to Automatic, the NGINX ACME module stores its account and
certificates under /var/cache/nginx/acme-letsencrypt. The image’s root
filesystem is read-only, so mount a persistent volume there, and make user 101
own it after Docker has created the volume contents. A fresh named volume
takes the ownership of the image directory (root) on its first mount, which
would make NGINX fail with mkdir() ... failed (13: Permission denied). The
generated deploy steps therefore run, once, before the first start:
docker volume create nginx-acme
docker run --rm --user 0 --entrypoint sh -v nginx-acme:/var/cache/nginx \
nginx:1.30.5-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 \
-c 'mkdir -p /var/cache/nginx/acme-letsencrypt && chown -R 101:101 /var/cache/nginx'
Publish port 80 as well (-p 80:8080): Let’s Encrypt checks the domain with
HTTP-01 on port 80, and the HTTP server answers the challenge before it
redirects to HTTPS. scripts/smoke-acme.mjs runs this whole path against a
local Pebble ACME server: it issues separate certificates for the canonical name
and the www alias, then restarts NGINX on the same volume and checks that the
same certificates are served with no new order. Use the staging option until
issuing works.
Your own certificates in a container
The HTTP server serves /.well-known/acme-challenge/ from
/var/cache/nginx/acme-challenge, in every profile and also with HTTPS off.
Mount a host folder there read-only. The first certificate needs the HTTP
server, so start the container with the config set to HTTPS Off, run
certbot certonly --webroot -w /acme in a certbot container that mounts the
same folder, copy the files to ./tls/<name>/ (the key must be readable by
UID 101), then start the container again with the HTTPS config. The generated
Deploy steps print each command.
The renderer and builder cannot configure your service manager, DNS, firewall, certificate renewal, trusted load balancer CIDRs, upstream CA, or cache invalidation. Keep those decisions in deployment configuration and review them separately.
Logs and health
The runtime image should write access and error logs to stdout/stderr so the container runtime can collect them. A health check must make an HTTP request to the local health endpoint; checking only that an nginx process exists can miss a broken listener or configuration.
On a host, keep request and upstream timing fields in access logs and rotate them. Use error level warn or error during normal operation. Debug logging is temporary, expensive, and may expose request data.
Image tags and repeatability
Use a full stable NGINX version for the runtime base and update it when the security advisory or base-image status changes. Pin a digest for a release when reproducibility matters, then refresh it through a reviewed update. A permanent digest misses fixes; a floating tag changes silently.
Tag images with the source commit and release version. The changed runtime
purpose is released as v3.0.0; use its image tag after the release workflow
records the digest. A sha-<commit> tag is traceable to a source commit but
remains a mutable registry tag; only a digest reference is immutable:
docker pull ghcr.io/risan/nginx-config:3.0.0
docker pull ghcr.io/risan/nginx-config@sha256:<published-digest>
The runtime image’s NGINX version is not proof that another host package can run an exported configuration; inspect each deployment separately.
GitHub Container Registry
The repository uses CI for pull requests and main, and publish-image for releases. Their release policy is:
- Pull requests and main pushes build and test without registry login.
- Publishing occurs only for an exact stable
vMAJOR.MINOR.PATCHtag or an explicitly requested workflow dispatch. Leading zeroes and suffixes are rejected. - A manual dispatch publishes only traceable SHA-derived tags; tags can move, so only a digest is immutable. It never moves latest, major, or minor aliases. Only a pushed stable release may move those aliases.
- The publish job uses the repository GITHUB_TOKEN with only
contents: readandpackages: writepermissions. - Third-party actions are pinned to full commit SHAs.
- Each image receives a commit-SHA tag and standard OCI source, revision, and version labels. latest moves only during an intentional stable release.
- Buildx attaches OCI provenance and an SBOM to published multi-architecture images.
GitHub may create a new GHCR package as private. An owner must deliberately change package visibility and access policy. After publishing, read the package visibility and published manifest back from GitHub, then pull the recorded digest. Do not infer public pull access from repository visibility. Publishing is configured here; this checkout does not publish an image by itself.
A release handoff is:
git switch main
git pull --ff-only
git tag -a v3.0.0 -m "Release v3.0.0"
git push origin v3.0.0
Review the generated image, digest, labels, health check, package visibility,
and manifest platforms after the workflow finishes. The release workflow builds
linux/amd64 and linux/arm64; the published digest is the artifact to
inspect, not the earlier local single-platform build. For example:
docker buildx imagetools inspect ghcr.io/risan/nginx-config:3.0.0
docker pull ghcr.io/risan/nginx-config@sha256:<published-digest>
docker image inspect ghcr.io/risan/nginx-config@sha256:<published-digest> \
--format '{{json .Config.Labels}}'
docker run --rm -d --name nginx-config-release -p 8080:8080 \
ghcr.io/risan/nginx-config@sha256:<published-digest>
curl -fsS --header 'Host: localhost' http://127.0.0.1:8080/healthz
docker stop nginx-config-release
The explicit Host: localhost header is required here because the image’s
default server intentionally rejects unknown host names with status 444.
Confirm the manifest lists both platforms, OCI source/revision/version labels match the reviewed commit, the digest pull is healthy on a native runner, and the package visibility is the intended value. Record those receipts with the release; a multi-architecture build alone is not post-push proof.
Updating NGINX
When a stable patch is released:
- Read the official release and security advisory notes.
- Update
lib/version.ts(NGINX_VERSIONandNGINX_IMAGE_DIGEST), theNGINX_VERSIONarguments inDockerfileand both Compose services,scripts/smoke-image.sh, generated examples, and dated documentation.lib/version.tsis canonical for generated text and for the smoke tests’ image, but these build and release pins are explicit interfaces and must stay in sync. - Refresh the pinned base-image digest and lockfile as applicable. Verify the digest belongs to the intended multi-platform tag.
- Run
node scripts/check-nginx-version.mjsand the full renderer, type, browser, NGINX syntax matrix (scripts/verify-nginx-configs.mjs), runtime smoke (smoke-image.sh,smoke-proxy,smoke-cache,smoke-php,smoke-static,smoke-tls,smoke-http3,smoke-acme,smoke-resolve), and container checks; the version checker is a guard, not a substitute for reviewing every pin and example. - Compare the expanded configuration and test TLS, static, proxy, PHP, and error paths before tagging.
Do not call a version “latest” in a long-lived document without recording the date and linking the official download page. The free stable and mainline branches have different change rates; choose stable by default and test mainline separately.
Graceful host reload
A safe host update keeps a known-good configuration:
sudo cp -a /etc/nginx /etc/nginx.backup.$(date +%Y%m%d%H%M%S)
sudo nginx -t
sudo nginx -T > /tmp/nginx-expanded.conf
sudo nginx -s reload
curl -fsS https://example.com/healthz
If the test fails, do not reload. If a post-reload request fails, inspect the error log and restore the last known-good file using the host’s normal service procedure. Keep certificates, ownership, and distribution-managed includes intact while rolling back.