First boot
Bring a freshly installed Debian 13 host up to a working baseline — an administrative user, a hardened SSH server, and SSH keys synced from GitHub.
Overview
This guide explains how to take a Debian 13 (trixie) host from the state the installer leaves it in to a working baseline: an account with administrative rights, an SSH server that accepts keys and nothing else, and those keys kept in step with a GitHub account automatically.
Do the three sub-tasks in order. Each one depends on the one before it — there
is no point hardening SSH from an account that cannot run sudo, and no point
syncing keys onto a server you cannot reach.
Before you start
Before you set up a new host, ensure:
- Debian 13 is installed and you can log in, either at the console or over SSH.
- You can become root, either by logging in as root or with
su -. - You have an SSH key pair on the machine you connect from. If you do not,
ssh-keygen -t ed25519makes one. - You have console or physical access as a fallback. Most of what follows can lock you out of SSH if it goes wrong, and console access is the way back.
- You keep your working session open throughout. Every verification step below happens in a second terminal, so a mistake leaves you with a live session to undo it from.
Set up a new host
Add a user to sudoers
If you set a root password during installation, Debian deliberately left your
first user out of the sudo group. If you left the root password blank, the
installer added your user to sudo already and you can skip to the next
sub-task.
-
Become root.
bash su -Use the root password you set during installation.
-
Install
sudoif the system does not have it.bash apt update apt install sudoThe netinst and minimal images do not ship
sudo, so this is not the no-op it looks like. -
Add the account to the
sudogroup.bash usermod -aG sudo aliceDebian's administrative group is
sudo, not thewheelgroup used on Red Hat derivatives. The-amatters:usermod -G sudo alicewithout it replaces every one of the account's supplementary groups, silently dropping it from groups it needs. -
Have the user log out and log back in, then verify.
Group membership is fixed when a session is created, so an already-open shell keeps the groups it started with. After reconnecting, from the user's own session:
bash sudo id -unThis prints
root. Keep the root shell from step 1 open until you have seen that — it is the way back in if something is wrong.
Harden the SSH server
Debian 13 ships OpenSSH 10.0p1, whose /etc/ssh/sshd_config reads
Include /etc/ssh/sshd_config.d/*.conf on line 12. sshd takes the first
value it sees for any keyword, so a drop-in pulled in at line 12 overrides
anything set further down the shipped file. That is why none of this edits the
shipped file, and why a package upgrade cannot quietly revert it.
-
Install the server.
bash sudo apt install openssh-serverHost keys are generated on install and the service starts straight away.
-
Copy your public key to the account you will log in as.
bash ssh-copy-id [email protected]Do this before touching any configuration. Disabling password authentication with no working key installed is the most common way to lock yourself out of a host.
-
Confirm key authentication works on its own.
From a second terminal, forcing the client to use only the key:
bash ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no [email protected]You must reach a shell prompt without being asked for a password. If you are prompted or rejected, stop and fix the key — the next step removes the password fallback you are currently relying on.
-
Write the hardening drop-in.
bash sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF' PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes MaxAuthTries 3 LoginGraceTime 30 X11Forwarding no AllowUsers alice EOFReplace
alicewith the accounts that should be able to log in, separated by spaces.AllowUsersis an allowlist — once present, every account not named in it is refused regardless of its keys, so leaving your own account out locks you out. -
Validate the configuration before restarting anything.
bash sudo sshd -tSilence means it parses. Any output is an error with a file and line number, and you should fix it here — restarting with a broken configuration leaves
sshdrefusing to start and you outside the host. -
Find out whether socket activation is in play.
bash systemctl is-active ssh.socketDebian 13 ships both
ssh.serviceandssh.socket, and which one drives depends on how the host was installed and whether it was upgraded from Debian 12. The answer decides the next step, so check rather than assume. -
Restart the unit that is actually in charge.
If step 6 printed
active, systemd owns the listening socket:bash sudo systemctl restart ssh.socketIf it printed
inactive, the daemon owns it:bash sudo systemctl restart sshUnder socket activation,
PortandListenAddressinsshd_confighave no effect — systemd has already bound the port, andListenStream=22inssh.socketdecides it. Changing the port means a drop-in for the socket unit, not the file from step 4. Runningreloadwhile the socket is active fails withCannot bind any address, for the same reason. -
Verify the hardening from a new terminal.
Keep your working session open. In a second terminal, a normal login still works:
bash And a password login is refused outright:
bash ssh -o PubkeyAuthentication=no [email protected][email protected]: Permission denied (publickey).That message is the success condition. Only once you have seen both results should you close the original session.
Sync SSH keys from GitHub on a schedule
GitHub serves any account's public keys as plain text at
https://github.com/<account>.keys, with no authentication, so rotating a key
in one place replaces it on every host that pulls from it.
Be deliberate about the cost: anyone who can add a key to that GitHub account can log into this host. The account joins the host's trust boundary, so it needs two-factor authentication and it should be one you control. The keys are fetched on a schedule rather than during login, so a GitHub outage cannot lock you out — the host keeps the last set it successfully fetched.
-
Confirm the account publishes the keys you expect.
bash curl -fsSL https://github.com/beolson.keysThe reply is plain text, one key per line:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWKLDJnd77rO0phWHKO7NRJ... ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCWJb7/yu0bhGFpSwMMQ5DW...Read the list. Everything here becomes a key that can log into the host, so this is the moment to remove any you no longer recognise from the GitHub account itself. Install
curlfirst if it is missing:sudo apt install curl. -
Keep a break-glass key outside the managed file.
Make sure the account's
~/.ssh/authorized_keysalready holds a key you control locally — the one from the previous sub-task will do. Nothing below touches that file, which is the point: it is the way back in if the GitHub account is ever emptied or taken over. -
Tell
sshdto read a second, machine-managed key file.bash sudo tee /etc/ssh/sshd_config.d/20-authorized-keys.conf <<'EOF' AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys.github EOF sudo sshd -t sudo systemctl restart ssh.socket 2>/dev/null || sudo systemctl restart sshSplitting the managed keys into their own file is what keeps step 2's break-glass key safe: the timer only ever overwrites
authorized_keys.github. -
Write the sync script.
bash sudo tee /usr/local/sbin/sync-github-keys <<'EOF' #!/bin/bash set -euo pipefail user=${1:?usage: sync-github-keys <local-user> <github-account>} account=${2:?usage: sync-github-keys <local-user> <github-account>} home=$(getent passwd "$user" | cut -d: -f6) group=$(id -gn "$user") dest="$home/.ssh/authorized_keys.github" tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT curl -fsSL --max-time 20 "https://github.com/${account}.keys" -o "$tmp" # Refuse to install anything that is not a valid public key file. A reply # that is empty or an error page would otherwise revoke every key at once. ssh-keygen -l -f "$tmp" >/dev/null install -d -o "$user" -g "$group" -m 700 "$home/.ssh" install -o "$user" -g "$group" -m 600 "$tmp" "$dest" EOF sudo chmod 755 /usr/local/sbin/sync-github-keysThe
ssh-keygen -l -fline is the guard that matters.curl -falready turns a 404 into a non-zero exit, but a proxy or captive portal can answer200with a body that is not a key file, andset -ethen stops the script before that body reachesauthorized_keys.github. -
Run it once by hand.
bash sudo /usr/local/sbin/sync-github-keys alice beolsonThe first argument is the local Debian account, the second the GitHub account. Confirm the result:
bash sudo ls -l /home/alice/.ssh/authorized_keys.github-rw------- 1 alice alice 715 Aug 22 18:59 /home/alice/.ssh/authorized_keys.githubOwned by the user, mode
600.sshdignores an authorized keys file that is writable by group or other, so a wrong mode fails silently at login rather than loudly now. -
Create the service and timer units.
bash sudo tee /etc/systemd/system/sync-github-keys.service <<'EOF' [Unit] Description=Sync alice's authorized SSH keys from GitHub Wants=network-online.target After=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/sync-github-keys alice beolson EOF sudo tee /etc/systemd/system/sync-github-keys.timer <<'EOF' [Unit] Description=Sync alice's authorized SSH keys from GitHub hourly [Timer] OnCalendar=hourly RandomizedDelaySec=15m Persistent=true [Install] WantedBy=timers.target EOFPersistent=trueruns a missed sync once the host comes back up, so a machine that was off does not sit on stale keys. For a second account, copy these to differently named units rather than adding a secondExecStart— one failing fetch would otherwise stop the other account updating. -
Enable the timer and confirm it is scheduled.
bash sudo systemctl daemon-reload sudo systemctl enable --now sync-github-keys.timer systemctl list-timers sync-github-keys.timerNEXT LEFT LAST PASSED UNIT ACTIVATES Sat 2026-08-22 20:07:31 UTC 51min Sat 2026-08-22 19:03:12 UTC 12min sync-github-keys.timer sync-github-keys.serviceA
NEXTin the future means the schedule took. If a run ever fails the reason is injournalctl -u sync-github-keys.service, and because the script refuses to install a bad fetch, a failure leaves the previous keys in place rather than removing them.
See also
- sshd_config(5)
- sudoers(5)
- systemd.timer(5)
- Debian wiki: sudo
- First boot — the same task as one paste
- Set up Cockpit