hero4hire
RunbooksDebian 13 x64

First boot

Take a fresh Debian 13 host to a working baseline in one paste — an administrative user, an SSH server hardened to Mozilla's Modern profile, keys imported from GitHub daily, and optionally Tailscale.

Debian 13 x64linuxsecurityUnverified

Overview

This runbook explains how to bring a freshly installed Debian 13 (trixie) host up to a working baseline.

Afterwards the machine has one account with administrative rights, an SSH server that accepts public keys and nothing else, that account's keys refreshed from a GitHub account once a day, and — if you asked for it — a Tailscale client on the tailnet. Password logins and root logins over SSH are gone.

The same commands hold on any x64 Debian 13 host. They do not hold on Debian 12: trixie is the first release where ssh.socket drives the SSH server by default, and the Tailscale repository is pinned to trixie.

First boot walks the same ground one command at a time, and explains the reasoning as it goes. Read that one the first time; paste this one after that.

Before you start

Before you run this, ensure:

  • Debian 13 is installed and you can log in, at the console or over SSH.
  • You can become root — either you are already in the sudo group, or you know the root password and can run su -.
  • You have console or physical access as a fallback. The script disables password authentication, and console access is the way back if the key you kept turns out not to work.
  • You have already put a public key in the target account's ~/.ssh/authorized_keys, or you are supplying a GitHub account below. The script refuses to harden a host where neither is true, but it refuses after installing packages rather than before.
  • You keep this session open once the script finishes. It prints two commands to run from a second terminal; only when both behave should you close the first.

The script asks you for:

  • ADMIN_USER — the account that gets administrative rights and whose keys are managed. Defaults to whoever invoked the script, which is right unless you reached root with su - from somewhere else.
  • GH_ACCOUNT — a GitHub account whose published SSH keys may log in. Leave it blank to skip. Anyone who can add a key to that account can log into this host, so it needs to be one you control, with two-factor authentication on it.
  • WANT_TAILSCALE — whether to install Tailscale.
  • TS_AUTHKEY — a Tailscale auth key, if you want the host to join the tailnet unattended. Generate one at https://login.tailscale.com/admin/settings/keys. Leave it blank to run tailscale up yourself later. The prompt does not echo.

The script

It asks its four questions first, then runs unattended for a minute or two of apt.

