Port 443 belongs to the server, not the domain
When one domain on a shared host loses its certificate, the obvious move is to close HTTPS for it. A firewall cannot do that. It can only close HTTPS for every domain on the server. Why iptables never sees a hostname, and the guard that decides when the port may close.
On a fleet of shared hosting servers, certificates and the firewall meet at one port. When a server’s first certificate is issued, port 443 opens on it. When a certificate goes away — revoked, deactivated, expired, deleted — the port may close.
May. That word is the whole of this post.
Picture a server hosting a dozen customer domains. One of them has its certificate revoked. The event arrives at the firewall as “certificate removed on this server”, and the natural response is to close 443. The result: HTTPS breaks for the eleven domains that did nothing wrong.
Where the hostname actually lives
iptables matches on what is in the IP and TCP headers: source and destination addresses, protocol, ports, connection state. A rule can say “accept TCP to port 443”. It cannot say “accept TCP to port 443 for shop.example”, because no packet the firewall decides on carries that name.
Follow a connection from the start:
- The client sends a SYN to port 443. It has no payload. The firewall accepts it or rejects it — here, with nothing to go on but an address and a port.
- The TCP handshake completes.
- The client sends a TLS ClientHello, and inside it, in the Server Name Indication extension, the hostname it wants.
- The web server reads the SNI and picks the matching
serverblock and certificate. - After the handshake, the HTTP
Hostheader names the site again.
The decision the firewall makes happens at step 1. The hostname does not exist until step 3. Port 443 is open or closed per server — never per domain.
Could you match the SNI in the firewall anyway? iptables can match strings inside packets, but it would be fragile — the handshake has to be allowed before the ClientHello even arrives, and Encrypted Client Hello is designed to hide exactly that field from anything on the path. So per-domain HTTPS control belongs in nginx, which chooses a server block by SNI. That is how disabling one domain’s HTTPS works on this fleet: its SSL config moves out of the directory nginx reads, and the other sites are untouched.
The guard: count before you close
The requirement came from my team lead: closing 443 must not break the sites that still have certificates. The design I built puts the guard on the action, not the reason. Revocation, deactivation, expiry and deletion all arrive as “remove”, and all of them get the same check:
-- how many certificates are still active on this server?
SELECT COUNT(*) FROM certificates
WHERE server_id = %s AND status = 'active';
If the answer is above zero, the script declines, and says so: “3 certificate(s) still active on this server — not closing 443.” The rule is only marked inactive when the last certificate is gone.
Which field you count matters
The records also carried an expiry date, and it would have been natural to count certificates that had not expired yet. That date was not read from the certificate — it was written as a fixed ninety days when the record was created, and commented as a placeholder. So a record could be active with an expiry in the past.
Counting by that date would have closed a live server’s port because a placeholder had passed: exactly the outage the guard exists to prevent. The status field was the authoritative one — written back by the server itself when issuance completed — so that is the only one the guard reads.
When you cannot count, do not close
The query can fail. The database can be unreachable. The count’s configuration can be missing. In every one of those cases, the script refuses to close the port and exits with a distinct code, 3: could not prove the last certificate is gone — 443 left open.
That asymmetry is deliberate, and it comes from comparing the two ways of being wrong:
| If the script guesses wrong and… | Cost |
|---|---|
| leaves 443 open with no certificate behind it | a port that serves nothing useful, until the next reconciliation |
| closes 443 under live certificates | an HTTPS outage for every site on the server |
When the costs are that lopsided, “unknown” must resolve to the cheap failure. Refuse rather than guess — and make the refusal visible, so it gets looked at instead of retried until it happens to pass.
Opening: check state, don’t start a timer
The other direction has its own trap. Provisioning a new server issues one certificate per domain, and each one calls the script. A server with ten domains produces ten “open 443” calls in quick succession — and each firewall deploy takes the fleet’s deployment lock.
The first suggestion was to debounce with a three-minute timer. I proposed a state check instead: before doing anything, ask whether an active 443 rule for this server already exists. If it does, there is nothing to write and nothing to deploy, and the orchestrator is skipped entirely. Only the first domain on a server pays for a deploy.
The difference is what each one can know. A timer knows when you last deployed. It cannot tell a redundant call from a retry of one that failed — so it can skip a deploy that was genuinely needed. A state check knows whether the port is open, which is the question that matters. If an earlier deploy failed while the rule was written, the hourly sweep — which redeploys what the database says, but never decides anything itself — is the backstop.
Validate the identifier, not just the action
During live testing, the server-ID check caught a real typo: an ID with an extra digit. Without it, the script would have written a firewall rule scoped to a server that does not exist. That rule would have looked correct in the table. The generator would have matched it against no host. And 443 would have silently never opened on the server that needed it.
So every call is checked against the list of real servers before anything is written. A rule that matches nothing is worse than an error, because it looks applied.
What I took from it
- Know which layer your control lives at. A control can only act on what its layer can see. The firewall sees servers; the web server sees sites.
- Put guards on the action, not the reason. New reasons for “remove” will appear. They should all hit the same check without anyone remembering to add it.
- Count the authoritative field. A plausible-looking column that nobody keeps accurate is worse than no column.
- Unknown resolves to the cheap failure. Work out which mistake is an outage, and make uncertainty land on the other one.
- State beats timers. Ask whether the thing is already true, not whether you did something recently.
This bridge connects the SSL certificate automation to the firewall platform, which compiles and deploys the rule it writes.
References
- RFC 6066 §3: Server Name Indication — the hostname travels inside the TLS handshake.
- RFC 9110 §7.2: Host and :authority
- RFC 9293 §3.5: TCP connection establishment — the three-way handshake that completes before any of it.
- nginx: name-based HTTPS servers and SNI
- iptables-extensions(8) — what a rule can match on.