OpenFactory nginx TLS reverse-proxy lab with nginx-edge as the border gateway and three useful backend services

Build an nginx + TLS Reverse Proxy on OpenFactory

A production-grade build prompt

May 9, 2026

← Back to Blog

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.

What an nginx reverse proxy actually does

  • TLS termination. Certs live in one place, not on every backend. Backends speak plain HTTP and stay simple.
  • Hostname / path routing. api.example.com goes to service A, example.com/admin goes to service B, all on one IP and one cert.
  • Slow-client buffering. nginx reads slow clients into RAM so your Python or Node backends aren't tied up waiting on someone's bad LTE.
  • Rate limiting and abuse defense. limit_req_zone, IP filters, request-size caps stop traffic before it ever reaches the app.
  • Hiding internal topology. Clients see one edge. You can swap, scale, or re-route backends without changing public DNS.
  • Static asset offload. CSS, JS, images, and cached responses are served by nginx, leaving the app server to do real work.

Who reaches for this pattern

  • Solo devs and small teams putting a real service on the open internet and wanting more than python -m http.server behind Cloudflare.
  • Homelabbers and self-hosters with multiple services (Nextcloud, Jellyfin, Gitea) who want one cert and one front door.
  • Regulated shops — medical, fintech, defense — where “how do you prove your edge is configured the way you say it is” is a real audit question, not a rhetorical one.
  • Platform teams building internal infra where every service team needs the same edge pattern and nobody wants to copy-paste nginx configs across repos.

Why build it on OpenFactory

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.

  • The ISO is the spec. Cert generation, nginx config, systemd unit, and the backend service are baked into bootable images. New node = boot the same ISO. No SSH-in runbook.
  • Scenario assertions ride along. The build group fails closed if the redirect breaks, the TLS handshake doesn't terminate, or the proxy can't reach the backend. You don't deploy and hope.
  • Multi-node from one prompt. Four nodes here. The same shape scales to the six-node nginx-hawc-web-cluster fixture (edge + 2 app + Postgres primary/replica + Redis) without leaving the chat UI.
  • Reproducibility for regulated work. “We 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.
  • Iteration is editing the prompt. Need rate limiting and a WAF? Change the prompt, rebuild, redeploy. The diff is in version control, not in your shell history.

What you'll build

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.

Topology

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.

Topology diagram: four VMs on 10.55.0.0/24 — nginx-edge as the public-facing border gateway (TLS termination, rate limiting, security headers) and three backend services on :8000 each: status-app (status page, metrics), paste-app (pastebin), shortlink-app (URL shortener). The edge does path-based routing to each upstream.

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.

How OpenFactory's chat builder turns a prompt into ISOs

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.

The prompt

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.

Running it

  1. Open the chat builder at console.openfactory.tech and paste the prompt into a new conversation.
  2. Review the streamed build plan. You'll see the topology (four VMs: edge plus three services), the four recipes (nginx-edge, status-app, paste-app, shortlink-app), and the scenario assertions. Edit the prompt and re-run if anything is off.
  3. Click Build group. OpenFactory fans the plan out to per-node ISO builds. When all four reach built, boot the group on the runner network from the same UI.
  4. Exercise each backend through the edge. Once the group is running, from any client that can reach the lab network:
    # 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/about

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

What's still your responsibility

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:

  • DNS and the ACME flip. The bootstrap cert is self-signed for 90 days. Drop your hostname into /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.
  • Authn/authz on the write paths. 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.
  • Swap in your own services. The three backends (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.
  • Log shipping. JSON access logs land in /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.
  • Secrets at runtime. The bootstrap cert key lives in the recipe; the ACME-issued one lives in /etc/letsencrypt/live. Upstream auth, cookie secrets, etc. should ride in via systemd credentials (mounted at /run/credentials/app/) rather than being baked in.
  • Scale-out shape. One edge in front of three services fits a lot of cases. When it doesn't, the same prompt vocabulary scales: replicate any service behind a named upstream pool, add a Postgres primary/replica + Redis tier, or split hostnames across multiple server blocks — same shape, more leaves.
  • WAF if you want one. ModSecurity / Coraza aren't in the recipe; add them to the prompt if your threat model needs request-body inspection on top of the rate limiter and security headers.

Where to go next

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.

Ready to ship this in production?

OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.