bash
(
  set -euo pipefail

  # ---- Everything it asks for. Nothing here changes the machine. ----

  DEFAULT_USER=${SUDO_USER:-$(logname 2>/dev/null || id -un)}
  read -rp "Administrative user [$DEFAULT_USER]: " ADMIN_USER
  ADMIN_USER=${ADMIN_USER:-$DEFAULT_USER}
  id "$ADMIN_USER" >/dev/null || { echo "No such account: $ADMIN_USER" >&2; exit 1; }
  [[ "$ADMIN_USER" != "root" ]] || { echo "Name the account you log in as, not root." >&2; exit 1; }

  read -rp "GitHub account to import SSH keys from (blank to skip): " GH_ACCOUNT

  read -rp "Install Tailscale? [y/N]: " WANT_TAILSCALE
  TS_AUTHKEY=""
  if [[ "$WANT_TAILSCALE" == [Yy]* ]]; then
    read -rsp "Tailscale auth key (blank to authenticate by hand later): " TS_AUTHKEY
    echo
  fi

  SUDO="sudo"
  if [[ "$EUID" -eq 0 ]]; then
    SUDO=""
  elif ! sudo -v; then
    echo "This account cannot sudo yet. Run 'su -', then paste this again." >&2
    exit 1
  fi

  # ---- Everything below here changes the machine. ----

  echo "==> Installing packages"
  export DEBIAN_FRONTEND=noninteractive
  $SUDO apt-get update
  $SUDO apt-get install -y sudo openssh-server openssh-sftp-server ca-certificates curl
  if [[ -n "$GH_ACCOUNT" ]]; then
    $SUDO apt-get install -y ssh-import-id
  fi

  echo "==> Giving $ADMIN_USER administrative rights"
  $SUDO usermod -aG sudo "$ADMIN_USER"

  ADMIN_HOME=$(getent passwd "$ADMIN_USER" | cut -d: -f6)
  ADMIN_GROUP=$(id -gn "$ADMIN_USER")
  $SUDO install -d -o "$ADMIN_USER" -g "$ADMIN_GROUP" -m 700 "$ADMIN_HOME/.ssh"

  if [[ -n "$GH_ACCOUNT" ]]; then
    echo "==> Installing the daily GitHub key import"

    $SUDO tee /usr/local/sbin/import-github-keys >/dev/null <<'SCRIPT'
#!/bin/bash
# Replace a local account's machine-managed authorized keys with whatever the
# GitHub account publishes right now.
set -euo pipefail

user=${1:?usage: import-github-keys <local-user> <github-account>}
account=${2:?usage: import-github-keys <local-user> <github-account>}

home=$(getent passwd "$user" | cut -d: -f6)
group=$(id -gn "$user")

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

ssh-import-id -o - "gh:$account" >"$tmp"

# Refuse to install anything that is not a valid public key file: an empty or
# malformed fetch would otherwise revoke every managed key at once.
ssh-keygen -l -f "$tmp" >/dev/null

install -o "$user" -g "$group" -m 600 "$tmp" "$home/.ssh/authorized_keys.github"
SCRIPT
    $SUDO chmod 755 /usr/local/sbin/import-github-keys
    $SUDO /usr/local/sbin/import-github-keys "$ADMIN_USER" "$GH_ACCOUNT"

    $SUDO tee /etc/systemd/system/import-github-keys.service >/dev/null <<UNIT
[Unit]
Description=Import $ADMIN_USER's authorized SSH keys from GitHub
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/import-github-keys $ADMIN_USER $GH_ACCOUNT
UNIT

    $SUDO tee /etc/systemd/system/import-github-keys.timer >/dev/null <<'UNIT'
[Unit]
Description=Import authorized SSH keys from GitHub daily

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
UNIT

    $SUDO tee /etc/ssh/sshd_config.d/20-authorized-keys.conf >/dev/null <<'CONF'
AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.github
CONF

    $SUDO systemctl daemon-reload
    $SUDO systemctl enable --now import-github-keys.timer
  fi

  echo "==> Checking $ADMIN_USER has a key to log in with"
  KEY_COUNT=$(cat "$ADMIN_HOME/.ssh/authorized_keys" \
                  "$ADMIN_HOME/.ssh/authorized_keys.github" 2>/dev/null |
              grep -cvE '^[[:space:]]*(#|$)' || true)
  if [[ "$KEY_COUNT" -eq 0 ]]; then
    echo "$ADMIN_USER has no authorized keys, so hardening now would lock" >&2
    echo "every account out of SSH. From your workstation, run" >&2
    echo "  ssh-copy-id $ADMIN_USER@$(hostname)" >&2
    echo "then paste this script again." >&2
    exit 1
  fi

  echo "==> Hardening sshd ($KEY_COUNT authorized key(s) found)"
  $SUDO tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'CONF'
# Mozilla OpenSSH security guidelines, "Modern" profile, with the
# post-quantum key exchange OpenSSH 10 defaults to kept in front.
# https://infosec.mozilla.org/guidelines/openssh.html

KexAlgorithms mlkem768x25519-sha256,[email protected],[email protected],ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256
Ciphers [email protected],[email protected],[email protected],aes256-ctr,aes192-ctr,aes128-ctr
MACs [email protected],[email protected],[email protected],hmac-sha2-512,hmac-sha2-256,[email protected]

AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no

LogLevel VERBOSE
Subsystem sftp /usr/lib/openssh/sftp-server -f AUTHPRIV -l INFO
CONF

  for algo in ed25519 rsa ecdsa; do
    if [[ -f "/etc/ssh/ssh_host_${algo}_key" ]]; then
      echo "HostKey /etc/ssh/ssh_host_${algo}_key" |
        $SUDO tee -a /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null
    fi
  done

  $SUDO cp -n /etc/ssh/sshd_config /etc/ssh/sshd_config.dist
  $SUDO sed -i 's|^Subsystem[[:space:]]|#&|' /etc/ssh/sshd_config

  echo "==> Dropping Diffie-Hellman moduli below 3072 bits"
  $SUDO cp -n /etc/ssh/moduli /etc/ssh/moduli.dist
  awk '$5 >= 3071' /etc/ssh/moduli.dist | $SUDO tee /etc/ssh/moduli.new >/dev/null
  if [[ -s /etc/ssh/moduli.new ]]; then
    $SUDO mv /etc/ssh/moduli.new /etc/ssh/moduli
  else
    $SUDO rm -f /etc/ssh/moduli.new
    echo "    none are 3072 bits or more; left /etc/ssh/moduli alone" >&2
  fi

  echo "==> Validating and restarting sshd"
  $SUDO /usr/sbin/sshd -t
  if systemctl is-active --quiet ssh.socket; then
    $SUDO systemctl restart ssh.socket
  else
    $SUDO systemctl restart ssh
  fi

  if [[ "$WANT_TAILSCALE" == [Yy]* ]]; then
    echo "==> Installing Tailscale"
    curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.noarmor.gpg |
      $SUDO tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
    curl -fsSL https://pkgs.tailscale.com/stable/debian/trixie.tailscale-keyring.list |
      $SUDO tee /etc/apt/sources.list.d/tailscale.list >/dev/null
    $SUDO apt-get update
    $SUDO apt-get install -y tailscale
    $SUDO systemctl enable --now tailscaled
    if [[ -n "$TS_AUTHKEY" ]]; then
      $SUDO tailscale up --auth-key="$TS_AUTHKEY"
    fi
  fi

  HOST=$(hostname -f 2>/dev/null || hostname)

  echo
  echo "Done."
  echo "  $ADMIN_USER is in the sudo group - log out and back in to pick it up."
  echo "  sshd accepts public keys only, from $KEY_COUNT authorized key(s)."
  if [[ -n "$GH_ACCOUNT" ]]; then
    echo "  import-github-keys.timer refreshes gh:$GH_ACCOUNT daily."
  fi
  if [[ "$WANT_TAILSCALE" == [Yy]* && -z "$TS_AUTHKEY" ]]; then
    echo "  Tailscale is installed but not logged in: run 'sudo tailscale up'."
  fi
  echo
  echo "Check from a SECOND terminal, before you close this one:"
  echo "  ssh $ADMIN_USER@$HOST                             # must succeed"
  echo "  ssh -o PubkeyAuthentication=no $ADMIN_USER@$HOST  # must be refused"
)

