Skip to content

Provision a new VPS

This guide takes a freshly created cloud server, the kind Hetzner Cloud or DigitalOcean hands you with only root and your SSH key, to a hardened web server in three sadame apply runs: an administrator account, a security baseline (SSH on port 2222 with keys only, a firewall, fail2ban, automatic updates) and nginx serving a site. Each step is one of the examples, used as it is, and the output below comes from running exactly these commands against a fresh Debian 12 server.

What you need

  • sadame on your machine (see Install) and a checkout of the repository for the examples, or copies of examples/06-app-deploy-user, examples/07-hardening-baseline and examples/02-nginx-static-site;
  • an SSH key pair; the commands below use ~/.ssh/id_ed25519.pub;
  • a new server running Debian 12 or Ubuntu 24.04, reachable as root with that key.

1. Create the server

Create it with your public key, in the web console or with the provider's CLI. With Hetzner Cloud's hcloud, after hcloud ssh-key create --name me --public-key-from-file ~/.ssh/id_ed25519.pub:

hcloud server create --name vps1 --type cx22 --image debian-12 --ssh-key me   # types: hcloud server-type list

With DigitalOcean's doctl, using the fingerprint from doctl compute ssh-key list:

doctl compute droplet create vps1 --image debian-12-x64 --size s-1vcpu-1gb --region fra1 --ssh-keys FINGERPRINT

Note the server's IP address; the commands below write vps1.example.com in its place. Connect once with ssh root@ADDRESS to check the key works and to accept the host key.

2. An administrator instead of root

06-app-deploy-user creates a user with your key and a sudoers rule. Given ALL as its only command, the user is an administrator with passwordless sudo, which sadame needs:

$ sadame apply examples/06-app-deploy-user -h root@vps1.example.com --var "ssh_keys=$(cat ~/.ssh/id_ed25519.pub)" --var sudo_commands=ALL
[vps1.example.com] copying sadame-linux-arm64 to ~/.sadame/bin/dcf637ae7d2f79587cd36cc4fb47eff845444424933647a5e75565917475c21d/
[vps1.example.com] sadame 0.5.1 apply on vps1.example.com (Debian GNU/Linux 12 (bookworm))
[vps1.example.com]
[vps1.example.com] changed    package[sudo]
[vps1.example.com]            state: absent -> installed
[vps1.example.com] changed    user[deploy]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            shell: /bin/bash
[vps1.example.com] changed    directory[/home/deploy/.ssh]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0700
[vps1.example.com]            owner: deploy
[vps1.example.com] changed    file[/home/deploy/.ssh/authorized_keys]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0600
[vps1.example.com]            owner: deploy
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /home/deploy/.ssh/authorized_keys (desired)
[vps1.example.com]            @@ -0,0 +1,2 @@
[vps1.example.com]            +# Managed by sadame. Keys added by hand will be removed.
[vps1.example.com]            +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFWO3Nymy8E4ghq6ua2q2VpLuhKeGi+Q5Dqe2P1u++2F root@0ca1c9a9292f
[vps1.example.com] changed    file[/etc/sudoers.d/deploy]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0440
[vps1.example.com]            owner: root
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /etc/sudoers.d/deploy (desired)
[vps1.example.com]            @@ -0,0 +1,2 @@
[vps1.example.com]            +# Managed by sadame.
[vps1.example.com]            +deploy ALL=(root) NOPASSWD: ALL
[vps1.example.com]
[vps1.example.com] Summary
[vps1.example.com]   changed      5  package[sudo]
[vps1.example.com]                   user[deploy]
[vps1.example.com]                   directory[/home/deploy/.ssh]
[vps1.example.com]                   file[/home/deploy/.ssh/authorized_keys]
[vps1.example.com]                   file[/etc/sudoers.d/deploy]
[vps1.example.com]   unchanged    0
[vps1.example.com]   failed       0
[vps1.example.com]   skipped      0
[vps1.example.com]   notified     0
[vps1.example.com]
[vps1.example.com] Finished in 5.60s.

Hosts
  vps1.example.com  ok

apply on 1 host: 1 ok. Finished in 5.78s.

The first run copied sadame to the server (~/.sadame/bin/); later runs reuse it.

3. The security baseline

From now on, connect as deploy with --sudo. 07-hardening-baseline moves SSH to port 2222, turns off root and password logins, and lets only SSH and the ports in open_ports through the firewall:

