Skip to content

1.15 Step | Nginx (6 sub steps)

Omisen edited this page Aug 15, 2026 · 2 revisions

The Nginx reverse proxy, optional (--with-nginx). It lives in src/steps/nginx_*.rs. A port of lib/nginx.sh + templates/nginx.conf.tpl.

Two things make it important despite being optional: it is the second monolith split (after the sources in 1.9–1.11), and the snapshot goes back to being a protection — two mutations touch Nginx and firewall configuration that is not ours.


The sub-steps (all gated by --with-nginx)

setup_nginx becomes six steps. Without --with-nginx the whole phase is inert (Untracked snapshot, no-op run and undo), like 1.3 SetupLogDir with the log file disabled: adding them to the sequence is always safe.

# Step Role Undo
1 NginxInstall install + enable stop+disable; purge only with --aggressive-rollback (D3)
2 NginxWriteConfig vhost from the template remove if ours / restore the backup if pre-existing
3 NginxEnableSite enable the site + free port 80 remove our link + restore the default site
4 NginxSelinux turn on httpd_can_network_connect where SELinux exists turn it off only if we turned it on
5 NginxFirewall open the ports with the family's firewall remove only the delta
6 NginxReload nginx -t + reload stop Nginx if we started it (D4)

Reverse rollback: reload → firewall → selinux → enable-site → write-config → install.


The snapshot as a protection (the heart of this phase)

Two treacherous mutations touch the customer's system configuration. Without a correct snapshot, a rollback would leave the system worse than it found it. The “record the pre-existing state, restore it in the undo” pattern — already seen with the odoo.conf backup in 1.12 — here protects Nginx and the firewall.

Nginx's conventions change with the family, and the step does not know it: it asks the Distro boundary where the vhost goes and whether there is a default site to move out of the way. On Debian/Ubuntu it is sites-available + a symlink in sites-enabled; on Fedora it is conf.d/*.conf, with no symlink and no default site to remove — there the default server is a block inside nginx.conf, and rewriting the main configuration of a customer's service is not something we do. Declared consequence: on Fedora a request to a hostname that does not match NGINX_SERVER_NAME still gets the welcome page. Detail in Multi-distribution support.

1) The default site (NginxEnableSite) — deb family

To free port 80, sites-enabled/default has to be moved out of the way — and that is the customer's pre-existing configuration.

snapshot: record WHAT is there (path_kind): absent | symlink{target} | regular file | other
run:      symlink       → remove it (it has no content of its own)
          regular file  → MOVE it to a backup, never delete it
          other         → do not touch it, and say so
undo:     symlink       → recreate it towards the RECORDED TARGET
          regular file  → put the backup back where it was

Recording whether it was there is not enough. While the snapshot was a bool, two defects grew out of it:

  • restoration was not faithful: the undo always recreated a symlink towards /etc/nginx/sites-available/default, the distribution-standard target. If the customer's default pointed somewhere else, their config did not come back as it was — it came back as it usually is. For a project that restores .bashrc byte for byte, that was a double standard;
  • a file could be lost: symlink_exists uses symlink_metadata, which answers true for a regular file too, and remove_symlink is fs::remove_file. An administrator who had written sites-enabled/default as a real file would find it destroyed, and the undo would hand back a symlink to the distro default. Not a leftover: a loss of configuration.