How it works

Working out how to become root

SUDO is set to sudo or to nothing, so every privileged line reads $SUDO something and you can still see at a glance which lines act as root. The empty case exists because of the order this runbook runs in: the first thing it does is put an account in the sudo group, so on a host where the installer set a root password, the person pasting this cannot sudo yet and has arrived by way of su -. When neither is true — not root, not yet in sudosudo -v fails at the top and the script stops before installing anything.

DEFAULT_USER comes from SUDO_USER when you got here through sudo, and from logname otherwise. logname reports the account that opened the login session rather than the one the shell is running as, so it still says alice inside a root shell reached with su -. That is why the default is usually right and why the script refuses root outright: managing root's keys is pointless once PermitRootLogin no lands.

Installing packages

openssh-sftp-server is named explicitly even though openssh-server only recommends it. The hardened configuration hard-codes /usr/lib/openssh/sftp-server, which that package owns, and a host installed with --no-install-recommends would otherwise fail sshd -t later on a path that does not exist.

DEBIAN_FRONTEND=noninteractive keeps apt from opening a dialog on a terminal you have walked away from.

Granting administrative rights

usermod -aG sudo "$ADMIN_USER" adds one supplementary group. The -a carries real weight: usermod -G sudo alice without it replaces every supplementary group the account has, silently dropping it from the rest. Debian's administrative group is sudo, not the wheel group used on Red Hat derivatives.

Group membership is fixed when a session is created, so the account's existing shells keep the groups they started with. Whoever is affected has to log out and back in before sudo works for them.

Importing keys from GitHub

ssh-import-id -o - "gh:$account" asks https://api.github.com/users/<account>/keys for the keys that account publishes and writes them to standard output. Two details matter. Using - rather than a filename sidesteps the tool's normal behaviour, which is to append to ~/.ssh/authorized_keys and never remove anything — that would mean a key you deleted at GitHub kept working here forever. Writing a fresh file from each fetch instead makes revocation propagate: whatever GitHub no longer publishes is gone from the host the next day. The API is unauthenticated and rate-limited to 60 requests an hour per address, which a daily timer is nowhere near.

ssh-keygen -l -f "$tmp" is the guard. ssh-import-id already exits non-zero on a 404 or an empty key list, but a captive portal or proxy can answer 200 with a body that is not a key file, and set -euo pipefail stops the script before that body reaches the account's keys. Only a file ssh-keygen can fingerprint gets installed, at mode 600 owned by the account — sshd ignores an authorized keys file that is group- or world-writable, and does so silently at login rather than loudly here.

The keys land in authorized_keys.github, not authorized_keys, and 20-authorized-keys.conf tells sshd to read both. That split is the whole point: the timer only ever overwrites the managed file, so a key you put in authorized_keys by hand is your way back in if the GitHub account is ever emptied or taken over. Keep one there.

OnCalendar=daily fires at midnight; RandomizedDelaySec=1h scatters the fetch across the following hour so a fleet of hosts does not hit GitHub together. Persistent=true runs a missed sync once the machine comes back up, so a host that was switched off does not sit on stale keys. If a run ever fails, the reason is in journalctl -u import-github-keys.service, and because the script refuses to install a bad fetch, a failure leaves yesterday's keys in place rather than removing them.

