
A production-grade build prompt
May 9, 2026
An nginx + TLS reverse proxy is the load-bearing piece of almost every public-facing web service. The job is to put one well-behaved process in front of N badly-behaved ones: terminate TLS once, route by hostname or path, absorb slow clients, throttle abuse, and hide your internal topology from the open internet. If a service faces the public web and isn't behind a managed load balancer, there is almost always an nginx (or Caddy, Envoy, HAProxy) doing this work in front of it.
This post walks through building that pattern end-to-end on OpenFactory: four buildable VMs — an nginx edge VM acting as the border gateway plus three useful backend services behind it (a status page, a pastebin, and a URL shortener) — all generated from a single prompt and shipped as bootable ISOs. The prompt is production-shaped, not a tutorial fixture: modern TLS profile, HSTS and the rest of the security headers, per-IP rate limiting, structured JSON access logs, hardened systemd sandboxes, hardened SSH, an nftables default-deny ruleset, and certbot wired up so the bootstrap self-signed cert auto-replaces with an ACME-issued one as soon as you point a real domain at it.
api.example.com goes to service A, example.com/admin goes to service B, all on one IP and one cert.limit_req_zone, IP filters, request-size caps stop traffic before it ever reaches the app.python -m http.server behind Cloudflare.The lazy path is apt install nginx, edit a config, hope it survives reboots. That works for one box and falls apart the moment you have a fleet, a teammate who didn't write the config, or an auditor asking how you know what's actually running.
nginx-hawc-web-cluster fixture (edge + 2 app + Postgres primary/replica + Redis) without leaving the chat UI.apt install-ed nginx” is not an audit answer. A signed manifest of the ISO that runs the edge, plus runtime attestation confirming the kernel is enforcing what was configured, is.Four Debian Trixie VMs — nginx-edge as the public-facing border gateway plus three backend services on the runner network. nginx-edge is the only VM the public internet reaches; the three services are lab-subnet only, fronted entirely by the edge:
nginx-edge (10.55.0.10) — nginx on HTTP/2 with TLS 1.2/1.3 only and a Mozilla-intermediate cipher suite, OCSP stapling, HSTS preload + the rest of the security-header set, a per-IP limit_req_zone rate limiter, certbot wired in with a 90-day self-signed bootstrap cert that swaps for an ACME-issued one the moment /etc/edge/domain is populated, three named upstreams (one per backend), path-based routing, structured JSON access logs, an internal-only /nginx_status endpoint, and a hardened systemd unit running it all.status-app (10.55.0.20:8000) — service status page. GET /status returns JSON with version, uptime, and the last 10 events; GET /status.html renders the same data as HTML; GET /metrics exposes Prometheus text; POST /event (lab-only, X-Internal: 1 required) appends to /var/lib/status/events.jsonl.paste-app (10.55.0.30:8000) — a tiny pastebin. POST /p with up to 64 KB of text returns a secrets.token_urlsafe(6) ID; GET /p/<id> reads it back as plain text from /var/lib/paste/.shortlink-app (10.55.0.40:8000) — URL shortener with a file-locked JSON store at /var/lib/shortlink/links.json. POST /r (lab-only) registers a target; GET /r/<id> issues a 302 redirect. Seeded with /r/about and /r/blog on first boot so the redirect path is wired before any operator interaction.All four VMs layer a 10.55.0.0/24 lab alias on top of the default DHCP interface, run with nftables in default-deny with explicit per-node allow rules (edge: :22 from runner, :80/:443 from anywhere; each app: :22 and :8000 from the lab subnet only), and ship with SSH locked down to key-only auth, no root login, and a modern KEX/cipher set. auditd is on for every VM. The three backend services share a SystemCallFilter=@system-service sandbox with MemoryDenyWriteExecute, LockPersonality, read-only root, structured JSON request logs to journald, SIGTERM-driven graceful drain, and explicit memory / CPU / task caps.
Public traffic terminates at nginx-edge — the only VM with :80 / :443 open to the public network. The edge applies its security and rate-limit policy, then proxies via path rules to one of three upstream services on the runner network.