$ sadame apply examples/07-hardening-baseline -h deploy@vps1.example.com --sudo --var open_ports=80,443
[vps1.example.com] copying sadame-linux-arm64 to ~/.sadame/bin/dcf637ae7d2f79587cd36cc4fb47eff845444424933647a5e75565917475c21d/
[vps1.example.com] sadame 0.5.1 apply on vps1.example.com (Debian GNU/Linux 12 (bookworm))
[vps1.example.com]
[vps1.example.com] unchanged  package[openssh-server]
[vps1.example.com] changed    package[ufw]
[vps1.example.com]            state: absent -> installed
[vps1.example.com] changed    package[fail2ban]
[vps1.example.com]            state: absent -> installed
[vps1.example.com] changed    package[unattended-upgrades]
[vps1.example.com]            state: absent -> installed
[vps1.example.com] unchanged  service[ssh]
[vps1.example.com] unchanged  exec[move ssh.socket to the new port]
[vps1.example.com] changed    file[/etc/ssh/sshd_config.d/10-hardening.conf]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0644
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /etc/ssh/sshd_config.d/10-hardening.conf (desired)
[vps1.example.com]            @@ -0,0 +1,7 @@
[vps1.example.com]            +# Managed by sadame.
[vps1.example.com]            +Port 2222
[vps1.example.com]            +PermitRootLogin no
[vps1.example.com]            +PasswordAuthentication no
[vps1.example.com]            +KbdInteractiveAuthentication no
[vps1.example.com]            +X11Forwarding no
[vps1.example.com]            +MaxAuthTries 3
[vps1.example.com] changed    exec[ufw default deny incoming]
[vps1.example.com]            command: ufw default deny incoming
[vps1.example.com]            (not_if exited non-zero)
[vps1.example.com] changed    exec[ufw allow 2222/tcp]
[vps1.example.com]            command: ufw allow 2222/tcp
[vps1.example.com]            (not_if exited non-zero)
[vps1.example.com] changed    exec[ufw allow 80/tcp]
[vps1.example.com]            command: ufw allow 80/tcp
[vps1.example.com]            (not_if exited non-zero)
[vps1.example.com] changed    exec[ufw allow 443/tcp]
[vps1.example.com]            command: ufw allow 443/tcp
[vps1.example.com]            (not_if exited non-zero)
[vps1.example.com] changed    exec[ufw --force enable]
[vps1.example.com]            command: ufw --force enable
[vps1.example.com]            (not_if exited non-zero)
[vps1.example.com] changed    file[/etc/fail2ban/jail.d/sshd.local]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0644
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /etc/fail2ban/jail.d/sshd.local (desired)
[vps1.example.com]            @@ -0,0 +1,7 @@
[vps1.example.com]            +# Managed by sadame.
[vps1.example.com]            +[sshd]
[vps1.example.com]            +enabled = true
[vps1.example.com]            +backend = systemd
[vps1.example.com]            +port = 2222
[vps1.example.com]            +maxretry = 5
[vps1.example.com]            +bantime = 1h
[vps1.example.com] changed    service[fail2ban]
[vps1.example.com]            state: stopped -> running
[vps1.example.com] unchanged  file[/etc/apt/apt.conf.d/20auto-upgrades]
[vps1.example.com] unchanged  service[apt-daily-upgrade.timer]
[vps1.example.com] notified   service[ssh]: restart (delayed, from file[/etc/ssh/sshd_config.d/10-hardening.conf])
[vps1.example.com] notified   exec[move ssh.socket to the new port]: run (delayed, from file[/etc/ssh/sshd_config.d/10-hardening.conf])
[vps1.example.com] notified   service[fail2ban]: restart (delayed, from file[/etc/fail2ban/jail.d/sshd.local])
[vps1.example.com]
[vps1.example.com] Summary
[vps1.example.com]   changed     11  package[ufw]
[vps1.example.com]                   package[fail2ban]
[vps1.example.com]                   package[unattended-upgrades]
[vps1.example.com]                   file[/etc/ssh/sshd_config.d/10-hardening.conf]
[vps1.example.com]                   exec[ufw default deny incoming]
[vps1.example.com]                   exec[ufw allow 2222/tcp]
[vps1.example.com]                   exec[ufw allow 80/tcp]
[vps1.example.com]                   exec[ufw allow 443/tcp]
[vps1.example.com]                   exec[ufw --force enable]
[vps1.example.com]                   file[/etc/fail2ban/jail.d/sshd.local]
[vps1.example.com]                   service[fail2ban]
[vps1.example.com]   unchanged    5
[vps1.example.com]   failed       0
[vps1.example.com]   skipped      0
[vps1.example.com]   notified     3  service[ssh]: restart
[vps1.example.com]                   exec[move ssh.socket to the new port]: run
[vps1.example.com]                   service[fail2ban]: restart
[vps1.example.com]
[vps1.example.com] Finished in 20.58s.

Hosts
  vps1.example.com  ok

apply on 1 host: 1 ok. Finished in 20.82s.

sshd restarted on the new port at the end of the run; the connection that ran it was kept, because ufw lets established connections through. On Ubuntu, where systemd starts sshd from ssh.socket, the recipe also moves the socket to the new port; restarting sshd alone would leave it on 22, behind the firewall.