Refusing to lock you out

KEY_COUNT counts the non-blank, non-comment lines across both authorized keys files. If it is zero, the script stops before writing any sshd configuration. Everything it has done up to that point — installed packages, added a group, installed the timer — is harmless on its own, and none of it has yet changed how you authenticate. Fix the key and paste the script again; every step in it is safe to repeat.

Hardening sshd

Debian's /etc/ssh/sshd_config reads Include /etc/ssh/sshd_config.d/*.conf at the top of the file, and sshd takes the first value it sees for any keyword. A drop-in included at line 13 therefore beats anything set further down the shipped file, which is why almost nothing here edits that file and why a package upgrade cannot quietly revert it.

The three algorithm lines are Mozilla's Modern profile as published, with one deliberate change: mlkem768x25519-sha256 and [email protected] are prepended to KexAlgorithms. Mozilla's list predates post-quantum key exchange, and OpenSSH 10 — which is what trixie ships — negotiates mlkem768x25519-sha256 by default. Pasting Mozilla's list verbatim would remove that and leave the session key recoverable by anyone recording traffic today against a quantum computer later. Everything after those two entries is Mozilla's list unchanged.

AuthenticationMethods publickey is the line that actually enforces key-only login. PasswordAuthentication no on its own leaves PAM's keyboard-interactive path open, which is why KbdInteractiveAuthentication no is there too, and why the guarantee is stated a third time in terms sshd checks before any method runs. From this point ssh-copy-id no longer works against this host — a new key has to arrive through the GitHub account, or be written to authorized_keys from a session you already have.

PermitRootLogin no closes direct root login. Sessions already open are unaffected, so a root shell you are holding survives the restart.

LogLevel VERBOSE makes sshd log the fingerprint of the key each login used, which is the difference between knowing an account logged in and knowing which key did it.

The HostKey lines are appended in a loop rather than written flat, because sshd -t fails outright on a HostKey naming a file that does not exist. Listing only the keys the installer actually generated keeps a host missing one of the three from failing validation. Naming them at all replaces sshd's built-in default order, putting Ed25519 ahead of RSA.

Subsystem is the one keyword where first-wins does not apply: a second definition is a fatal parse error, not an override. The shipped file defines sftp at line 118, so that line is commented out — the only edit to /etc/ssh/sshd_config in the whole script, taken after a cp -n to sshd_config.dist. Expect a prompt about the modified file on the next openssh-server upgrade. Reverting all of the above is rm /etc/ssh/sshd_config.d/10-hardening.conf plus restoring sshd_config.dist.

Filtering the moduli

/etc/ssh/moduli holds the Diffie-Hellman groups sshd offers for diffie-hellman-group-exchange-sha256, and Debian ships some as small as 2048 bits. awk '$5 >= 3071' keeps only the rows whose fifth field — the size, recorded as one less than the bit length — is 3072 bits or more, as Mozilla requires. The filter reads from moduli.dist, the copy taken before the first run, so pasting the script twice cannot filter an already filtered file down to nothing. The -s check refuses to install an empty result, since a moduli file with no usable groups breaks that key exchange entirely. Restore with cp /etc/ssh/moduli.dist /etc/ssh/moduli.

Restarting the right unit

sshd -t parses the whole configuration and exits silently when it is valid. Running it before the restart is what keeps a typo from leaving sshd refusing to start with you outside the host.

Debian 13 ships both ssh.service and ssh.socket, and which one holds the listening socket depends on how the host was installed and whether it was upgraded from Debian 12 — so the script asks rather than assumes. Under socket activation, Port and ListenAddress in sshd_config have no effect, because systemd has already bound the port and ListenStream=22 in ssh.socket decides it; changing the port means a drop-in for the socket unit instead. Either way, restarting does not disturb sessions that are already established.

Installing Tailscale

The keyring is fetched as .noarmor.gpg, which is the binary form apt wants in /usr/share/keyrings, so nothing has to shell out to gpg to dearmor it. The matching .list file names that keyring in signed-by=, which scopes the key to this one repository — a key dropped in /etc/apt/trusted.gpg.d instead would be trusted to sign packages from every repository the host has.

Both URLs are pinned to trixie rather than derived from /etc/os-release, which is the same claim the folder this runbook sits in makes: it was written for Debian 13.

tailscale up --auth-key registers the host without a browser. The key is read with read -rsp so it stays out of your scrollback and out of the shell history — it is a credential that can add machines to your tailnet, and it is worth generating as single-use and short-lived. With no key, the package is installed and tailscaled is running but the host is not on the tailnet until someone runs sudo tailscale up and follows the URL it prints.

See also

On this page