nginx-configNGINX 1.30.5

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:

  1. Replace placeholders for the server name, document root, upstream, and certificate paths.
  2. Check that the installed NGINX package contains the modules the file uses.
  3. Run nginx -t and inspect nginx -T with the same user, paths, and mounts as the service.
  4. Test unknown hosts, static assets, application routes, upstream failures, TLS, and health endpoints.
  5. 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:

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:

  1. Read the official release and security advisory notes.
  2. Update lib/version.ts (NGINX_VERSION and NGINX_IMAGE_DIGEST), the NGINX_VERSION arguments in Dockerfile and both Compose services, scripts/smoke-image.sh, generated examples, and dated documentation. lib/version.ts is canonical for generated text and for the smoke tests’ image, but these build and release pins are explicit interfaces and must stay in sync.
  3. Refresh the pinned base-image digest and lockfile as applicable. Verify the digest belongs to the intended multi-platform tag.
  4. Run node scripts/check-nginx-version.mjs and 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.
  5. 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.