4. The web server

From now on, the server is at port 2222:

$ sadame apply examples/02-nginx-static-site -h deploy@vps1.example.com:2222 --sudo --var server_name=vps1.example.com
[vps1.example.com] sadame 0.5.1 apply on vps1.example.com (Debian GNU/Linux 12 (bookworm))
[vps1.example.com]
[vps1.example.com] changed    package[nginx]
[vps1.example.com]            state: absent -> installed
[vps1.example.com] changed    service[nginx]
[vps1.example.com]            state: stopped -> running
[vps1.example.com] changed    file[/etc/nginx/nginx.conf]
[vps1.example.com]            --- /etc/nginx/nginx.conf
[vps1.example.com]            +++ /etc/nginx/nginx.conf (desired)
[vps1.example.com]            @@ -1,83 +1,17 @@
[vps1.example.com]            +# Managed by sadame. Local changes will be overwritten.
[vps1.example.com]             user www-data;
[vps1.example.com]             worker_processes auto;
[vps1.example.com]             pid /run/nginx.pid;
[vps1.example.com]             error_log /var/log/nginx/error.log;
[vps1.example.com]            -include /etc/nginx/modules-enabled/*.conf;
[vps1.example.com]             
[vps1.example.com]             events {
[vps1.example.com]            - worker_connections 768;
[vps1.example.com]            - # multi_accept on;
[vps1.example.com]            +  worker_connections 1024;
[vps1.example.com]             }
[vps1.example.com]             
[vps1.example.com]             http {
[vps1.example.com]            -
[vps1.example.com]            - ##
[vps1.example.com]            - # Basic Settings
[vps1.example.com]            - ##
[vps1.example.com]            -
[vps1.example.com]            - sendfile on;
[vps1.example.com]            - tcp_nopush on;
[vps1.example.com]            - types_hash_max_size 2048;
[vps1.example.com]            - # server_tokens off;
[vps1.example.com]            -
[vps1.example.com]            - # server_names_hash_bucket_size 64;
[vps1.example.com]            - # server_name_in_redirect off;
[vps1.example.com]            -
[vps1.example.com]            - include /etc/nginx/mime.types;
[vps1.example.com]            - default_type application/octet-stream;
[vps1.example.com]            -
[vps1.example.com]            - ##
[vps1.example.com]            - # SSL Settings
[vps1.example.com]            - ##
[vps1.example.com]            -
[vps1.example.com]            - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
[vps1.example.com]            - ssl_prefer_server_ciphers on;
[vps1.example.com]            -
[vps1.example.com]            - ##
[vps1.example.com]            - # Logging Settings
[vps1.example.com]            - ##
[vps1.example.com]            -
[vps1.example.com]            - access_log /var/log/nginx/access.log;
[vps1.example.com]            -
[vps1.example.com]            - ##
[vps1.example.com]            - # Gzip Settings
[vps1.example.com]            - ##
[vps1.example.com]            -
[vps1.example.com]            - gzip on;
[vps1.example.com]            -
[vps1.example.com]            - # gzip_vary on;
[vps1.example.com]            - # gzip_proxied any;
[vps1.example.com]            - # gzip_comp_level 6;
[vps1.example.com]            - # gzip_buffers 16 8k;
[vps1.example.com]            - # gzip_http_version 1.1;
[vps1.example.com]            - # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
[vps1.example.com]            -
[vps1.example.com]            - ##
[vps1.example.com]            - # Virtual Host Configs
[vps1.example.com]            - ##
[vps1.example.com]            -
[vps1.example.com]            - include /etc/nginx/conf.d/*.conf;
[vps1.example.com]            - include /etc/nginx/sites-enabled/*;
[vps1.example.com]            +  include /etc/nginx/mime.types;
[vps1.example.com]            +  default_type application/octet-stream;
[vps1.example.com]            +  sendfile on;
[vps1.example.com]            +  access_log /var/log/nginx/access.log;
[vps1.example.com]            +  include /etc/nginx/conf.d/*.conf;
[vps1.example.com]             }
[vps1.example.com]            -
[vps1.example.com]            -
[vps1.example.com]            -#mail {
[vps1.example.com]            -#    # See sample authentication script at:
[vps1.example.com]            -#    # http://wiki.nginx.org/ImapAuthenticateWithApachePhpScript
[vps1.example.com]            -#
[vps1.example.com]            -#    # auth_http localhost/auth.php;
[vps1.example.com]            -#    # pop3_capabilities "TOP" "USER";
[vps1.example.com]            -#    # imap_capabilities "IMAP4rev1" "UIDPLUS";
[vps1.example.com]            -#
[vps1.example.com]            -#    server {
[vps1.example.com]            -#        listen     localhost:110;
[vps1.example.com]            -#        protocol   pop3;
[vps1.example.com]            -#        proxy      on;
[vps1.example.com]            -#    }
[vps1.example.com]            -#
[vps1.example.com]            -#    server {
[vps1.example.com]            -#        listen     localhost:143;
[vps1.example.com]            -#        protocol   imap;
[vps1.example.com]            -#        proxy      on;
[vps1.example.com]            -#    }
[vps1.example.com]            -#}
[vps1.example.com] unchanged  directory[/etc/nginx/conf.d]
[vps1.example.com] changed    file[/etc/nginx/conf.d/static-site.conf]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0644
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /etc/nginx/conf.d/static-site.conf (desired)
[vps1.example.com]            @@ -0,0 +1,7 @@
[vps1.example.com]            +# Managed by sadame.
[vps1.example.com]            +server {
[vps1.example.com]            +  listen 80 default_server;
[vps1.example.com]            +  server_name vps1.example.com;
[vps1.example.com]            +  root /var/www/static-site;
[vps1.example.com]            +  index index.html;
[vps1.example.com]            +}
[vps1.example.com] changed    directory[/var/www/static-site]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0755
[vps1.example.com] changed    file[/var/www/static-site/index.html]
[vps1.example.com]            state: absent -> present
[vps1.example.com]            mode: 0644
[vps1.example.com]            --- /dev/null
[vps1.example.com]            +++ /var/www/static-site/index.html (desired)
[vps1.example.com]            @@ -0,0 +1,11 @@
[vps1.example.com]            +<!doctype html>
[vps1.example.com]            +<html lang="en">
[vps1.example.com]            +  <head>
[vps1.example.com]            +    <meta charset="utf-8">
[vps1.example.com]            +    <title>It works</title>
[vps1.example.com]            +  </head>
[vps1.example.com]            +  <body>
[vps1.example.com]            +    <h1>It works</h1>
[vps1.example.com]            +    <p>Served by nginx, configured by sadame.</p>
[vps1.example.com]            +  </body>
[vps1.example.com]            +</html>
[vps1.example.com] notified   service[nginx]: reload (delayed, from file[/etc/nginx/nginx.conf])
[vps1.example.com]
[vps1.example.com] Summary
[vps1.example.com]   changed      6  package[nginx]
[vps1.example.com]                   service[nginx]
[vps1.example.com]                   file[/etc/nginx/nginx.conf]
[vps1.example.com]                   file[/etc/nginx/conf.d/static-site.conf]
[vps1.example.com]                   directory[/var/www/static-site]
[vps1.example.com]                   file[/var/www/static-site/index.html]
[vps1.example.com]   unchanged    1
[vps1.example.com]   failed       0
[vps1.example.com]   skipped      0
[vps1.example.com]   notified     1  service[nginx]: reload
[vps1.example.com]
[vps1.example.com] Finished in 3.67s.

Hosts
  vps1.example.com  ok

apply on 1 host: 1 ok. Finished in 3.88s.

5. Check the result

drift on the baseline exits 0: everything is as the recipe says. Root can no longer log in, and port 22 no longer answers:

$ sadame drift examples/07-hardening-baseline -h deploy@vps1.example.com:2222 --sudo --var open_ports=80,443
[vps1.example.com] sadame 0.5.1 drift on vps1.example.com (Debian GNU/Linux 12 (bookworm))
[vps1.example.com]
[vps1.example.com] No drift: 16 resources match the recipe.

Hosts
  vps1.example.com  ok

drift on 1 host: 1 ok. Finished in 0.31s.

$ echo $?
0

$ ssh -p 2222 root@vps1.example.com true
root@vps1.example.com: Permission denied (publickey).

$ ssh -p 22 deploy@vps1.example.com true
ssh: connect to host vps1.example.com port 22: Connection timed out

Keep it in an inventory

Typing -h deploy@...:2222 --sudo gets old. Put the server in a hosts.rb, as examples/07-hardening-baseline/hosts-after.rb does, and use -g:

examples/07-hardening-baseline/hosts-after.rb
# After the first apply: SSH listens on vars[:ssh_port] (2222) only.
group :new do
  host "vps1.example.com", user: "admin", sudo: true, port: 2222
end

Then Drift in CI checks the server every morning.

Common mistakes

  • Running the baseline before the administrator account. The baseline turns off root login; without another user with a key and sudo, you are locked out.
  • Forgetting open_ports. The firewall only lets SSH in. A web server needs --var open_ports=80,443 (or open_ports: [80, 443] in vars.rb).
  • Connecting to port 22 after the baseline. Use HOST:2222, port: 2222 in hosts.rb, or Port 2222 in ~/.ssh/config.
  • The provider's own firewall. Hetzner and DigitalOcean also have firewalls outside the server; open ports 2222, 80 and 443 there too if you use them.

See also