Thirteen scenario assertions cover the moving parts: ports listen on every VM, each backend's /healthz returns ok, the edge can reach all three upstreams, the TLS handshake terminates correctly, legacy TLS (1.0/1.1) is rejected, plain HTTP returns a 301/308, the security headers are all present, the rate limiter engages under burst, the systemd sandbox is applied on every app, the access log is JSON-shaped, and the full HTTPS-through-proxy path works for each service: a status JSON fetch, a paste round-trip (POST then GET back the snippet), and a shortlink redirect to the seeded target.
The flow is short. You paste the prompt into the chat builder in your browser, and the builder streams back a build-plan preview — topology, per-node recipes, and the scenario contract that will run after boot. You click Build group, and OpenFactory fans the recipes out to per-node ISOs. When every ISO is built, you boot the group on the runner network and the scenario assertions run across the live VMs.
Paste this verbatim into the chat builder. Nothing above or below it — the builder expects the prompt body to start at the "Build a multi-node lab..." line.
Build a multi-node lab named `nginx-tls-edge`.
Output discipline: production-shaped configuration, not a tutorial fixture. The edge fronts a small fleet of useful backend services via path-based routing on a single domain. Bake in modern TLS, security headers, rate limiting, structured access logs, hardened systemd units, hardened SSH, an nftables default-deny ruleset on every VM, and an ACME-ready certbot wiring with a 90-day self-signed bootstrap cert that auto-replaces itself when `/etc/edge/domain` is populated. Use Python stdlib for the backend services so the build stays deterministic, but treat each as a production-shaped service (graceful shutdown, structured JSON logs to stdout, hardened sandbox, persistent state under `/var/lib/<svc>`).
Recipe authoring rules: write every config file, systemd unit, nginx file, Python service script, runbook, and notes file inline with `os.startup_scripts[].command` heredocs. Use `run_as: "root"` and `after: "network-online.target"` on each startup script. Do not emit `os.file_attachments`; this prompt has no uploaded files. Every recipe top-level `test_config` must be `{ "enabled": false, "tests": [] }` so only the group scenario runs.
## Topology
Create four buildable `debian-trixie` nodes, all `x86_64`, SSH key-only (no passwords, no root login), DHCP/default route intact with lab aliases, DNS `1.1.1.1` and `8.8.8.8`, user `ops` in `sudo`, nftables default-deny INPUT. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`. The `nginx-edge` VM plays the public-facing border-gateway role (TLS termination, public-facing nftables rules, proxying into the lab subnet).
- `nginx-edge`: role `edge`, 4 GB RAM, 16 GB disk, alias `10.55.0.10/24`, x `160`, y `200`
- `status-app`: role `app`, 2 GB RAM, 16 GB disk, alias `10.55.0.20/24`, x `360`, y `200`
- `paste-app`: role `app`, 2 GB RAM, 16 GB disk, alias `10.55.0.30/24`, x `560`, y `200`
- `shortlink-app`: role `app`, 2 GB RAM, 16 GB disk, alias `10.55.0.40/24`, x `760`, y `200`
Connections (L7 proxy paths over the runner network):
- `nginx-edge` → `status-app:8000` (paths `/status`, `/status.html`, `/metrics`, `/event`).
- `nginx-edge` → `paste-app:8000` (paths `/p`, `/p/*`).
- `nginx-edge` → `shortlink-app:8000` (paths `/r`, `/r/*`).
## Common Recipe Requirements
All four VMs: features `headless`, `ssh`, `audit-logging`; packages `openssh-server`, `sudo`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`, `nftables`, `logrotate`, `auditd`. SSH config sets `PasswordAuthentication no`, `PermitRootLogin no`, `KbdInteractiveAuthentication no`, and restricts KEX / ciphers / MACs to modern Mozilla-intermediate defaults. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, `ip addr add <alias> dev "$IFACE" || true`. nftables loads `/etc/nftables.conf` with INPUT default-drop, allow established/related and loopback, allow DHCP client replies, and explicit allow rules per node (edge: `:22` from the runner/control-plane network plus `10.55.0.0/24`, `:80`/`:443` from anywhere; each app: `:22` from the runner/control-plane network plus `10.55.0.0/24`, and `:8000` from `10.55.0.0/24` only). Every service unit sets `NoNewPrivileges=true`, `ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`, `ProtectKernelTunables=true`, `ProtectKernelModules=true`, `ProtectControlGroups=true`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`, `RestrictNamespaces=yes`, `LockPersonality=yes`, `MemoryDenyWriteExecute=yes`, `SystemCallArchitectures=native`, `SystemCallFilter=@system-service`, plus per-service `MemoryMax`, `CPUQuota`, `TasksMax`.
## Common App Requirements
`status-app`, `paste-app`, and `shortlink-app` share the same backend shape: a single Python stdlib HTTP service on `0.0.0.0:8000` running as non-root user matching the service name (`status`, `paste`, `shortlink`), no shell, no home dir. Each service installs a `SIGTERM` handler that calls `server.shutdown()` for graceful drain, logs every request as a one-line JSON record (`level`, `time`, `remote_addr`, `method`, `path`, `status`, `duration_ms`) to stdout (journald captures it), exposes `GET /healthz` returning `200 {"status":"ok","node":"<name>"}`, and persists state under `/var/lib/<name>/` (mode `0750 <name>:<name>`). systemd unit adds `ReadOnlyPaths=/`, `ReadWritePaths=/var/lib/<name> /var/log/<name>`, `Restart=on-failure`, `RestartSec=2s`, `MemoryMax=512M`, `CPUQuota=100%`, `TasksMax=64`. All three have nftables only allowing `:8000` from the lab subnet — never from the public internet directly.
## Node Requirements
`nginx-edge`: also installs `nginx`, `openssl`, `certbot`. Register `nginx-edge-deploy.service` (one-shot) and `certbot-renew.timer` (daily, running `certbot renew --webroot -w /var/www/certbot --post-hook "systemctl reload nginx"`). Cert/bootstrap sequence must be safe on first boot and on later restarts: create `/etc/edge`, `/var/www/certbot`, `/etc/ssl/private`, and `/etc/ssl/certs`; if `/etc/ssl/private/edge.key` or `/etc/ssl/certs/edge.crt` is missing, generate a 90-day self-signed bootstrap cert with `openssl req -x509 -newkey rsa:4096 -nodes -days 90 -subj "/CN=edge.local"` and key mode `0600 root:root`; write the nginx config; run `nginx -t`; restart nginx; then, only if `/etc/edge/domain` exists, run `certbot certonly --webroot -w /var/www/certbot --non-interactive --agree-tos -m ops@$(cat /etc/edge/domain) -d "$(cat /etc/edge/domain)"`, symlink `/etc/letsencrypt/live/<domain>/fullchain.pem` to `/etc/ssl/certs/edge.crt` and `/etc/letsencrypt/live/<domain>/privkey.pem` to `/etc/ssl/private/edge.key`, run `nginx -t`, and reload nginx.
Replace Debian's default nginx site and write a full `/etc/nginx/nginx.conf` (not a `conf.d` file) so main-context directives are legal: `user www-data; worker_processes auto; pid /run/nginx.pid; include /etc/nginx/modules-enabled/*.conf; events { worker_connections 4096; } http { ... }`. Inside `http`, define `log_format main_json escape=json` with fields `time`, `remote_addr`, `request`, `status`, `bytes_sent`, `request_time`, `upstream_response_time`, `http_user_agent`, `http_x_forwarded_for`; write `access_log /var/log/nginx/access.log main_json;` and `access_log syslog:server=unix:/dev/log,facility=local6,tag=nginx main_json;`; set `sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; gzip on; gzip_vary on; resolver 1.1.1.1 8.8.8.8 valid=300s;`; `limit_req_zone $binary_remote_addr zone=edge_rl:10m rate=20r/s;`; `limit_req_status 429;`; `proxy_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 30s; proxy_next_upstream error timeout http_502 http_503 http_504;`. Define three named upstreams: `upstream status_up { server 10.55.0.20:8000 max_fails=2 fail_timeout=10s; keepalive 16; }`, same shape for `paste_up` (`10.55.0.30`) and `shortlink_up` (`10.55.0.40`). Server blocks: a port `80` default server that serves `/.well-known/acme-challenge/` from `/var/www/certbot` and otherwise returns `301` to `https://$host$request_uri`; a port `443 ssl` server with `http2 on;`, `ssl_certificate` / `ssl_certificate_key` pointing at the configured cert paths, `ssl_protocols TLSv1.2 TLSv1.3`, `ssl_prefer_server_ciphers off`, the Mozilla-intermediate `ssl_ciphers` list, `ssl_session_cache shared:SSL:10m`, `ssl_session_timeout 1d`, `ssl_session_tickets off`, `ssl_stapling on; ssl_stapling_verify on;`. Always-on response headers: `Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"`, `X-Frame-Options DENY`, `X-Content-Type-Options nosniff`, `Referrer-Policy strict-origin-when-cross-origin`, `Content-Security-Policy "default-src 'self'"`, `Permissions-Policy "geolocation=(), microphone=(), camera=()"`. Apply `limit_req zone=edge_rl burst=40 nodelay;` at server scope. Routing rules: `location = /` returns `301` to `/status.html`; `location = /healthz` sets `default_type application/json` and returns `200 {"status":"ok","node":"edge"}` directly from nginx (no upstream); `location ~ ^/(status|status.html|metrics|event)$` proxies to `http://status_up`; `location ~ ^/p(/.*)?$` proxies to `http://paste_up`; `location ~ ^/r(/.*)?$` proxies to `http://shortlink_up`. Every proxy location sets `proxy_http_version 1.1;`, `proxy_set_header Connection "";`, `proxy_set_header Host $host;`, `proxy_set_header X-Real-IP $remote_addr;`, `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, `proxy_set_header X-Forwarded-Proto https;`. Internal `location = /nginx_status` is `allow 127.0.0.1; deny all;` with `stub_status`. logrotate rotates `/var/log/nginx/access.log` daily, keeps 14 compressed copies, and uses `postrotate [ -s /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid) || true`. Write `/root/edge-runbook.md` documenting cert rotation, the renewal timer, rate-limit tuning, the nftables rule set, the upstream pool definitions, and the `/etc/edge/domain` handoff for ACME.
`status-app`: register `status-app.service`. Write executable `/opt/status-app/main.py` (Python stdlib): tracks process start time; appends to `/var/lib/status/events.jsonl` (max 100 entries, oldest evicted); on first start, seeds an `{"event":"service_up","node":"status-app","at":"<iso>"}` row. Routes:
- `GET /healthz` -> `200 {"status":"ok","node":"status-app"}`.
- `GET /status` -> `200 {"version":"1.0.0","node":"status-app","started_at":"<iso>","uptime_seconds":<n>,"last_events":[<up to 10 most recent rows>]}` with `Content-Type: application/json`.
- `GET /status.html` -> minimal server-rendered HTML showing the same data, `Content-Type: text/html; charset=utf-8`.
- `GET /metrics` -> Prometheus text format with `status_uptime_seconds`, `status_events_total`, `status_requests_total`.
- `POST /event` requires header `X-Internal: 1` (drop without it; intended to be reachable only via the lab subnet); body is a JSON object; appends to `events.jsonl`; returns `201`.
`paste-app`: register `paste-app.service`. Write executable `/opt/paste-app/main.py` (Python stdlib + `secrets`, `pathlib`): a tiny pastebin. Storage in `/var/lib/paste/<id>.txt`. Max body size `64 KB`. Routes:
- `GET /healthz` -> `200 {"status":"ok","node":"paste-app"}`.
- `POST /p` -> reject if body > 64 KB with `413`; generate `id = secrets.token_urlsafe(6)`; write file; return `201 {"id":"<id>","url":"/p/<id>"}` with `Location: /p/<id>`.
- `GET /p/<id>` -> validate `<id>` matches `^[A-Za-z0-9_-]{6,12}$`; return raw text with `Content-Type: text/plain; charset=utf-8`; `404` if missing.
`shortlink-app`: register `shortlink-app.service`. Write executable `/opt/shortlink-app/main.py` (Python stdlib + `secrets`, `fcntl`, `urllib.parse`): URL shortener with file-locked JSON store at `/var/lib/shortlink/links.json`. On first start, seed two examples: `{"about":"https://openfactory.tech/about","blog":"https://openfactory.tech/blog"}`. Routes:
- `GET /healthz` -> `200 {"status":"ok","node":"shortlink-app"}`.
- `POST /r` requires header `X-Internal: 1`; body `{"target":"https://..."}`; validate target is a syntactically valid `http(s)://` URL; generate `id = secrets.token_urlsafe(4)`; persist (with `fcntl.flock`); return `201 {"id":"<id>","short":"/r/<id>"}`.
- `GET /r/<id>` -> if `<id>` exists, return `302` with `Location: <target>`; else `404`.
## Scenario
Emit exactly one group scenario named `nginx-tls-edge-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`.
- `Cluster ports listen`: `port_listening` on `nginx-edge:80`, `nginx-edge:443`, `status-app:8000`, `paste-app:8000`, `shortlink-app:8000`.
- `Backends healthy`: on each app node, `curl -fsS http://localhost:8000/healthz | jq -e '.status == "ok"' >/dev/null && echo backend-ok`.
- `Edge reaches every app`: on `nginx-edge`, `for h in 10.55.0.20 10.55.0.30 10.55.0.40; do nc -z -w 5 "$h" 8000; done && echo upstreams-reachable`.
- `Modern TLS only`: on `nginx-edge`, `openssl s_client -tls1_1 -connect 127.0.0.1:443 -servername edge.local </dev/null 2>&1 | grep -qiE "alert|handshake failure|no protocols available|unsupported protocol|protocol version" && echo legacy-blocked`.
- `Plain HTTP redirects to HTTPS`: `http_responds` for `http://10.55.0.10/healthz` returning status in `[301, 308]` from `nginx-edge`.
- `HSTS and security headers present`: on `nginx-edge`, `curl -sk -D- https://localhost/status -o /dev/null | grep -iE "strict-transport-security|x-frame-options: DENY|x-content-type-options: nosniff|content-security-policy" | wc -l | awk '{exit ($1>=4)?0:1}' && echo headers-ok`.
- `Status JSON via proxy`: on `nginx-edge`, `curl -fsSk https://localhost/status | jq -e '.node == "status-app" and (.uptime_seconds | type) == "number"' >/dev/null && echo status-ok`.
- `Paste round-trip via proxy`: on `nginx-edge`, `id=$(curl -fsSk -X POST -H 'Content-Type: text/plain' --data 'hello-from-edge' https://localhost/p | jq -r .id) && [ -n "$id" ] && curl -fsSk "https://localhost/p/$id" | grep -qx 'hello-from-edge' && echo paste-roundtrip`.
- `Shortlink redirect via proxy`: on `nginx-edge`, `curl -sk -o /dev/null -w '%{http_code} %{redirect_url}' https://localhost/r/about | grep -E "^30[27] https://openfactory.tech/about" && echo shortlink-ok`.
- `Rate limit engages under burst`: on `nginx-edge`, `for i in $(seq 1 160); do curl -sk -o /dev/null -w '%{http_code}
' https://localhost/healthz; done | grep -Ec '^(429|503)$' | awk '{exit ($1>=1)?0:1}' && echo rate-limited`.
- `Hardened systemd unit on every app`: on each app node, `svc="$(hostname | cut -d. -f1).service"; systemctl show "$svc" -p NoNewPrivileges,ProtectSystem,MemoryDenyWriteExecute,LockPersonality | grep -E "yes|strict" | wc -l | awk '{exit ($1>=4)?0:1}' && echo hardened`.
- `JSON access logs`: on `nginx-edge`, `curl -sk https://localhost/status >/dev/null && tail -n1 /var/log/nginx/access.log | jq -e '.status == "200"' >/dev/null && echo json-logs`.
- `HTTPS proxy returns 200 for status`: on `nginx-edge`, `curl -fsSk -o /dev/null -w '%{http_code}
' https://10.55.0.10/status | grep -qx 200 && echo https-status-ok`.
Production handoff: drop the public DNS hostname into `/etc/edge/domain`, ensure `:80` and `:443` reach `nginx-edge` from the public internet (port-forward / DNAT on whatever router or load balancer fronts the lab — out of scope for this BUILD_PLAN), then `systemctl restart nginx-edge-deploy.service` to swap the bootstrap self-signed cert for an ACME-issued one; renewal is automatic via the `certbot-renew.timer`. Real deployments still need DNS / public PKI ownership, runtime secret distribution outside the recipe, log shipping to a central aggregator, replacing the Python stdlib backend services with the operator's real apps (status, paste, shortlink are useful starters but not the full surface), and runtime authn/authz on `POST /event` and `POST /r`. The `10.55.0.0/24` lab aliases stay deployment-time intent; substitute the operator's runner network on real infrastructure.nginx-edge, status-app, paste-app, shortlink-app), and the scenario assertions. Edit the prompt and re-run if anything is off.# status page (JSON)
curl -sk https://10.55.0.10/status | jq
# pastebin round-trip
id=$(curl -sk -X POST --data 'hello-from-edge' https://10.55.0.10/p | jq -r .id)
curl -sk https://10.55.0.10/p/$id
# URL shortener
curl -sk -o /dev/null -w '%{http_code} %{redirect_url}\n' https://10.55.0.10/r/about
# -> 302 https://openfactory.tech/aboutIf you're driving OpenFactory from an AI agent rather than the browser, the same flow is exposed through the OpenFactory MCP server — submit the prompt programmatically and get the same build-plan preview back, then call create_build / start_vm on the resulting recipes. Single-image builds (no multi-node plan) can also go straight through the openfactory CLI.
The prompt bakes in the production-shaped knobs — modern TLS, security headers, rate limiting, hardened systemd, an nftables default-deny, key-only SSH, structured logs, ACME wiring. A few things still sit outside the recipe and need operator attention before you point public traffic at it:
/etc/edge/domain, make sure the edge is reachable on :80 from the public internet for the HTTP-01 challenge, and restart nginx-edge-deploy.service. Renewal is automatic from there.POST /event on status-app and POST /r on shortlink-app are gated by an X-Internal: 1 header and lab-subnet nftables only — useful for an internal admin path, not enough for a public surface. Add real auth (signed JWT, mTLS, operator-controlled API key) before you wire either to the public internet.status-app, paste-app, shortlink-app) are useful Python stdlib starters that demonstrate the routing pattern, not the full surface you'll ship. When you're ready, replace each with the production app behind the same upstream contract; the prompt's routing rules and scenario assertions stay the same shape./var/log/nginx/access.log and journald. Forward to your aggregator (Loki, Elastic, Datadog, etc.) via the agent of your choice — OpenFactory recipes can bake an agent in, but the destination is yours./etc/letsencrypt/live. Upstream auth, cookie secrets, etc. should ride in via systemd credentials (mounted at /run/credentials/app/) rather than being baked in.upstream pool, add a Postgres primary/replica + Redis tier, or split hostnames across multiple server blocks — same shape, more leaves.If reverse proxies are step one, signed runtime evidence is step two. The runtime attestation post walks through the dual-source kernel + systemd verification you can layer on any recipe, including this one. For fleet-scale orchestration patterns built on top of these BuildPlans, the OpenFactory + ServiceNow post covers change, CMDB, and incident wiring. And when you're ready to build, the Enterprise & GxP page is the way in.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.