The backup of a regular file lives in /etc/nginx/, not in sites-enabled/: nginx.conf includes sites-enabled/* — every file, not only .conf ones — so a backup left there would be loaded and port 80 would stay occupied. That would be the same defect under another name.

If the default site did not exist → we do not invent one.

2) SELinux (NginxSelinux)

Where SELinux is enforcing — Fedora — a proxy towards 127.0.0.1:8069 is denied even with a valid nginx -t and a successful reload: the symptom is a 502, and nothing in Nginx's configuration explains it. The httpd_can_network_connect boolean has to be turned on with setsebool -P, that is persistently — so it is an artifact to record and to turn back off in the undo, but only if it was off before us.

If getsebool is not executable (no SELinux, or the tools are not installed) the answer is not “off” but “I do not know”, and nothing is concluded from it: the step does not touch the policy of a system it cannot question.

3) The firewall rules (NginxFirewall) — the delta pattern

Firewall rules are the customer's configuration. The delta pattern applies (as with packages in 1.4):

delta = the desired rules (80/tcp, + 443/tcp with --open-https-port) NOT already present
run:   open only the delta
undo:  remove ONLY the delta — never a rule the customer already had

If the firewall is not installed or not active → no-op (we do not force a firewall on).

“Already present” is decided by token, not by substring. The comparison used to run contains over the output of ufw status, and "80/tcp" is contained in "8080/tcp": on a machine with another web app, the rule for port 80 did not enter the delta, was never opened, and nginx stayed unreachable from outside with nothing to signal it. Now the first token of each line — the To column — is compared, with the headings excluded.

4) Port 80 when Nginx is already serving

The preflight used to demand port 80 be free whenever --with-nginx was requested. But the supported scenario — and the one these steps handle explicitly, with NginxInstall marking an already-running nginx as Preexisting and not touching it — is precisely adding a vhost to an nginx that is already running. On such a machine port 80 is held by nginx, the very program we are about to configure.

A port held by us is not a conflict. If nginx is not serving and 80 is taken (Apache, another proxy), the conflict is real and the refusal stands: nginx would not even manage to bind.

5) Two instances behind one nginx: the names have to differ

nginx keeps one namespace for every vhost it loads. The template used to declare upstream odoo and upstream odoo-longpolling — global names — so the second instance made nginx -t fail with a duplicate upstream, which failed the reload step, which rolled back the whole installation. Loud rather than silent, but a hard blocker on the only reason to put two instances behind one proxy.

The upstreams now carry the instance name, together with the proxy_pass that uses them. The longpolling upstream had the same problem more quietly: it pointed at a hardwired 127.0.0.1:8072, so both vhosts sent every websocket to whichever instance had managed to bind that port. It now carries this instance's gevent port.

What still has to differ between two vhosts is --server-name: with the catch-all _ on both, nginx answers for the same hostname and serves whichever it loaded first. The log files are already per instance.

6) TLS is not configured by the installer

The generated vhost listens on port 80 only, deliberately. The supported way to get HTTPS is certbot --nginx, which obtains the certificates and rewrites the vhost itself, adding the 443 block and the redirect. Generating one of our own would mean competing with it — and, if it pointed at certificates that do not exist, nginx -t would fail and take the whole installation with it.

The --open-https-port flag (formerly --enable-ssl) only opens 443 on the firewall ahead of that step: it does not touch the vhost.


The other details

  • NginxInstall — policy consistent with PostgreSQL (D3): stop+disable if we enabled it, but purge only with --aggressive-rollback.
  • NginxWriteConfig — the vhost is rendered from the embedded template (include_str!); if a vhost with the same name pre-existed, it is backed up before overwriting and restored in the undo.
  • NginxReload — runs nginx -t and reloads only if the config is valid (no reloading a broken config → error). Its undo stops Nginx if we started it (D4).

The realignment reload lives in NginxInstall::undo, not in NginxReload::undo, and must not be moved (bug A1.4, found by the end-to-end test). Undos run in reverse: NginxReload's is the first of the phase, when the configurations have not been restored yet; NginxInstall's is the last. Reloading too early leaves the customer's nginx serving our config after the files behind it have gone.


Design notes

  • No new shared state: the gating is a local ctx.with_nginx check in each step; no Arc added to the Context.
  • render_vhost/validate_vhost are pure functions, tested separately.
  • Tests: gating (all six steps inert without --with-nginx), default site removed → restored, an absent default not invented, the firewall delta (only 443 removed, never the customer's 80), an inactive firewall no-op, failed nginx -t → error, D3 on install, and the complete rollback through the engine (default site restored + only the firewall delta removed).

Clone this wiki locally