hero4hire
Debian 13

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.

linuxsecurityUnverified

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 ed25519 makes 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.

  1. Become root.

    bash
    su -

    Use the root password you set during installation.

  2. Install sudo if the system does not have it.

    bash
    apt update
    apt install sudo

    The netinst and minimal images do not ship sudo, so this is not the no-op it looks like.

  3. Add the account to the sudo group.

    bash
    usermod -aG sudo alice

    Debian's administrative group is sudo, not the wheel group used on Red Hat derivatives. The -a matters: usermod -G sudo alice without it replaces every one of the account's supplementary groups, silently dropping it from groups it needs.

  4. 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 -un

    This 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.

  1. Install the server.

    bash
    sudo apt install openssh-server

    Host keys are generated on install and the service starts straight away.

  2. 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.

  3. 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.

  4. 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
    EOF

    Replace alice with the accounts that should be able to log in, separated by spaces. AllowUsers is 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.

  5. Validate the configuration before restarting anything.

    bash
    sudo sshd -t

    Silence 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 sshd refusing to start and you outside the host.

  6. Find out whether socket activation is in play.

    bash
    systemctl is-active ssh.socket

    Debian 13 ships both ssh.service and ssh.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.

  7. Restart the unit that is actually in charge.

    If step 6 printed active, systemd owns the listening socket:

    bash
    sudo systemctl restart ssh.socket

    If it printed inactive, the daemon owns it:

    bash
    sudo systemctl restart ssh

    Under socket activation, Port and ListenAddress in sshd_config have no effect — 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, not the file from step 4. Running reload while the socket is active fails with Cannot bind any address, for the same reason.

  8. 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.

  1. Confirm the account publishes the keys you expect.

    bash
    curl -fsSL https://github.com/beolson.keys

    The 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 curl first if it is missing: sudo apt install curl.

  2. Keep a break-glass key outside the managed file.

    Make sure the account's ~/.ssh/authorized_keys already 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.

  3. Tell sshd to 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 ssh

    Splitting 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.

  4. 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-keys

    The ssh-keygen -l -f line is the guard that matters. curl -f already turns a 404 into a non-zero exit, but a proxy or captive portal can answer 200 with a body that is not a key file, and set -e then stops the script before that body reaches authorized_keys.github.

  5. Run it once by hand.

    bash
    sudo /usr/local/sbin/sync-github-keys alice beolson

    The 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.github

    Owned by the user, mode 600. sshd ignores an authorized keys file that is writable by group or other, so a wrong mode fails silently at login rather than loudly now.

  6. 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
    EOF

    Persistent=true runs 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 second ExecStart — one failing fetch would otherwise stop the other account updating.

  7. 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.timer
    NEXT                        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.service

    A NEXT in the future means the schedule took. If a run ever fails the reason is in journalctl -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

On this page