Log
·

Hardening a Forge-Provisioned Server

One of my shared Hetzner boxes runs several production Laravel apps, provisioned through Forge like most of my servers. Forge gets you a long way — firewall, SSH keys, PHP-FPM pools, Nginx vhosts — but a few defaults on the underlying Ubuntu 24.04 image are looser than they need to be. None of these are exotic; they're the kind of thing you'd catch in any general server-hardening checklist. I wrote a script to close them and ran it against that box.

PostgreSQL listens on every interface

Default provisioning ships `listen_addresses = '*'` in postgresql.conf, even though every app on the box connects over localhost. pg_hba.conf compounds it with a blanket `host all all 0.0.0.0/0 md5` rule — anyone who reaches port 5432 can attempt password auth against any database on the server. The loopback-only rules Forge also ships are the only ones actually needed.

sed -i "s/^listen_addresses = .*/listen_addresses = 'localhost'/" "$PG_CONF"
sed -i "/^host[[:space:]]\+all[[:space:]]\+all[[:space:]]\+0\.0\.0\.0\/0[[:space:]]\+md5/d" "$PG_HBA"
systemctl restart postgresql

Memcached binds to 0.0.0.0 too

Same story, different service: the IPv6 line in memcached.conf is correctly loopback-only (`-l ::1`), but the IPv4 line ships as `-l 0.0.0.0`. Memcached has no built-in auth, so the firewall is the only thing standing between "internal cache" and "anyone on the internet with read/write access to it" — worth not depending on that being the sole layer.

sed -i 's/^-l 0\.0\.0\.0/-l 127.0.0.1/' /etc/memcached.conf
systemctl restart memcached

SSH ships X11 forwarding enabled

Unused on a headless server, on by default, zero functional cost to disable. A small drop-in in sshd_config.d keeps it out of the main config file Forge manages.

cat > /etc/ssh/sshd_config.d/50-harden.conf << 'EOF'
X11Forwarding no
EOF
sshd -t && systemctl reload ssh.service

One sysctl tweak

This host isn't a router, so it shouldn't be sending ICMP redirects.

cat > /etc/sysctl.d/60-harden-send-redirects.conf << 'EOF'
net.ipv4.conf.all.send_redirects = 0
EOF
sysctl -p /etc/sysctl.d/60-harden-send-redirects.conf

What I deliberately left alone

Hardening scripts have a habit of turning into checklists nobody reads, so I only wanted things that don't cost anything to change. Two defaults I left untouched, on purpose:

  • PermitRootLogin prohibit-password — Forge's own worker key (worker@forge.laravel.com) is trusted for root on provisioned servers, and it's what powers panel features that fall outside the site-user's sudoers whitelist: SSL/Let's Encrypt, the Firewall tab, Daemons, database management, PHP version installs, Recipes. Setting PermitRootLogin no breaks all of that. Password auth for root was already disabled globally, so this isn't a real exposure either way — just a Forge-integration dependency that isn't obvious until you go looking for it.
  • Redis without requirepass — it's already loopback-only and firewalled by default. Adding a shared password wouldn't add real isolation on a single-OS-user box, since every app would just end up storing the same secret in its .env anyway.

The full script

Idempotent, safe to re-run, and skips gracefully on servers that don't run a given service. Run as root, or via sudo, right after provisioning.

#!/usr/bin/env bash

# forge-hardening.sh
#
# A handful of hardening gaps found on a default Forge-provisioned Ubuntu
# 24.04 server (single-site-user, PostgreSQL + Memcached + Redis stack).
# None of these should affect Forge's own management of the box or any
# deployed site — every change was verified against a live server with
# several production Laravel apps before/after.
#
# Idempotent: safe to re-run. Run as root (or via sudo).

set -euo pipefail

echo "==> 1/4: PostgreSQL — restrict to localhost"
PG_CONF=$(find /etc/postgresql -maxdepth 2 -name postgresql.conf 2>/dev/null | sort -V | tail -1)
PG_HBA=$(find /etc/postgresql -maxdepth 2 -name pg_hba.conf 2>/dev/null | sort -V | tail -1)

if [[ -n "${PG_CONF:-}" && -n "${PG_HBA:-}" ]]; then
    cp -n "$PG_CONF" "${PG_CONF}.bak-$(date +%Y%m%d)"
    cp -n "$PG_HBA" "${PG_HBA}.bak-$(date +%Y%m%d)"
    sed -i "s/^listen_addresses = .*/listen_addresses = 'localhost'/" "$PG_CONF"
    sed -i "/^host[[:space:]]\+all[[:space:]]\+all[[:space:]]\+0\.0\.0\.0\/0[[:space:]]\+md5/d" "$PG_HBA"
    systemctl restart postgresql
else
    echo "    postgresql not installed on this server, skipping"
fi

echo "==> 2/4: Memcached — bind to loopback only"
if [[ -f /etc/memcached.conf ]]; then
    cp -n /etc/memcached.conf /etc/memcached.conf.bak-"$(date +%Y%m%d)"
    sed -i 's/^-l 0\.0\.0\.0/-l 127.0.0.1/' /etc/memcached.conf
    systemctl restart memcached
else
    echo "    memcached not installed on this server, skipping"
fi

echo "==> 3/4: SSH — disable unused X11 forwarding"
mkdir -p /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/50-harden.conf << 'EOF'
X11Forwarding no
EOF
sshd -t
systemctl reload ssh.service 2>/dev/null || systemctl reload sshd.service

echo "==> 4/4: sysctl — disable ICMP send_redirects"
cat > /etc/sysctl.d/60-harden-send-redirects.conf << 'EOF'
net.ipv4.conf.all.send_redirects = 0
EOF
sysctl -p /etc/sysctl.d/60-harden-send-redirects.conf

echo "==> Done."

None of this is groundbreaking — it's the same handful of items you'd find on any general Ubuntu hardening checklist. But it's exactly the kind of thing that's easy to forget between spinning up a new server and deploying the first site to it, which is why it's now a script instead of a mental checklist. If you run Forge, it'd make a solid addition to the default provisioning script.