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.
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
sudogroup, or you know the root password and can runsu -. - 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 withsu -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 athttps://login.tailscale.com/admin/settings/keys. Leave it blank to runtailscale upyourself later. The prompt does not echo.
The script
It asks its four questions first, then runs unattended for a minute or two
of apt.
(
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 sudo — sudo -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
- First boot — the same task as a walkthrough
- Mozilla: OpenSSH security guidelines
- sshd_config(5)
- ssh-import-id(1)
- systemd.timer(5)
- Tailscale: Debian setup