Skip to content

Instantly share code, notes, and snippets.

@dargmuesli
Last active September 8, 2026 22:29
Show Gist options
  • Select an option

  • Save dargmuesli/645a4d51ab1806ebfb3329fb05637318 to your computer and use it in GitHub Desktop.

Select an option

Save dargmuesli/645a4d51ab1806ebfb3329fb05637318 to your computer and use it in GitHub Desktop.
Initial hardening/setup script for a fresh Debian VPS.
#!/usr/bin/env bash
#
# vserver-setup-debian.sh
# Initial hardening/setup script for a fresh Debian 12/13 VPS.
#
# USAGE:
# 1. Edit the CONFIG section below (username, SSH port, timezone, hostname).
# 2. Put your SSH public key in SSH_PUBKEY, or have it in root's
# authorized_keys already; the script refuses to run without one.
# 3. Run as root: bash vserver-setup-debian.sh
# It prompts for the new user's password, which sudo will ask for, and
# for a root password, which the provider's console will ask for.
# 4. Open a NEW terminal and verify you can log in as the new user via
# SSH key + new port BEFORE closing your current root session.
#
# Rerunning is safe: every step checks the current state first, changes nothing
# that is already correct, and restarts a service only when its config changed.
set -euo pipefail
# ─── CONFIG ──────────────────────────────────────────────────────────────
NEW_USER="deploy" # non-root user to create
SSH_PORT="2222" # change to 22 to keep default
TIMEZONE="Europe/Berlin"
HOSTNAME_NEW="" # new system hostname, e.g. "web01"; leave empty to keep the provider's
SET_ROOT_PASSWORD="yes" # yes/no - prompts for a root password used by the provider's console
SSH_PUBKEY="" # paste your public key here, e.g. "ssh-ed25519 AAAA... you@host"
ENABLE_SWAP="yes" # yes/no
SWAP_SIZE="2G"
# ─────────────────────────────────────────────────────────────────────────
# No firewall is configured here on purpose, see the closing notes for why.
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root." >&2
exit 1
fi
STAMP="$(date +%s)"
STATE_DIR="/var/lib/vserver-setup-debian"
mkdir -p "${STATE_DIR}"
# Writes stdin to a file only when the content differs, keeping a timestamped backup of what it replaced.
# Returns 0 when the file changed and 1 when it was already correct, so callers must use it inside an if.
write_if_changed() {
local dest="$1" tmp
tmp="$(mktemp)"
cat > "${tmp}"
if [[ -e "${dest}" ]] && cmp -s "${tmp}" "${dest}"; then
rm -f "${tmp}"
return 1
fi
if [[ -e "${dest}" ]]; then
cp -p "${dest}" "${dest}.bak.${STAMP}"
fi
cat "${tmp}" > "${dest}"
rm -f "${tmp}"
return 0
}
# Bounded so a non-interactive run fails loudly instead of spinning on a passwd that can never succeed.
prompt_password() {
local user="$1" why="$2" attempt
for attempt in 1 2 3; do
if passwd "${user}"; then
return 0
fi
if [[ "${attempt}" != 3 ]]; then
echo " Password not set, please try again."
fi
done
echo "ERROR: could not set a password for '${user}'." >&2
echo "${why}" >&2
exit 1
}
echo "==> Updating system packages"
apt-get update
# Left interactive on purpose: if the upgrade wants to replace a config file, that is your call to make.
apt-get -y full-upgrade
echo "==> Installing base packages"
apt-get install -y sudo git fail2ban
if [[ -n "${HOSTNAME_NEW}" ]]; then
echo "==> Setting hostname to ${HOSTNAME_NEW}"
hostnamectl set-hostname "${HOSTNAME_NEW}"
# Without a matching 127.0.1.1 entry sudo and various daemons stall while resolving the new name.
if grep -qE '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
sed -i -E "s|^127\.0\.1\.1[[:space:]].*|127.0.1.1\t${HOSTNAME_NEW}|" /etc/hosts
else
printf '127.0.1.1\t%s\n' "${HOSTNAME_NEW}" >> /etc/hosts
fi
fi
echo "==> Creating user '${NEW_USER}' (skipped if already exists)"
if ! id "${NEW_USER}" &>/dev/null; then
adduser --disabled-password --gecos "" "${NEW_USER}"
else
echo " User already exists, skipping creation."
fi
# Runs unconditionally so a pre-existing account (provider image, earlier partial run) also gets sudo.
usermod -aG sudo "${NEW_USER}"
# adduser --disabled-password leaves '!' in the shadow field, which makes every sudo call fail.
# With root login disabled that would leave no way back in, so insist on a real password here.
if [[ "$(passwd -S "${NEW_USER}" | awk '{print $2}')" != "P" ]]; then
echo "==> Setting a password for '${NEW_USER}' (sudo will ask for it)"
prompt_password "${NEW_USER}" "Without one sudo cannot work, and root login is about to be disabled."
else
echo "==> '${NEW_USER}' already has a password, keeping it"
fi
# Root already has a password on most provider images, so the shadow field cannot tell us whether it is
# still the generated one that was mailed around. A marker records that this script asked, which keeps
# reruns from prompting again while a first run always does.
ROOT_PW_MARKER="${STATE_DIR}/root-password-set"
if [[ "${SET_ROOT_PASSWORD}" == "yes" ]]; then
if [[ -e "${ROOT_PW_MARKER}" ]]; then
echo "==> Root password was set by an earlier run, skipping"
echo " Delete ${ROOT_PW_MARKER} to be asked again."
else
echo "==> Setting the root password"
echo " Root SSH login gets disabled below, so this is only for the provider's console."
echo " That console is the way back in if SSH ever breaks, so use something you can retrieve."
prompt_password root "The console would be left with a password you did not choose."
touch "${ROOT_PW_MARKER}"
fi
fi
echo "==> Setting up SSH key for ${NEW_USER}"
USER_HOME="$(getent passwd "${NEW_USER}" | cut -d: -f6)"
AUTH_KEYS="${USER_HOME}/.ssh/authorized_keys"
mkdir -p "${USER_HOME}/.ssh"
chmod 700 "${USER_HOME}/.ssh"
if [[ -n "${SSH_PUBKEY}" ]]; then
# Make sure the file ends with a newline so the appended key cannot glue itself onto the last one.
if [[ -s "${AUTH_KEYS}" && -n "$(tail -c1 "${AUTH_KEYS}")" ]]; then
echo >> "${AUTH_KEYS}"
fi
echo "${SSH_PUBKEY}" >> "${AUTH_KEYS}"
# Also what keeps a rerun from adding the same key twice.
sort -u -o "${AUTH_KEYS}" "${AUTH_KEYS}"
elif [[ -s /root/.ssh/authorized_keys ]]; then
echo " No SSH_PUBKEY set, copying root's authorized_keys instead."
cp /root/.ssh/authorized_keys "${AUTH_KEYS}"
fi
if [[ ! -s "${AUTH_KEYS}" ]]; then
echo "ERROR: no authorized_keys for '${NEW_USER}' and none to copy from root." >&2
echo "Set SSH_PUBKEY in the CONFIG section, otherwise disabling password auth locks you out." >&2
exit 1
fi
chmod 600 "${AUTH_KEYS}"
chown -R "${NEW_USER}:${NEW_USER}" "${USER_HOME}/.ssh"
echo "==> Hardening SSH config"
SSHD_CONFIG="/etc/ssh/sshd_config"
SSHD_CONFIG_D="/etc/ssh/sshd_config.d"
HARDENING_CONF="${SSHD_CONFIG_D}/00-hardening.conf"
mkdir -p "${SSHD_CONFIG_D}"
HARDENING_CONF_EXISTED=0
if [[ -e "${HARDENING_CONF}" ]]; then
HARDENING_CONF_EXISTED=1
fi
rollback_sshd() {
local b
if [[ "${HARDENING_CONF_EXISTED}" == 0 ]]; then
rm -f "${HARDENING_CONF}"
fi
for b in "${SSHD_CONFIG}.bak.${STAMP}" "${SSHD_CONFIG_D}"/*.bak."${STAMP}"; do
[[ -f "${b}" ]] || continue
mv "${b}" "${b%.bak."${STAMP}"}"
done
}
ssh_changed=0
# sshd takes the FIRST value it sees for a keyword and the stock config includes sshd_config.d near the top,
# so a provider drop-in such as 50-cloud-init.conf silently beats anything written into the main file.
# Writing 00-hardening.conf makes these settings sort ahead of every other drop-in and win.
#
# Port is the exception: it is additive, so a leftover "Port 22" anywhere would keep 22 listening next to the new port.
# Comment out every existing Port line before writing ours.
# On a rerun those lines are already commented, so nothing matches and no second backup is made.
for f in "${SSHD_CONFIG}" "${SSHD_CONFIG_D}"/*.conf; do
[[ -f "${f}" && "${f}" != "${HARDENING_CONF}" ]] || continue
if grep -qE '^[[:space:]]*Port[[:space:]]+' "${f}"; then
cp -p "${f}" "${f}.bak.${STAMP}"
sed -i -E 's|^([[:space:]]*Port[[:space:]]+)|#\1|' "${f}"
ssh_changed=1
fi
done
if write_if_changed "${HARDENING_CONF}" << EOF
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
then
ssh_changed=1
fi
chmod 644 "${HARDENING_CONF}"
if ! sshd -t; then
rollback_sshd
echo "ERROR: sshd rejected the new config, rolled back and changed nothing." >&2
exit 1
fi
# Trust the parser, not the file: sshd -T prints what the daemon will actually use, includes and all.
# This runs on every pass, so a rerun re-checks the result even when it changed nothing.
effective() { sshd -T | awk -v k="$1" 'tolower($1)==k {print $2}'; }
ports="$(effective port | paste -sd, -)"
if [[ "${ports}" != "${SSH_PORT}" ]]; then
rollback_sshd
echo "ERROR: expected sshd to listen on ${SSH_PORT} only, got '${ports}'. Rolled back." >&2
exit 1
fi
for check in "permitrootlogin no" "passwordauthentication no"; do
key="${check% *}" want="${check#* }"
got="$(effective "${key}")"
if [[ "${got}" != "${want}" ]]; then
rollback_sshd
echo "ERROR: ${key} is '${got}', expected '${want}'. Rolled back." >&2
exit 1
fi
done
# Under socket activation systemd owns the listening port and sshd ignores the Port directive,
# so hand the port back to sshd itself before restarting.
if systemctl is-enabled --quiet ssh.socket 2>/dev/null || systemctl is-active --quiet ssh.socket 2>/dev/null; then
echo " ssh.socket is in use, switching to ssh.service so the Port directive applies."
systemctl disable --now ssh.socket
systemctl enable ssh.service
ssh_changed=1
fi
if [[ "${ssh_changed}" == 1 ]] || ! systemctl is-active --quiet ssh; then
systemctl restart ssh
echo " SSH now listening on port ${SSH_PORT} only, root login disabled, password auth disabled."
else
echo " SSH config already correct, left the running daemon alone."
fi
echo "==> Configuring fail2ban"
# Debian's jail.d/defaults-debian.conf already picks banaction = nftables, which fail2ban depends on directly,
# so bans work without a host firewall package being installed.
if write_if_changed /etc/fail2ban/jail.local << EOF
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
bantime = 1h
findtime = 10m
EOF
then
systemctl restart fail2ban
else
echo " jail.local already correct, not restarting."
fi
systemctl is-enabled --quiet fail2ban || systemctl enable fail2ban
systemctl is-active --quiet fail2ban || systemctl start fail2ban
echo "==> Enabling automatic security updates"
# The APT::Periodic settings below are what actually schedule the work, run by the stock apt-daily timers.
# The unattended-upgrades service itself only holds up shutdown while an upgrade is still running.
if write_if_changed /etc/apt/apt.conf.d/20auto-upgrades << EOF
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
then
echo " Wrote 20auto-upgrades."
else
echo " 20auto-upgrades already correct."
fi
systemctl is-enabled --quiet unattended-upgrades || systemctl enable unattended-upgrades
systemctl is-active --quiet unattended-upgrades || systemctl start unattended-upgrades
echo "==> Setting timezone to ${TIMEZONE}"
timedatectl set-timezone "${TIMEZONE}"
if [[ "${ENABLE_SWAP}" == "yes" ]]; then
if [[ ! -e /swapfile ]]; then
echo "==> Creating ${SWAP_SIZE} swap file"
fallocate -l "${SWAP_SIZE}" /swapfile
chmod 600 /swapfile
mkswap /swapfile
fi
# Checked separately from the creation above so a run interrupted partway still converges.
if ! swapon --show=NAME --noheadings 2>/dev/null | grep -qx /swapfile; then
swapon /swapfile
fi
if ! grep -qE '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab; then
printf '/swapfile none swap sw 0 0\n' >> /etc/fstab
fi
fi
echo ""
echo "=================================================================="
echo " Setup complete."
echo ""
echo " IMPORTANT: Before closing this session, open a NEW terminal and"
echo " confirm you can log in with:"
echo ""
echo " ssh -p ${SSH_PORT} ${NEW_USER}@<server-ip>"
echo ""
echo " Then check that sudo works there: sudo -v"
echo ""
echo " Root login and password authentication are now disabled."
echo " If the new login fails, DO NOT close this session, fix it first."
echo ""
echo " ------------------------------------------------------------"
echo " STILL TO DO: this box has NO firewall. Nothing filters inbound"
echo " traffic yet, so pick one of these."
echo ""
echo " 1. A network firewall in your provider's console, inbound:"
echo " - ${SSH_PORT}/tcp from your address if it is static, else anywhere"
echo " - 80/tcp, 443/tcp only if this box serves web traffic"
echo " It filters ahead of the host, so Docker cannot punch through it."
echo ""
echo " 2. A tunnel, which needs no inbound ports at all. cloudflared or"
echo " tailscale dial out from this box and you reach services through"
echo " that connection, so the firewall above can deny everything"
echo " inbound, including ${SSH_PORT}/tcp once the tunnel is proven to work."
echo " Nothing on the public internet can then reach this machine."
echo ""
echo " Avoid ufw here: Docker writes its own rules and published container"
echo " ports bypass it, which makes it look protective without being it."
echo " If you use it anyway, publish ports as 127.0.0.1:PORT:PORT and put"
echo " a reverse proxy in front."
echo " ------------------------------------------------------------"
echo "=================================================================="
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment