Bootstrapping a new server: the first fifteen minutes

You have a fresh Ubuntu box and a root password in an email. Everything you know about infrastructure says configuration should be codified, reviewed, version-controlled and applied by a tool. And none of that helps yet, because Ansible needs SSH access, a user, and a Python interpreter — none of which exist.

This is the bootstrap paradox, and it is the reason the first fifteen minutes of a server's life are always a little manual. The goal is not to avoid that. It is to make the manual part small, ordered correctly, and finished quickly — because the moment that IP became public, it started receiving traffic you did not ask for.

The clock is real

Automated SSH scanning is continuous and indiscriminate. A newly allocated IP typically starts receiving login attempts within minutes, not hours — there is no grace period during which nobody has noticed your server, because nobody is looking for your server. They are looking for port 22 on every address there is.

You can watch it happen. After a day, on any box with a public IP:

sudo journalctl -u ssh --since "24 hours ago" | grep -ci "invalid user"

The number is usually in the hundreds or thousands. Those attempts began before you finished reading the provisioning email.

This does not mean panic. It means order matters: the things that close the door come before the things that make the server useful.

Minutes 0–2: get in, and make a user

ssh root@203.0.113.10

adduser lars
usermod -aG sudo lars

Working as root over SSH is the thing every subsequent step is designed to stop. Create the account you will actually use before anything else, because every later step assumes it exists.

If your provider gave you a root password rather than injecting a key, change it now — that password has been in an email, and it will be in that mailbox for years.

Minutes 2–5: keys, in the order that avoids lockout

This is where people lock themselves out, and it is entirely a sequencing problem.

From your local machine, not the server:

ssh-copy-id lars@203.0.113.10

Now — and this is the step that gets skipped — open a second terminal and log in with the key, while the first session stays open.

ssh lars@203.0.113.10
sudo -v

Two things are being verified: that key authentication works, and that sudo works for this user. Only once both succeed do you disable password authentication. The first session is your lifeline; if the new configuration is broken, you still have a shell to fix it from.

Every "I locked myself out of my VPS" story is this ordering done backwards. Keep the second terminal open until the very end.

Minutes 5–8: the firewall, before the services

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw enable

Deny-by-default first, then open what you need. The opposite order leaves a window — usually short, occasionally not — where a service is listening and unfiltered.

Use the OpenSSH application profile rather than allow 22. If you later move SSH to another port, you update the profile in one place instead of hunting for a hardcoded number.

Note what is not here: web ports. Do not open 80 and 443 until something is actually listening on them and configured. There is no benefit to opening a port early.

Minutes 8–12: hardening SSH, and the Ubuntu 24.04 trap

Drop-in files, not edits to sshd_config. The distribution owns that file and will overwrite it on upgrade:

sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
EOF

sudo sshd -t && sudo systemctl reload ssh

sshd -t validates the configuration before you reload. Skipping it is how a typo becomes an outage on a machine you can no longer reach.

Then test in your second terminal before closing the first.

The socket activation trap

If you plan to move SSH off port 22 — and it is worth knowing this even if you do not — Ubuntu changed how this works and most guides have not caught up.

Since Ubuntu 22.10, SSH is socket-activated. ssh.socket listens on port 22 and starts sshd only when a connection arrives, which saves memory on small VMs. The consequence is that Port and ListenAddress in sshd_config are not used, because systemd is doing the listening. You edit the file, restart the service, and it stubbornly stays on 22.

The reliable fix is a socket override:

sudo systemctl edit ssh.socket
[Socket]
ListenStream=
ListenStream=2222

The empty ListenStream= is essential — it clears the inherited value. Without it you listen on both ports, which is the opposite of the intent.

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ufw allow 2222/tcp

ss -tlnp | grep -E ':(22|2222)'

That last command is the one that matters. Reports differ on whether newer Ubuntu reads the port back from sshd_config, so do not trust any guide including this one — check what is actually listening.

Worth being honest about the value: moving SSH off 22 eliminates almost all automated scanning noise in your logs. It is not a security measure against anyone specifically targeting you, since a port scan finds it in seconds. Quieter logs are the real benefit, and quieter logs make genuine anomalies visible.

Minutes 12–15: patch, and make patching automatic

sudo apt update && sudo apt upgrade -y

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Security updates applying themselves is worth more than almost anything else on this page. The failure mode of a forgotten server is not a dramatic breach; it is eighteen months of unapplied CVEs.

Set the timezone and hostname while you are here, because certificates and log correlation both care:

sudo hostnamectl set-hostname web01.example.com
sudo timedatectl set-timezone Europe/Copenhagen
timedatectl status

Confirm the clock is synchronised. Certificate validation is time-sensitive, and clock drift produces errors that mention certificates and never mention time.

Now hand it over

Fifteen minutes in, you have exactly what a configuration management tool needs: a non-root user with sudo, key-only SSH, a closed firewall, and Python. Everything after this point belongs in a playbook.

ansible -i inventory/production.yml web -m ping

If that returns pong, stop configuring by hand. The LEMP playbook picks up precisely here — MySQL, PHP-FPM, nginx and certificates, all codified, all repeatable.

The test of whether you did this right is not that the server works. It is that you could destroy it and get an identical one back without consulting your shell history.

The better answer: don't do any of this

Everything above is a fallback. On any cloud provider, all fifteen minutes collapse into a cloud-init file supplied at creation time:

#cloud-config
users:
  - name: lars
    groups: [sudo]
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... lars@workstation

ssh_pwauth: false
disable_root: true

package_update: true
package_upgrade: true
packages:
  - ufw
  - unattended-upgrades

runcmd:
  - ufw default deny incoming
  - ufw default allow outgoing
  - ufw allow OpenSSH
  - ufw --force enable

timezone: Europe/Copenhagen

The server now boots into the hardened state. Password authentication is never enabled, root login is never possible, and the firewall is up before the first login attempt arrives — which closes the window the manual process leaves open, however fast you type.

It is also version-controllable, reviewable, and identical across every machine you create. Which is the same argument for configuration management, applied to the one part that configuration management cannot reach.

What not to do in the first fifteen minutes

Don't install fail2ban. With password authentication disabled, it is defending against an attack that cannot succeed. It adds a moving part and a way to ban yourself. Install it later if you have a specific reason.

Don't tune the kernel. Every sysctl-hardening blog post you find will be partly obsolete and partly wrong for your workload. Defaults are reasonable.

Don't install your application stack. That is what the playbook is for, and anything you install by hand now is drift you will have to reconcile later.

Don't skip the second terminal. It costs nothing and it is the difference between a mistake and a support ticket.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.