When “it’s behind the firewall” stops being a security plan
In the early days, a Linux server behind an enterprise firewall feels like a safe bet. The network team has rules. The perimeter is monitored. The server is “internal.” Then the environment grows. A second subnet appears. A third-party integration needs outbound access. A monitoring agent is added. A jump host becomes a shared dependency. Suddenly, the firewall is no longer a single protective wall—it’s a set of moving parts, exceptions, and assumptions.
This is where incidents quietly begin: a service binds to all interfaces, a default route changes, DNS starts leaking to the wrong resolver, or a “temporary” rule becomes permanent. The fix is rarely dramatic. It’s disciplined. We make the server predictable, minimize what it can do, minimize what can reach it, and verify every change. That is the principle of least privilege applied to Linux in a real network.
Prerequisites and assumptions
Before we touch commands, we need to be explicit about what we are configuring and what we are not. These assumptions keep the steps safe and production-grade.
- Platform: Linux server running systemd. The commands below are written for Debian/Ubuntu-style systems (APT, UFW). If we are on RHEL-family, the same concepts apply but the package manager and firewall tooling differ.
- Access method: We have console access (hypervisor console, iDRAC/iLO, cloud serial console) or an existing, working SSH path through the enterprise firewall. We do not rely on making risky network changes without a recovery path.
- Privileges: We have a non-root admin account with sudo. We will avoid staying logged in as root.
- Network reality: The enterprise firewall controls north-south traffic. We still configure host controls because internal lateral movement is a real threat and because firewall rules drift over time.
- Change control: We schedule a maintenance window if this is a shared system. We keep an active session open while applying firewall changes, and we validate after each step.
- Least privilege: We will only allow what is required for operations: administration (SSH), time sync, DNS, package updates, logging/monitoring, and the specific application ports that are truly needed. We will not rely on broad allowances.
Step 1: Capture the server’s current network identity
Before we harden anything, we need to know what the server thinks its interfaces, routes, and DNS settings are. This prevents us from writing rules against the wrong interface or troubleshooting the wrong resolver later.
set -euo pipefail
hostnamectl
ip -br link
ip -br addr
ip route
resolvectl status || true
We have now captured the baseline: hostname, interfaces, IP addresses, routing, and DNS resolver state. If anything looks unexpected (wrong default route, multiple active interfaces, or DNS pointing somewhere unapproved), we pause and fix that first—hardening on top of a broken baseline creates fragile systems.
Step 2: Update packages safely and enable unattended security updates
Behind enterprise firewalls, patching often fails quietly due to proxy requirements or restricted egress. We will first confirm we can update, then enable unattended security updates so the server does not drift into known-vulnerable territory.
sudo apt-get update
sudo apt-get -y upgrade
The system has now pulled the latest package metadata and applied available upgrades. If this step fails, it usually indicates DNS, proxy, or egress restrictions that we should address before continuing.
Next, we enable unattended security updates. This is not about “auto-upgrading everything”; it is about consistently applying security fixes with minimal operational overhead.
sudo apt-get -y install unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades
Unattended upgrades are now installed and configured via Debian’s standard mechanism. We should still manage major version upgrades through change control, but security updates will no longer depend on someone remembering to run them.
We verify the service timers are present and active.
systemctl list-timers --all | grep -E 'apt|unattended' || true
systemctl status unattended-upgrades --no-pager || true
We have now confirmed the scheduling and current status. If the service is inactive, we investigate logs later in the troubleshooting section.
Step 3: Create a controlled admin path with SSH hardening
In enterprise environments, SSH is usually the operational lifeline. The goal is not to make SSH “clever”; it is to make it boring, predictable, and resistant to common abuse. We will enforce key-based access, disable direct root login, and reduce attack surface.
First, we confirm the SSH service state and which address/port it is listening on. We do this before changing anything so we can compare after.
systemctl status ssh --no-pager
ss -tulpn | grep -E '(:22)s' || true
We have now confirmed whether SSH is running and listening. If SSH is not listening where expected, we stop and resolve that before applying firewall rules.
Next, we create a dedicated admin group and ensure our current sudo-capable account is in it. This gives us a clean way to restrict SSH access to a known set of operators.
sudo groupadd -f sshadmins
sudo usermod -aG sshadmins "$USER"
id
The server now has an sshadmins group, and our current user is a member. The id output should show sshadmins listed.
Now we harden the SSH daemon configuration. We will keep it explicit and minimal: no root login, no password authentication, and only members of sshadmins can log in via SSH.
sudo cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%F)
sudo tee /etc/ssh/sshd_config >/dev/null <<'EOF'
# Managed configuration: secure baseline for enterprise environments
# Principle of least privilege: only approved admins, key-based auth, no root login.
Port 22
Protocol 2
# Listen on all interfaces by default; firewall and network segmentation control reachability.
# If we must bind to a specific management interface, we do it intentionally after validation.
ListenAddress 0.0.0.0
ListenAddress ::
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowGroups sshadmins
X11Forwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
ClientAliveInterval 300
ClientAliveCountMax 2
LogLevel VERBOSE
Subsystem sftp /usr/lib/openssh/sftp-server
EOF
sudo sshd -t
sudo systemctl restart ssh
We have now replaced the SSH configuration with a controlled baseline, validated syntax with sshd -t, and restarted the service. The key change is that only key-based logins are allowed, root cannot log in directly, and only members of sshadmins can access SSH.
We verify SSH is still listening and that the service is healthy.
systemctl status ssh --no-pager
ss -tulpn | grep -E '(:22)s' || true
If SSH is not listening or the service fails, we immediately revert using the backup file and restart SSH. We cover that in troubleshooting.
Step 4: Turn on host firewalling with least privilege using UFW
The enterprise firewall is necessary, but it is not sufficient. Host firewalling gives us a second control plane that travels with the server, survives network changes, and limits lateral movement. We will implement a default-deny stance and then allow only what operations require.
First, we install UFW and confirm it is available.
sudo apt-get -y install ufw
sudo ufw --version
UFW is now installed. Next, we identify the primary interface so we can reason about where traffic enters and exits.
EXT_IFACE=$(ip route show default 0.0.0.0/0 | awk '{print $5; exit}')
echo "$EXT_IFACE"
We have now captured the default-route interface into EXT_IFACE. This helps us validate that firewall behavior aligns with the server’s actual routing.
Now we set a conservative baseline: deny inbound by default, allow outbound by default, and log at a low level. This aligns with least privilege while keeping operations workable in most enterprise networks where outbound is controlled upstream.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw logging low
The firewall policy is now set. Inbound connections will be blocked unless explicitly allowed. Outbound is allowed at the host level, but enterprise egress controls still apply.
Next, we allow SSH. We do this before enabling the firewall to avoid locking ourselves out.
sudo ufw allow 22/tcp comment 'SSH administration'
SSH is now explicitly allowed through the host firewall.
Now we enable UFW and confirm status. Enabling applies the rules immediately and persists across reboots.
sudo ufw --force enable
sudo ufw status verbose
The host firewall is now active, persistent, and enforcing a default-deny inbound stance. The status output should show SSH allowed and default policies as configured.
Step 5: Reduce exposed services and confirm listening sockets
Firewalls are not a substitute for not running unnecessary services. The cleanest security win is to stop listening on ports we do not need. We will inventory listening sockets and disable what is not required.
sudo ss -tulpn
This output is our truth: it shows what is actually reachable on the network. If we see services we do not recognize (for example, a database listening on all interfaces), we either bind it to localhost, restrict it to an internal subnet, or disable it entirely.
We also confirm which services are enabled at boot, because persistence matters in production.
systemctl list-unit-files --type=service --state=enabled
We now have a list of services that will start automatically. This is where “temporary” agents often become permanent. We keep only what we need.
Step 6: Apply kernel and network hardening with sysctl
Behind firewalls, the most common network attacks are not exotic—they are lateral movement, spoofing attempts, and opportunistic scanning. Sysctl hardening helps the kernel behave defensively: it reduces information leakage, blocks certain redirect behaviors, and improves baseline resilience.
We will create a dedicated sysctl file so our changes are explicit, versionable, and persistent across reboots.
sudo tee /etc/sysctl.d/99-niilaa-hardening.conf >/dev/null <<'EOF'
# NIILAA baseline hardening - principle of least privilege
# Network protections
net.ipv4.ip_forward = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Reduce kernel info exposure
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.unprivileged_bpf_disabled = 1
# File system protections
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
EOF
sudo sysctl --system
We have now applied hardened kernel parameters and made them persistent via /etc/sysctl.d. The sysctl --system output shows which values were loaded.
We verify a few key values to ensure the system is actually enforcing them.
sysctl net.ipv4.ip_forward
sysctl net.ipv4.conf.all.accept_redirects
sysctl kernel.kptr_restrict
sysctl kernel.dmesg_restrict
These checks confirm that forwarding is off (unless we intentionally run a router), redirects are not accepted, and kernel information exposure is reduced.
Step 7: Make outbound dependencies explicit: DNS, time, and package egress
Most “behind the firewall” outages are outbound problems: DNS can’t resolve, NTP can’t sync, or package repositories are blocked. We do not solve this by loosening inbound rules. We solve it by making dependencies explicit and verifying them.
DNS verification
We confirm which resolver is in use and whether name resolution works. This matters because security controls often depend on DNS (updates, logging endpoints, internal service discovery).
resolvectl status || true
getent hosts deb.debian.org || true
getent hosts security.ubuntu.com || true
If name resolution fails, we typically have either a blocked path to internal DNS, an incorrect resolver configuration, or a split-DNS expectation that is not met on this subnet.
Time synchronization verification
Time drift breaks TLS, authentication, and log correlation. We verify that systemd-timesyncd (or another NTP client) is active and synchronized.
timedatectl
timedatectl timesync-status || true
systemctl status systemd-timesyncd --no-pager || true
If the clock is not synchronized, we work with the network team to ensure NTP is reachable to the approved time sources. We do not “fix” time by disabling validation or ignoring TLS errors.
Outbound connectivity verification
We validate that the server can reach required endpoints through the enterprise firewall. We keep this simple and observable.
ip route
curl -I https://example.com --max-time 10 || true
This confirms routing and basic HTTPS egress. If this fails, we investigate proxy requirements or egress restrictions rather than changing inbound exposure.
Step 8: Logging and auditability that survives real operations
Security without visibility is guesswork. We want logs that help us answer: who logged in, what changed, and what was blocked. We will ensure journald persistence and enable basic auditing.
First, we make journald persistent so logs survive reboots. This is critical when diagnosing incidents that involve restarts.
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo sed -i 's/^#?Storage=.*/Storage=persistent/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
Journald is now configured to store logs persistently under /var/log/journal. After restart, logs will survive reboots and provide continuity during investigations.
We verify journald is using persistent storage.
journalctl --disk-usage
grep -E '^s*Storage=' /etc/systemd/journald.conf
This confirms both configuration and actual disk usage for logs.
Next, we enable auditd for basic security auditing. This is a common enterprise expectation and helps with accountability.
sudo apt-get -y install auditd audispd-plugins
sudo systemctl enable --now auditd
sudo systemctl status auditd --no-pager
Auditd is now installed, enabled at boot, and running. We can later add organization-specific audit rules, but even the baseline improves traceability.
Step 9: Reboot-safe verification checklist
Production hardening is not complete until we prove it survives a reboot and still behaves as expected. We will validate the key controls: SSH, firewall, sysctl, time sync, and listening services.
We reboot during a controlled window and keep console access available.
sudo systemctl reboot
The server will restart. After reconnecting, we verify the state again.
set -euo pipefail
systemctl status ssh --no-pager
sudo ufw status verbose
sysctl net.ipv4.ip_forward
timedatectl
sudo ss -tulpn
If these checks pass, we have a stable baseline: controlled admin access, default-deny inbound firewalling, hardened kernel parameters, synchronized time, and a clear view of what is listening.
Troubleshooting
SSH access suddenly fails after hardening
- Symptom: SSH connection is refused or times out.
- Likely causes: UFW enabled without allowing SSH; SSH service failed to restart due to config error; we are not in the
sshadminsgroup; key-based auth not set up. - Fix: Use console access, then validate and revert safely.
sudo ufw status verbose
sudo ufw allow 22/tcp comment 'SSH administration'
sudo systemctl restart ssh
sudo systemctl status ssh --no-pager
# If sshd config is broken, revert to backup and restart
ls -1 /etc/ssh/sshd_config.bak.* | tail -n 1
sudo cp -a "$(ls -1 /etc/ssh/sshd_config.bak.* | tail -n 1)" /etc/ssh/sshd_config
sudo sshd -t
sudo systemctl restart ssh
We have now confirmed firewall allowance, restored a known-good SSH configuration if needed, and restarted SSH with syntax validation.
Package updates fail behind the firewall
- Symptom:
apt-get updatefails with timeouts or “Temporary failure resolving”. - Likely causes: DNS not reachable; proxy required; egress blocked to repositories.
- Fix: Confirm DNS and route first, then align with enterprise proxy/egress policy.
ip route
resolvectl status || true
getent hosts deb.debian.org || true
# If a proxy is required, we configure it explicitly (example file creation without embedding secrets)
sudo tee /etc/apt/apt.conf.d/90proxy >/dev/null <<'EOF'
# Acquire::http::Proxy "http://proxy.example:3128";
# Acquire::https::Proxy "http://proxy.example:3128";
EOF
We have now validated routing and DNS, and we have a safe place to configure proxy settings in a controlled way that aligns with enterprise standards.
Time is not synchronized
- Symptom: TLS errors, authentication failures, or
timedatectlshows “System clock synchronized: no”. - Likely causes: NTP blocked; wrong time sources; timesync service disabled.
- Fix: Ensure the service is enabled and confirm reachability to approved NTP sources.
systemctl enable --now systemd-timesyncd
systemctl status systemd-timesyncd --no-pager
timedatectl timesync-status || true
We have now ensured the time sync service is enabled and checked its status. If it still cannot sync, the remaining issue is almost always network policy, not the server.
Common mistakes
Mistake: Enabling the firewall before allowing SSH
- Symptom: Existing SSH session drops; new SSH connections time out.
- Fix: Use console access, allow SSH, then re-enable UFW.
sudo ufw allow 22/tcp comment 'SSH administration'
sudo ufw --force enable
sudo ufw status verbose
We have restored SSH reachability through the host firewall and confirmed the active rules.
Mistake: Locking out admins by restricting SSH groups
- Symptom: “Permission denied (publickey)” for valid keys, or login works for some accounts but not others.
- Fix: Ensure the account is in
sshadmins, then reconnect.
id
sudo usermod -aG sshadmins "$USER"
getent group sshadmins
We have confirmed group membership and ensured our account is authorized for SSH access under the least-privilege model.
Mistake: Assuming “behind the firewall” means services can bind anywhere
- Symptom: Unexpected listening ports appear in scans or internal monitoring; lateral movement risk increases.
- Fix: Inventory listening sockets and restrict services to localhost or required subnets, then enforce with UFW rules.
sudo ss -tulpn
sudo ufw status verbose
We have a clear view of what is listening and what the host firewall allows. From here, we tighten service bind addresses and firewall allowances to match actual requirements.
How do we at NIILAA look at this
This setup is not impressive because it is complex. It is impressive because it is controlled. Every component is intentional. Every configuration has a reason. This is how infrastructure should scale — quietly, predictably, and without drama.
At NIILAA, we help organizations design, deploy, secure, and maintain Linux server environments behind enterprise firewalls with clear standards: least privilege access, repeatable baselines, auditable change, and verification that holds up under real operations. We align host controls with network policy, reduce lateral movement risk, and keep systems maintainable as teams and environments grow.
Website: https://www.niilaa.com
Email: [email protected]
LinkedIn: https://www.linkedin.com/company/niilaa
Facebook: https://www.facebook.com/niilaa.llc