The job is not “install nginx”
Almost nobody wants nginx for its own sake. What you want is for app.example.com to reach a process listening on 127.0.0.1:3000, over HTTPS, without exposing that process to the internet directly. The web server is the means; the reverse proxy is the job. Confusing the two is how people end up with a default welcome page in production and a real service still bound to a port anyone can hit.
So the thesis of this piece is narrow: the reverse proxy is a small, well-understood configuration, and almost every way it goes wrong is a way it silently keeps serving the old thing. A broken proxy rarely errors loudly. It serves a stale serverblock, or the distribution's default site, or a 502 that looks like your application crashed when in fact nginx never reached it. The work is in the verification, not the typing.
Before the first package: read the machine
A fresh cloud image is rarely as empty as it looks. Something may already own port 80 — Apache from a half-finished experiment, a container publishing :80, an old nginx nobody remembers. Reconnaissance is not politeness; it is the difference between installing a service and colliding with one.
- Is nginx already here, and which version?
command -v nginx && nginx -v. If it is, you are configuring, not installing — and you must not reinstall over a working config. - Is anything already listening on 80 and 443?
ss -tlnH 'sport = :80' 'sport = :443'tells you what owns the ports before you try to bind them. A proxy that cannot bind is a proxy that never starts. - Does a vhost for this domain already exist? A file under
/etc/nginx/sites-enabled/with your domain in it is a config you are about to clobber. Read it first; back it up before you touch it.
The layout differs by distribution, and assuming the wrong one is a classic own goal. Debian and Ubuntu ship the sites-available / sites-enabled pair with a symlink between them; the RHEL family drops everything into /etc/nginx/conf.d/*.conf and has no sites-enabled at all. A runbook that hard-codes one path fails on half your fleet. The reconnaissance step is what tells the run which world it is in.
1. Install, but only if it is missing
Installation is the least interesting step and the one people over-think. On Debian and Ubuntu it is apt-get install -y nginx; on the RHEL family, dnf install -y nginx. The only rule that matters is the one that is easy to skip under time pressure: if nginx is already installed and serving, do not reinstall. Verify the version, confirm the service is up, and move on to the part that actually changes the outcome. Reinstalling a running web server buys you nothing and risks restarting it at a moment you did not choose.
The proof that this step worked is not “the command exited 0”. It is a version string from nginx -v and an active from systemctl is-active nginx. Two concrete facts, machine-checkable, that a human or a model can read without guessing.
2. The vhost: where the reverse proxy actually lives
This is the whole point of the exercise. A reverse-proxy server block is short, and each line earns its place. In plain terms it says: answer for this server_name, and pass everything through to an upstream address with the right headers attached.
Three details separate a working proxy from a subtly broken one:
- The upstream is host:port, and it should be loopback. Proxying to
127.0.0.1:3000means the application never has to listen on a public interface. If your app is bound to0.0.0.0, the proxy is decoration — the port is still reachable directly. Bind the app to loopback and let nginx be the only door. proxy_set_header Hostand the forwarded-for pair are what let the upstream know who really called and under what name. Omit them and your application logs everyone as127.0.0.1, your redirects point at the wrong host, and any IP-based logic downstream is blind.- The WebSocket upgrade headers are the ones everyone forgets. Without
proxy_set_header Upgrade $http_upgradeandConnection "upgrade", plain HTTP works and every WebSocket silently fails to connect. The symptom is a real-time feature that “just doesn't work” with nothing in the error log, because from nginx's point of view nothing went wrong.
And the rule that sits above all of them: do not overwrite an existing vhost without backing it up first. A cp site.conf site.conf.bak costs nothing and is the difference between a mistake you can undo in one command and an afternoon reconstructing a configuration from memory.
3. Validate before you reload — always, no exceptions
This is the single habit that prevents most self-inflicted nginx outages, and it is one command: nginx -t. It parses the entire configuration, follows every include, and tells you whether the daemon would accept it — before you ask the running daemon to swap to it.
nginx -t && systemctl reload nginx
The && is doing real work. A reload keeps existing connections alive and only applies the new configuration if it parses; but if you restart against a config that fails to parse, nginx stops and does not come back, and now the site is down for a typo. Chaining the test to the reload means a broken config never reaches the running server in the first place. Test, then reload; never reload blind.
The verification for this step is equally concrete: nginx -t reporting syntax is ok and test is successful, followed by systemctl is-active nginx still returning active. If either fails, you have not changed anything the outside world can see — which is exactly the property you want from a failed step.
4. TLS: the part with a rate limiter attached
A reverse proxy on port 80 is half a job. The other half is a certificate, and the honest way to get one is Certbot against Let's Encrypt — certbot --nginx -d app.example.com will obtain the certificate and rewrite your vhost to serve it. It is genuinely that smooth, with two caveats that catch people:
- DNS has to point at this machine first.Let's Encrypt proves you control the domain by reaching it; if the
A/AAAArecord does not resolve to this server yet, issuance fails, and no amount of retrying fixes a DNS problem. - The rate limits are real and unforgiving. A handful of failed issuances for the same domain and you are locked out for hours. This is why the sane move is to test the plumbing with
certbot renew --dry-run— it exercises the whole renewal path against the staging boundary without spending your live quota. Prove the renewal timer exists (systemctl list-timers | grep certbot) and you have proven the certificate will not quietly expire in ninety days.
Automatic renewal is the step people skip because the certificate they just issued works today. It is also the step whose absence takes the site down in three months, on a date nobody wrote down. Verify the timer, not the certificate.
What a reverse proxy does not give you
Being honest about the boundary is the whole point. Terminating TLS and forwarding requests does nothing for the security of the application behind it — a vulnerable app behind a perfect proxy is still a vulnerable app. It is not a WAF; it filters nothing about the content of a request unless you deliberately configure it to. It is not a load balancer until you give it more than one upstream and a policy for choosing between them. And it will happily proxy to an upstream that is down, returning a 502 that looks like your fault when the real problem is a crashed process one layer deeper.
What it does give you is a single, controllable front door: one place that terminates TLS, one place that sets headers, one place whose logs tell you who asked for what. That is worth having, as long as you do not mistake the door for the house.
The check that proves the proxy is real
A reload that succeeds proves nginx accepted the config. It does not prove the proxy reaches your app. So, in order:
curl -sf -o /dev/null -w '%{http_code}' http://localhost/against the vhost — a200, or a deliberate301to HTTPS, not the distribution's default page;- the same curl against the upstream directly, to know whether a 502 is nginx's fault or the app's;
- for a real-time feature, an actual WebSocket handshake — the header you set is only proven by a connection that upgrades and stays open;
ss -tlpnto confirm the application is on loopback and not, quietly, still exposed on the0.0.0.0wildcard;- the TLS path end to end once DNS resolves, and the renewal timer listed and enabled.
Where Servor fits
Servor ships this as a recipe — one of 150+ vetted playbooks — and the shape of a recipe is exactly what this job needs. It is not a frozen script fired blindly at the machine: it starts with reconnaissance, reading whether nginx is already there, whether the ports are free, and whether a vhost for your domain already exists, so the run adapts to a real server instead of assuming an empty one. A cheap model drives it, but every step it proposes carries its own verify — the nginx -t, the curl status code, the is-active — so success is proven, not asserted. The playbook is idempotent: if nginx is already serving, it checks and reports rather than reinstalling, and it will not overwrite your vhost without a backup.
And the execution model is the same one that governs the copilot. Every command is signed in your browser with a key derived from your vault key, relayed verbatim, and verified on the machine by the agent before it runs — the reverse proxy recipe bends none of the zero-knowledge rules. The agent reaches the control plane over an outbound connection, so tightening the very ports you are proxying never cuts your management path.
Add TLS on top with the Let's Encrypt recipe, and pair it with a monitor on the public endpoint so a 502 wakes a machine and not your next customer. The discipline behind that approve-sign-verify loop is the subject of the Plan-Execute-Verify piece, and the operational half lives in our server operations guide.