Last active
September 8, 2026 22:29
-
-
Save dargmuesli/645a4d51ab1806ebfb3329fb05637318 to your computer and use it in GitHub Desktop.
Initial hardening/setup script for a fresh Debian VPS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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