Production Security And Server Hardening
Firewall Policy And Network Exposure
Default Deny
Firewall policy starts from a stance, not a list: deny all inbound by default, then allow specific ports deliberately. On Ubuntu, ufw expresses this directly:
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Order matters operationally: allow SSH before enabling the firewall, for the same lockout reasons covered in the SSH lesson. ufw fronts nftables (historically iptables); larger estates write nftables rules or cloud security groups directly, but the default-deny stance is identical.
Cloud platforms add a second firewall outside the machine: security groups. Treat the two as layers with the same policy rather than choosing one — the host firewall survives a misconfigured security group and vice versa.
Binding Beats Blocking
The strongest way to keep a service off the internet is to never bind it to a public interface. MySQL bound to 127.0.0.1, Redis on a localhost port or a Unix socket, php-fpm on a Unix socket — these are unreachable from outside regardless of firewall state. A blocked port is one rule-change away from exposure; an unbound one is not. Prefer binding narrowly first and firewalling as the second layer.
Services that must be reachable by other servers — a database serving a separate app host — belong on a private network interface or VPC subnet, allowed by source address, never simply opened to the world with a password as the only barrier.
Verify From Outside
Configuration says what you intended; only observation says what is true. From the server, ss -tlnp lists every listening socket and the process that owns it — anything listening on 0.0.0.0 or [::] had better be on the intended list. From a machine outside the network, a port scan (nmap) of the server's public address confirms what the internet actually sees. Run both after any service installation, because packages happily start daemons listening on all interfaces by default.
Egress deserves a decision too. Most compromises phone home or exfiltrate outbound; default-allow egress is normal for small estates, but knowing your expected outbound destinations makes the compromise monitoring lesson's job possible.
What To Check
Before moving on, make sure you can:
- state the default-deny stance and the minimal port set for a PHP web server
- explain why binding to localhost or a socket beats relying on a firewall rule
- describe host firewall and cloud security group as layers, not alternatives
- verify exposure with
ss -tlnpinside and a scan from outside - explain why egress patterns matter even when egress is allowed
What You Should Be Able To Do
After this lesson, you should be able to write and apply a default-deny firewall policy without locking yourself out, keep internal services unreachable by binding rather than blocking alone, and prove a server's real exposure from the outside.
Practice
Practice: Design Network Exposure
A single VPS will run nginx, php-fpm, MySQL, and Redis for one Laravel application. Specify what binds where, the complete firewall ruleset, and how you will verify the result.
Show solution
Firewall: default deny incoming, default allow outgoing, allow OpenSSH, allow 80/tcp, allow 443/tcp — allow SSH before ufw enable. Nothing else, and specifically no rules for MySQL or Redis: rules for them would only matter if the bindings regressed, and their absence keeps the policy readable.
Verification, both directions: ss -tlnp on the host must show nginx on 80/443, sshd on its port, MySQL and Redis on 127.0.0.1 only, and php-fpm absent from TCP entirely. Then nmap from an outside machine must show exactly three open ports. Re-run both checks after any package installation, and put the outside scan on a monthly schedule — exposure drifts.
Practice: Diagnose An Exposed Service
A monitoring alert shows connection attempts from the internet reaching Redis on a production server. Redis was "secured last year." Investigation shows ufw is active with no Redis rule, but a Docker container was deployed last month publishing port 6379.
Task
Explain how the exposure happened despite the firewall, and what defenses were missing.
Show solution
Missing defenses, in order of effectiveness:
- Binding discipline: publishing to localhost only (
-p 127.0.0.1:6379:6379) would have kept Redis unreachable regardless of firewall behavior. Binding beats blocking precisely because of failure modes like this one. - Outside verification: a routine external
nmapwould have caught the new open port within a month of the deploy instead of via attacker traffic. - The cloud security group layer: an outer firewall allowing only 22/80/443 would have absorbed the host-level mistake, which is why the two layers carry the same policy.
- Redis authentication as depth:
requirepassand protected mode make an exposed Redis a target instead of an instant loss.
The response is a compromise-assessment, not just a fix: Redis exposed for a month must be assumed probed, per the compromise-response lesson.
Practice: Define Exposure Review Checks
Show solution
- External scan of every public address, diffed against a committed expected-ports file per host; any new open port pages a human. The scan runs from outside the provider's network so it sees what attackers see.
- On each host,
ss -tlnpoutput diffed against an expected-listeners baseline: process, port, and bind address. Catching a service that moved from 127.0.0.1 to 0.0.0.0 is this check's whole purpose. - Firewall state is asserted, not assumed: ufw active, default deny incoming, rule list matches the committed policy.
- Cloud security groups match the same committed policy; a rule added "temporarily" during an incident is the classic drift.
Quarterly, by a human: read the expected-ports and expected-listeners files themselves and challenge each entry — does this service still need to listen at all, and could it bind narrower? The automated checks keep reality matching intent; the human check keeps intent minimal.