Server provisioning for a kamal setup

There are a number of things you want to do to secure your VPS before you install your application with Kamal. By default, Kamal only installs docker and a private network called kamal for you.

You want to do the following:

  1. Have unattended upgrades on your server
  2. Lock down the root user
  3. Use a custom user for ssh and deployment
  4. Use fail2ban to ensure activity from suspicious IP addresses are blocked automatically 
  5. Use a firewall to prevent port scans and such
There is a lot to unpack so let's dive into it. I have taken great inspiration from Josef Strzibny's work with Kamal Handbook and Deployment from Scratch

This is the provisioning script I use to bootstrap a destination:

lang-ruby
#!/usr/bin/env ruby
# Provision a virtual private server for Kamal deployments on Ubuntu 22+ LTS.
#
# This script relies on SSH keys authentication.
#
# Usage: ./provision.rb <destination>
# Example: ./provision.rb production
#
# Make sure to add your private key first:
#   % ssh-add ~/path/to/ssh/key

require "net/ssh"
require "kamal"
require "optparse"
require "yaml"
require "erb"

def parse_arguments
  options = {}
  OptionParser.new do |opts|
    opts.banner = "Usage: #{$0} <destination>"

    opts.on("-h", "--help", "Show this help message") do
      puts opts
      exit
    end
  end.parse!

  if ARGV.empty?
    puts "Error: Destination argument is required"
    puts "Usage: #{$0} <destination>"
    puts "Example: #{$0} production"
    exit 1
  end

  ARGV[0]
end

def validate_config_file(config_path)
  unless File.exist?(config_path)
    puts "Error: Base configuration file not found: #{config_path}"
    puts "Make sure config/deploy.yml exists"
    exit 1
  end
end

def load_config(base_config_path, destination)
  config_file = Pathname.new(base_config_path)

  # Let Kamal handle the config loading and merging
  Kamal::Configuration.create_from(
    config_file: config_file,
    destination: destination
  )
end

def run_command(ssh, command, description)
  puts "#{description}..."
  result = ssh.exec!(command)
  if result && result.strip != ""
    puts "Output: #{result.strip}"
  end
end

# Server setup commands
COMMANDS = {
  install_essentials: {
    description: "Installing essential packages",
    command: <<~'SH'
      apt update && apt install -y build-essential curl
    SH
  },
  prepare_data: {
    description: "Preparing storage for disk service",
    command: <<~'SH'
      mkdir -p /data/storage;
      chmod 700 /data/storage;
      chown 1000:1000 /data/storage

      mkdir -p /data/redis;
      chmod 700 /data/redis;
      chown 1000:1000 /data/redis

      mkdir -p /data/postgres;
      chmod 700 /data/postgres;
      chown 1000:1000 /data/postgres
    SH
  },
  add_swap: {
    description: "Adding swap space",
    command: <<~'SH'
      if [ ! -f /swapfile ]; then
        fallocate -l 2GB /swapfile;
        chmod 600 /swapfile;
        mkswap /swapfile;
        swapon /swapfile;
        echo "\n/swapfile swap swap defaults 0 0\n" >> /etc/fstab;
        sysctl vm.swappiness=20;
        echo "\nvm.swappiness=20\n" >> /etc/sysctl.conf
      else
        echo "Swap file already exists"
      fi
    SH
  },
  install_fail2ban: {
    description: "Installing and running fail2ban",
    command: <<~'SH'
      apt install -y fail2ban;
      systemctl start fail2ban;
      systemctl enable fail2ban
    SH
  },
  configure_firewall: {
    description: "Configuring firewall",
    command: <<~'SH'
      ufw logging on;
      ufw default deny incoming;
      ufw default allow outgoing;
      ufw allow 22;
      ufw allow 80;
      ufw allow 443;
      ufw --force enable;
      systemctl restart ufw
    SH
  },
  disable_root: {
    description: "Disabling root",
    command: <<~'SH'
      sed -i 's@PasswordAuthentication yes@PasswordAuthentication no@g' /etc/ssh/sshd_config;
      sed -i 's@PermitRootLogin yes@PermitRootLogin no@g' /etc/ssh/sshd_config;
      chage -E 0 root;
      systemctl restart ssh
    SH
  }
}

def add_user_command(user_name)
  {
    description: "Adding user with sudo privileges",
    command: <<~SH
      if ! id -u #{user_name} >/dev/null 2>&1; then
        useradd --create-home #{user_name};
        su - #{user_name} -c 'mkdir -p ~/.ssh';
        su - #{user_name} -c 'touch ~/.ssh/authorized_keys';
        cat /root/.ssh/authorized_keys >> /home/#{user_name}/.ssh/authorized_keys;
        chmod 700 /home/#{user_name}/.ssh;
        chmod 600 /home/#{user_name}/.ssh/authorized_keys;
        echo '#{user_name} ALL=(ALL:ALL) NOPASSWD: ALL' | tee /etc/sudoers.d/#{user_name};
        chmod 0440 /etc/sudoers.d/#{user_name};
        visudo -c -f /etc/sudoers.d/#{user_name}
      else
        echo "User #{user_name} already exists"
      fi
    SH
  }
end

def install_docker_command(user_name)
  {
    description: "Installing and configuring Docker",
    command: <<~SH
      if ! command -v docker &> /dev/null; then
        curl -fsSL https://get.docker.com | sh;
        systemctl enable docker;
        systemctl start docker;
      fi

      if ! getent group docker >/dev/null; then
        groupadd docker;
      fi

      if ! docker network ls | grep -q kamal; then
        docker network create kamal;
      fi

      usermod -aG docker #{user_name}
    SH
  }
end

begin
  destination = parse_arguments

  # Get server IP and user name from config/deploy.yml
  base_config_path = File.expand_path("config/deploy.yml")
  validate_config_file(base_config_path)

  config = load_config(base_config_path, destination)

  hosts = config.roles.map(&:hosts).flatten + config.accessories.map(&:hosts).flatten
  hosts.uniq!
  user_name = config.ssh.user

  hosts.each do |host|
    puts "\nProvisioning server '#{host}' with user '#{user_name}'..."

    Net::SSH.start(host, "root") do |ssh|
      # Run base commands
      COMMANDS.each do |_key, cmd|
        run_command(ssh, cmd[:command], cmd[:description])
      end

      # Run user-specific commands
      run_command(ssh, add_user_command(user_name)[:command], add_user_command(user_name)[:description])
      run_command(ssh, install_docker_command(user_name)[:command], install_docker_command(user_name)[:description])
    end
  end

  puts "\nDone!"
  puts "Remember to log in as '#{user_name}' from now on:"
  puts "  ssh #{user_name}@#{hosts.first}"

rescue OptionParser::InvalidOption => e
  puts "Error: #{e.message}"
  exit 1
rescue Net::SSH::AuthenticationFailed
  puts "Error: SSH authentication failed. Make sure your SSH key is added:"
  puts "  ssh-add ~/path/to/ssh/key"
  exit 1
rescue StandardError => e
  puts "Error: #{e.message}"
  puts e.backtrace
  exit 1
end